Skip to content
Snippets Groups Projects
Commit e8c23e8e authored by Sébastien DA ROCHA's avatar Sébastien DA ROCHA
Browse files

Merge branch 'development' into 'master'

Development

See merge request !12
parents b938c64b 3bb95509
Branches master
Tags v3.1.0
1 merge request!12Development
Pipeline #30285 passed
Showing
with 27971 additions and 7474 deletions
......@@ -6,4 +6,9 @@ node_modules
/dist
.vscode
\ No newline at end of file
.vscode
*.sw[op]
src/.DS_Store
src/authentication/.DS_Store
.DS_Store
FROM node:12.13-slim
FROM node:14-slim
# Create app directory
WORKDIR /app
......
......@@ -7,32 +7,30 @@ services:
ports:
- ${AUTHENTICATION_API_BIND_PORT}:3000
environment:
# - POST_LOGOUT_REDIRECT_URI=${POST_LOGOUT_REDIRECT_URI}
# - REDIRECT_URI=${REDIRECT_URI}
# - OIDC_DISCOVERY_URL=${OIDC_DISCOVERY_URL}
# - OIDC_CLIENT_ID=${OIDC_CLIENT_ID}
# - OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET}
# - GLC_DISCOVERY_URL=${GLC_DISCOVERY_URL}
# - GLC_CLIENT_ID=${GLC_CLIENT_ID}
# - GLC_CLIENT_SECRET=${GLC_CLIENT_SECRET}
- LEGACY_MIDDLEWARE_URL=${LEGACY_MIDDLEWARE_URL}
- COOKIE_DOMAIN=${COOKIE_DOMAIN}
- ACCESS_TOKEN_COOKIE_KEY=${ACCESS_TOKEN_COOKIE_KEY}
- KONG_URL=${KONG_URL}
- JWT_LIFETIME=${JWT_LIFETIME} # in seconds
# - POST_LOGOUT_REDIRECT_URI: ${POST_LOGOUT_REDIRECT_URI}
LEGACY_MIDDLEWARE_URL: ${LEGACY_MIDDLEWARE_URL}
KONG_URL: ${KONG_URL}
JWT_LIFETIME: ${JWT_LIFETIME} # in seconds
COOKIE_DOMAIN: ${COOKIE_DOMAIN}
ACCESS_TOKEN_COOKIE_KEY: ${ACCESS_TOKEN_COOKIE_KEY}
REDIS_SENTINEL_HOST: ${REDIS_SENTINEL_HOST}
REDIS_SENTINEL_PORT: ${REDIS_SENTINEL_PORT}
REDIS_GROUP_NAME: ${REDIS_GROUP_NAME}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID}
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET}
OIDC_AUTH_URL: ${OIDC_AUTH_URL}
OIDC_TOKEN_URL: ${OIDC_TOKEN_URL}
OIDC_USER_INFO: ${OIDC_USER_INFO}
REDIRECT_URI: ${REDIRECT_URI}
USE_STRICT_SSL: "true"
restart: unless-stopped
# depends_on:
# - redis
# redis:
# image: redis:5.0.0-alpine
# command: ["redis-server", "--appendonly", "yes"]
# hostname: redis
# volumes:
# - redis-data:/data
# restart: unless-stopped
# volumes:
# redis-data:
redis:
ports:
- 6379:6379
image: redis:5.0.0-alpine
command: ["redis-server", "--appendonly", "yes"]
hostname: redis
restart: unless-stopped
This diff is collapsed.
......@@ -21,28 +21,32 @@
},
"dependencies": {
"@godaddy/terminus": "^4.1.0",
"@nestjs/common": "^5.1.0",
"@nestjs/core": "^5.1.0",
"@nestjs/swagger": "^2.5.1",
"@nestjs/terminus": "^5.5.0",
"@nestjs/common": "^6.0.0",
"@nestjs/config": "^0.6.0",
"@nestjs/core": "^6.0.0",
"@nestjs/swagger": "^3.0.0",
"@nestjs/terminus": "^6.0.0",
"bluebird": "^3.5.3",
"class-transformer": "^0.2.0",
"class-validator": "^0.9.1",
"cookie-parser": "^1.4.4",
"cookies": "^0.7.3",
"dotenv": "^6.1.0",
"install": "^0.13.0",
"ioredis": "^4.10.0",
"jsonwebtoken": "^8.3.0",
"redis": "^2.8.0",
"npm": "^7.12.1",
"reflect-metadata": "^0.1.12",
"request": "^2.88.0",
"request-promise-native": "^1.0.5",
"rxjs": "^6.2.2",
"serve-favicon": "^2.5.0",
"swagger-ui-express": "^4.1.6",
"typescript": "^3.0.1",
"uuid": "^3.3.2"
},
"devDependencies": {
"@nestjs/testing": "^5.1.0",
"@nestjs/testing": "^6.0.0",
"@types/express": "^4.16.0",
"@types/jest": "^23.3.1",
"@types/node": "^10.7.1",
......@@ -75,4 +79,4 @@
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}
\ No newline at end of file
}
import { Body, Controller, Get, HttpException, HttpStatus, InternalServerErrorException, Post, Put, Req, Res } from '@nestjs/common';
import { Body, Controller, Get, HttpException, HttpStatus, InternalServerErrorException, Post, Put, Req, Res, Param, Query} from '@nestjs/common';
import { ApiImplicitHeader, ApiOperation, ApiResponse, ApiUseTags } from '@nestjs/swagger';
import { Response } from 'express';
import { handleError } from '../helpers/errorHandlingHelper';
import { ConfigService } from '../configuration/config.service';
import { handleError } from '../helpers';
import { JWTTokenInfo, LoginForm, LoginResponse, UserInfoUpdateResponse, UserInfoWithoutPassword, UserUpdateForm } from './authentication.model';
import { JWTTokenInfo, LoginForm, LoginResponse, UserInfoUpdateResponse, UserInfoWithoutPassword, UserUpdateForm} from './authentication.model';
import { AuthenticationService } from './authentication.service';
import { TokenService } from './token.service';
@ApiUseTags('authentication')
......@@ -12,6 +14,7 @@ import { AuthenticationService } from './authentication.service';
export class AuthenticationController {
constructor(
private _authService: AuthenticationService,
private tokenService: TokenService,
private _configService: ConfigService,
) { }
......@@ -43,7 +46,7 @@ export class AuthenticationController {
{
domain: this._configService.config.cookieDomain,
expires: cookieExpiresAt,
sameSite: 'Strict',
sameSite: 'strict',
},
).status(HttpStatus.OK).send({ userInfo: loginResult.userInfo });
} catch (error) {
......@@ -76,42 +79,66 @@ export class AuthenticationController {
).status(HttpStatus.OK).send(true);
}
// @Get('/login/:identityProvider')
// @ApiOperation({ title: 'Login to the specified provider.' })
// @ApiResponse({ status: 302, description: 'Redirection to the appropriate url allowing the user to connect to the identity provider.' })
// @ApiResponse({
// status: 500, description: 'Occurs when the service couldn\'t get the identity provider configuration from the discovery endpoint.'
// })
// @HttpCode(302)
// async login(@Param('identityProvider') identityProvider: string, @Res() res) {
// try {
// const idp = identityProvider.toUpperCase(); // By convention the provider is always uppecased in config and in var env
// const loginUrl = await this._authService.login(idp);
// res.redirect(302, loginUrl);
// } catch (error) {
// // As we the @Res we need to use it to return an error too, because with the @Res nest is not automatically catching errors
// // as when we don't use @Res decorator
// if (error instanceof HttpException) {
// res.status(error.getStatus()).send(error.message);
// } else {
// res.status(500).send(new InternalServerErrorException());
// }
// }
// }
@Get('/login/:identityProvider')
@ApiOperation({ title: 'Login to the specified provider.' })
@ApiResponse({ status: 302, description: 'Redirection to the appropriate url allowing the user to connect to the identity provider.' })
@ApiResponse({
status: 500, description: 'Occurs when the service couldn\'t get the identity provider configuration from the discovery endpoint.'
})
//@HttpCode(302)
async login(@Param('identityProvider') identityProvider: string, @Res() res) {
try {
const idp = identityProvider.toUpperCase(); // By convention the provider is always uppecased in config and in var env
const loginUrl = await this._authService.login(idp);
res.redirect(302, loginUrl);
} catch (error) {
// As we the @Res we need to use it to return an error too, because with the @Res nest is not automatically catching errors
// as when we don't use @Res decorator
if (error instanceof HttpException) {
res.status(error.getStatus()).send(error.message);
} else {
res.status(500).send(new InternalServerErrorException());
}
}
}
// @Get('/token')
// @ApiOperation({ title: 'Exchange code against signed JWT containing user information.' })
// @ApiResponse({ status: 200, description: 'JWT generated', type: TokenResponse })
// @ApiResponse({ status: 400, description: 'The request failed for the reason indicated in the body of response.' })
// @ApiResponse({ status: 500, description: 'The connection to redis server failed.' })
// async getTokens(@Query('code') code: string, @Query('state') state: string): Promise<TokenResponse> {
// try {
// const token = await this._authService.getToken(code, state);
// return { token };
// } catch (error) {
// handleError(error, new InternalServerErrorException());
// }
// }
@Get('/token')
@ApiOperation({ title: 'Exchange code against signed JWT containing user information.' })
@ApiResponse({ status: 302, description: 'Redirect to home page'})
@ApiResponse({ status: 400, description: 'The request failed for the reason indicated in the body of response.' })
@ApiResponse({ status: 500, description: 'The connection to redis server failed.' })
async getTokens(@Query('code') code: string, @Query('state') state: string, @Res() res: Response): Promise<void> {
try {
const token = await this._authService.getToken(code, state);
const cookieExpiresAt = new Date();
cookieExpiresAt.setDate(cookieExpiresAt.getDate() + 7);
res.cookie(
this._configService.config.accessTokenCookieKey,
token.jwtToken,
{
domain: this._configService.config.cookieDomain,
expires: cookieExpiresAt,
httpOnly: true,
secure: true,
},
).cookie(
'XSRF-TOKEN',
token.xsrfToken,
{
domain: this._configService.config.cookieDomain,
expires: cookieExpiresAt,
sameSite: 'strict',
},
).status(HttpStatus.OK).redirect('/');
} catch (error) {
handleError(error, new InternalServerErrorException());
}
}
// @Get('/logout')
// @ApiOperation({ title: 'Logout from the identity provider.' })
......
......@@ -110,12 +110,6 @@ export class LoginResponse {
userInfo: PublicUserInfo;
}
export class TokenResponse {
@ApiModelProperty()
@IsString()
token: string;
}
export class KongUserJwtCredential {
consumer_id: string;
created_at: Date;
......@@ -168,6 +162,8 @@ export class JWTTokenInfo {
authzKey: string;
username: string;
identity_provider: string;
firstName?: string;
lastName?: string;
}
export class UserInfoUpdateServiceResponse {
......@@ -231,4 +227,4 @@ export class UserInfoWithoutPassword {
@ApiModelProperty()
@IsString()
email: string;
}
\ No newline at end of file
}
import { Module } from '@nestjs/common';
import { AuthenticationController } from './authentication.controller';
import { AuthenticationService } from './authentication.service';
import { TokenService } from './token.service';
@Module({
controllers: [AuthenticationController],
providers: [AuthenticationService],
providers: [AuthenticationService,TokenService],
})
export class AuthenticationModule {}
This diff is collapsed.
import { Injectable, InternalServerErrorException, Logger } from "@nestjs/common";
import { ConfigService } from "../configuration/config.service";
import { Redis } from "../helpers/redis.helper";
import * as request from 'request-promise-native';
import * as jwt from 'jsonwebtoken';
import { JWTTokenInfo } from './authentication.model';
interface TokenInfos{
access_token: string;
refresh_token: string;
}
@Injectable()
export class TokenService {
conf: any = {};
private logger: Logger;
private redis:Redis;
constructor(
private configService: ConfigService,
) {
this.conf = this.configService.config;
this.redis = new Redis(this.conf.redis);
}
async storeTokenInfos(email: any, body: any) {
let tokenInfos:TokenInfos={
"access_token":body.access_token,
"refresh_token":body.refresh_token,
}
return this.redis.setKeyValue(email,JSON.stringify(tokenInfos), this.conf.redis.ttl);
}
async getTokenInfos(email: any):Promise<TokenInfos>{
const value = await this.redis.getValueByKey(email);
let tokenInfos=JSON.parse(value);
let token_data = jwt.decode(tokenInfos.access_token);
//Check expiration
if (Date.now() >= token_data.exp * 1000) {
tokenInfos= await this.renewToken(email,tokenInfos);
}
return tokenInfos;
}
async getAccessToken(kongToken: JWTTokenInfo): Promise<string> {
const email = kongToken.email;
const tokenInfos = await this.getTokenInfos(email)
return tokenInfos.access_token;
}
async renewToken(email:String,tokenInfos: TokenInfos): Promise<any> {
const idpConf = this.conf.providers['OIDC'];
const payload = {
grant_type: 'refresh_token',
refresh_token:tokenInfos.refresh_token,
};
Logger.log(' [*] payload sent: ', JSON.stringify(payload));
Logger.log('[-] Token Request');
let options = {
url: idpConf.TokenUrl,
strictSSL: this.conf.useStrictSSL,
}
// Exchange the code against an id_token and an access_token
let body = await request.post(options)
.form(payload)
.auth(idpConf.client_id, idpConf.client_secret, true)
.catch((error)=>{
Logger.log('[-] error '+error);
});
let token_data=JSON.parse(body);
tokenInfos.access_token=token_data.access_token;
tokenInfos.refresh_token=token_data.refresh_token;
await this.storeTokenInfos(email,tokenInfos);
return tokenInfos;
}
}
......@@ -7,26 +7,35 @@ export class ConfigService {
dotenv.config();
// Complete redirect uris conf
// Gitlab ci doesn't allow var env per environment so we need to set the env in front of the variable
// this._config.redirect_uri = process.env.REDIRECT_URI;
this._config.redirect_uri = process.env.REDIRECT_URI;
// this._config.post_logout_redirect_uri = process.env.POST_LOGOUT_REDIRECT_URI;
this._config.legacyMiddlewareUrl = process.env.LEGACY_MIDDLEWARE_URL;
this._config.useStrictSSL = (process.env.USE_STRICT_SSL.toLowerCase() === 'true');
this._config.kongConsumers = process.env.KONG_URL + '/consumers';
this._config.kongStatus = process.env.KONG_URL + '/status';
this._config.jwtLifetime = parseInt(process.env.JWT_LIFETIME, 10);
this._config.cookieDomain = process.env.COOKIE_DOMAIN;
this._config.accessTokenCookieKey = process.env.ACCESS_TOKEN_COOKIE_KEY;
// this._config.providers.OIDC.discoveryUrl = process.env.OIDC_DISCOVERY_URL;
this._config.providers.OIDC.AuthUrl = process.env.OIDC_AUTH_URL;
this._config.providers.OIDC.TokenUrl = process.env.OIDC_TOKEN_URL;
this._config.providers.OIDC.UserInfoUrl = process.env.OIDC_USER_INFO;
//this._config.providers.OIDC.discoveryUrl = process.env.OIDC_DISCOVERY_URL;
// this._config.providers.GLC.discoveryUrl = process.env.GLC_DISCOVERY_URL;
// Complete providers conf
// for (const provider in this._config.providers) {
// if (this._config.providers.hasOwnProperty(provider)) {
// this._config.providers[provider].client_id = process.env[`${provider}_CLIENT_ID`];
// this._config.providers[provider].client_secret = process.env[`${provider}_CLIENT_SECRET`];
// }
// }
for (const provider in this._config.providers) {
if (this._config.providers.hasOwnProperty(provider)) {
this._config.providers[provider].client_id = process.env[`${provider}_CLIENT_ID`];
this._config.providers[provider].client_secret = process.env[`${provider}_CLIENT_SECRET`];
}
}
this._config.redis.sentinelHost = process.env.REDIS_SENTINEL_HOST;
this._config.redis.sentinelPort = process.env.REDIS_SENTINEL_PORT;
this._config.redis.groupName = process.env.REDIS_GROUP_NAME;
}
get config() {
return this._config;
}
}
\ No newline at end of file
}
export interface RedisCfg{
sentinel:boolean, // config simple ou sentinel
sentinelPort: number,
sentinelHost: string,
groupName: string,
ttl:number
}
export const Config = {
// redirect_uri: '',
redirect_uri: '',
// post_logout_redirect_uri: '',
// providers: {
// OIDC: {
// discoveryUrl: '',
// scopes: 'openid profile email',
// response_type: 'code',
// grant_type: 'authorization_code',
// },
// GLC: {
// discoveryUrl: '',
// scopes: 'openid profile email',
// response_type: 'code',
// grant_type: 'authorization_code',
// },
// },
providers: {
OIDC: {
//discoveryUrl: '',
scopes: 'openid profile email',
response_type: 'code',
grant_type: 'authorization_code',
AuthUrl: '',
TokenUrl: '',
UserInfoUrl: '',
},
},
legacyMiddlewareUrl: '',
useStrictSSL: false,
kongConsumers: '',
kongStatus: '',
cookieDomain: '',
accessTokenCookieKey: '',
jwtLifetime: 3600,
// redis: {
// ttl: 300, // in seconds
// port: 6379,
// host: 'redis',
// },
};
\ No newline at end of file
redis: {
sentinel:true,
sentinelPort: null,
sentinelHost: '',
groupName: '',
ttl: 3600, // in seconds
},
};
File moved
import { InternalServerErrorException, Logger } from '@nestjs/common';
import { handleError } from './errorHandlingHelper';
import * as IORedis from 'ioredis';
import { RedisCfg } from 'configuration/config';
export class Redis {
constructor(
private redisConfig: RedisCfg
) { }
connect() {
Logger.log(`Entering function`, `Redis.connect`);
let cfg:any={
host: this.redisConfig.sentinelHost, port: this.redisConfig.sentinelPort ,
name: this.redisConfig.groupName,
};
if(this.redisConfig.sentinel){
cfg={
sentinels: [
{ host: this.redisConfig.sentinelHost, port: this.redisConfig.sentinelPort },
],
name: this.redisConfig.groupName,
};
}
Logger.log(`config: ` + JSON.stringify(cfg), `Redis.connect`);
const client = new IORedis(cfg);
client.on('error', (error) => {
Logger.error('Redis client error: ' + error, `Redis.connect`);
client.disconnect();
});
return client;
}
async getValueByKey(key: string): Promise<string> {
Logger.log(`Entering function with params ${key}`, `Redis.getValueByKey`);
let client;
try {
client = this.connect();
const res = await client.get(key).catch((error) => {
client.disconnect();
Logger.error('Couldn\'t get redis value.', `${error}`, `Redis.getValueByKey`);
throw new InternalServerErrorException({ error, message: 'Couldn\'t get redis value.' });
});
Logger.log(`Value found`, `Redis.getValueByKey`);
client.disconnect();
return res;
} catch (error) {
handleError(error, new InternalServerErrorException('Couldn\'t get value from redis.'));
}
}
async setKeyValue(key: string, value: string, ttl?: number): Promise<any> {
Logger.log(`Entering function with key ${key} and ttl: ${ttl}`, `Redis.setKeyValue`);
let client;
try {
client = this.connect();
let res;
if (ttl) {
// Set key value with expiration time in seconds
res = await client.set(key, value, 'EX', ttl).catch((error) => {
client.disconnect();
Logger.error('Couldn\'t set redis key/value (with ttl).', `${error}`, `Redis.setKeyValue`);
throw new InternalServerErrorException({ error, message: 'Couldn\'t set redis key/value.' });
});
} else {
// Set key value with expiration time in seconds
res = await client.set(key, value).catch((error) => {
client.disconnect();
Logger.error('Couldn\'t set redis key/value (without ttl).', `${error}`, `Redis.setKeyValue`);
throw new InternalServerErrorException({ error, message: 'Couldn\'t set redis key/value.' });
});
}
Logger.log(`Done setting key/value pair`, `Redis.setKeyValue`);
client.disconnect();
return res;
} catch (error) {
handleError(error, new InternalServerErrorException('Couldn\'t set key/value in redis.'));
}
}
async deleteKeyValue(key: string): Promise<any> {
Logger.log(`Entering function with params ${key}`, `Redis.deleteKeyValue`);
let client;
try {
client = this.connect();
// Set key value with expiration time in seconds
const res = await client.del(key).catch((error) => {
client.disconnect();
Logger.error('Couldn\'t delete redis key/value.', `${error}`, `Redis.deleteKeyValue`);
throw new InternalServerErrorException({ error, message: 'Couldn\'t delete redis key/value.' });
});
Logger.log(`Removing key/pair, result is: ${res}`, `Redis.deleteKeyValue`);
client.disconnect();
return res;
} catch (error) {
handleError(error, new InternalServerErrorException('Couldn\'t remove key/value in redis.'));
}
}
}
import { UnauthorizedException, Logger, NestMiddleware, MiddlewareFunction, Injectable } from '@nestjs/common';
import { UnauthorizedException, Logger, NestMiddleware, Injectable } from '@nestjs/common';
import * as jwt from 'jsonwebtoken';
import { ConfigService } from '../configuration/config.service';
import { Request } from "express";
@Injectable()
export class VerifyXsrfTokenAndDecodeJWTPayloadMiddleware implements NestMiddleware {
......@@ -13,33 +14,31 @@ export class VerifyXsrfTokenAndDecodeJWTPayloadMiddleware implements NestMiddlew
this.logger = new Logger(VerifyXsrfTokenAndDecodeJWTPayloadMiddleware.name);
}
resolve(): MiddlewareFunction {
return (req, res, next) => {
this.logger.log('Entering function');
// Verifying that all the authentications part are set
// tslint:disable-next-line:max-line-length
if (req.headers['x-anonymous-consumer'] !== 'true' && req.cookies[this._configService.config.accessTokenCookieKey] && req.headers['x-xsrf-token']) {
let token = req.cookies[this._configService.config.accessTokenCookieKey];
const xsrfToken = req.headers['x-xsrf-token'];
token = jwt.decode(token);
// Make sure the token has correctly been decrypted
if (token) {
// Verify the wsrfToken from the header with the value of the corresponding in the JWT payload
if (token.xsrfToken === xsrfToken) {
req.headers.token = token;
next();
} else {
this.logger.log('Could\'t verify xsrf token.');
throw new UnauthorizedException('Could\'t verify xsrf token.');
}
use( req: Request, res: Response, next: Function) {
this.logger.log('Entering function');
// Verifying that all the authentications part are set
// tslint:disable-next-line:max-line-length
if (req.headers['x-anonymous-consumer'] !== 'true' && req.cookies[this._configService.config.accessTokenCookieKey] && req.headers['x-xsrf-token']) {
let token = req.cookies[this._configService.config.accessTokenCookieKey];
const xsrfToken = req.headers['x-xsrf-token'];
token = jwt.decode(token);
// Make sure the token has correctly been decrypted
if (token) {
// Verify the wsrfToken from the header with the value of the corresponding in the JWT payload
if (token.xsrfToken === xsrfToken) {
req.headers.token = token;
next();
} else {
this.logger.log('Invalid token provided');
throw new UnauthorizedException('Invalid token provided.');
this.logger.log('Could\'t verify xsrf token.');
throw new UnauthorizedException('Could\'t verify xsrf token.');
}
} else {
this.logger.log('Missing credential information');
throw new UnauthorizedException('Missing credential information.');
this.logger.log('Invalid token provided');
throw new UnauthorizedException('Invalid token provided.');
}
};
} else {
this.logger.log('Missing credential information');
throw new UnauthorizedException('Missing credential information.');
}
}
}
TAG=<version number>
AUTHENTICATION_API_BIND_PORT=<service port>
LEGACY_MIDDLEWARE_URL=<url of the legacy auth middleware>
USE_STRICT_SSL=<enable oidc certificate validation>
KONG_URL=<api gateway url>
JWT_LIFETIME=<life time of the JWT>
COOKIE_DOMAIN=<domain for which the cookie will be set>
ACCESS_TOKEN_COOKIE_KEY=<cookie key where the access token will be stored>
REDIS_SENTINEL_HOST=<host of the redis sentinel from the service point of view ex: 'redis-sentinel-1'>
REDIS_SENTINEL_PORT=<port of the redis sentinel from the service point of view ex: 26379>
REDIS_GROUP_NAME=<name of the group of the different sentinels>
OIDC_AUTH_URL=https://api-back.dev.grandlyon.neogeo.fr/oauth2/authorize
OIDC_TOKEN_URL=https://api-back.dev.grandlyon.neogeo.fr/oauth2/token
OIDC_USER_INFO=https://api-back.dev.grandlyon.neogeo.fr/oauth2/userinfo
OIDC_CLIENT_ID=<oidc client id>
OIDC_CLIENT_SECRET=<oidc client secret>
REDIRECT_URI=<URL of this server registered on the OIDC provider, finishes with /token>
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment