diff --git a/src/app.module.ts b/src/app.module.ts index 3e54fde..4229217 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -28,6 +28,7 @@ import { MixpanelModule } from './modules/mixpanel/mixpanel.module'; import { CustomersModule } from './modules/customers/customers.module'; import { OpenDataModule } from './modules/open-data/open-data.module'; import { ThemeModule } from './modules/theme/theme.module'; +import { OpenidModule } from './modules/openid/openid.module'; @Module({ providers: [ @@ -62,7 +63,8 @@ import { ThemeModule } from './modules/theme/theme.module'; OpenDataModule, ThemeModule, //Always leave HealthModule last, so it is on the bottom of swagger - HealthModule + HealthModule, + OpenidModule ], }) export class AppModule {} diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 33c243b..c810d2b 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -53,7 +53,6 @@ import jwt from 'jsonwebtoken'; import { LanguageEnum } from 'src/utils/languages.enum'; import { Language } from 'src/decorators/language.decorator'; import { ApiInternalOnlyEndpoint } from 'src/decorators/swagger.decorator'; -import { CreatedIdentityProvider, CreateIdentityProvider } from './dtos/openid'; @ApiTags('Auth') @ApiHeaders([{ name: 'dadosfera-lang', enum: LanguageEnum, required: false }]) @@ -111,56 +110,6 @@ export class AuthController { return this.authClient.refreshAccessToken({ refreshToken }, metadata); } - @Post('/idp') - @HttpCode(HttpStatus.OK) - @ApiOkResponse({ type: CreatedIdentityProvider }) - async addIdentityProvider( - @User() user: RequestUser, - @Body() body: IdProvider, - @Language() language: LanguageEnum, - ) { - this.logger.info('/auth - RefreshAccessToken'); - - const metadata = PackTheMetadata({ - customer_id: user.customer_id, - language, - }); - - return this.authClient.createIdentityProvider(body, metadata); - } - - @Get('/idp/:name') - @HttpCode(HttpStatus.OK) - @Redirect() - async loginIdp( - @Body() body: { name: string }, - @Req() req: Request, - ) { - this.logger.info('/auth - RefreshAccessToken'); - - const redirectUrl = this.authClient.loginIdp(body.name, req); - return { - url: redirectUrl, - }; - } - - @Get('/idp/:name/callback') - @HttpCode(HttpStatus.OK) - @ApiOkResponse({ type: AuthRefreshAccessTokenRes }) - @Redirect() - @RequireAllPermissions(PERMISSIONS_GROUPS.SNOWFLAKE.permissions.OPEN) - async callbackIdp( - @Param('name') name: string, - @Req() req: Request, - ) { - this.logger.info('/auth - RefreshAccessToken'); - - const redirectUrl = this.authClient.authorizeIdp(name, req); - return { - url: redirectUrl, - }; - } - @ApiInternalOnlyEndpoint() @Post('/sso/snowflake') @RequireAllPermissions(PERMISSIONS_GROUPS.SNOWFLAKE.permissions.OPEN) diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 1f15be6..f02404f 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -17,12 +17,10 @@ import { AuthResetPasswordRequest, AuthVerifyResetPasswordCodeRequest, AuthConfirmResetPasswordRequest, - IdProvider, } from '@dadosfera/protospack-v2/dist/lib/Duc/interfaces/messages'; import { DucClient } from '../duc/client.config'; import { Metadata } from '@grpc/grpc-js'; -import * as client from 'openid-client' -import { Request } from 'express'; + @Injectable() export class AuthClientService implements OnModuleInit { @@ -180,84 +178,5 @@ export class AuthClientService implements OnModuleInit { ); } - createIdentityProvider(body: IdProvider, metadata: Metadata) { - return lastValueFrom( - this.identityProviderService.CreateIdentityProvider(body, metadata), - ); - } - - async loginIdp(name: string, req: Request) { - const idp = await lastValueFrom( - this.identityProviderService.FindIdentityProvider({ name }), - ); - - const config: client.Configuration = await client.discovery( - new URL(idp.issueUrl), - idp.clientId, - idp.clientSecret, - ) - - const redirect_uri = `http://localhost:3000/auth/idp/${name}/callback`; - const scope = 'openid email'; - - const code_verifier: string = client.randomPKCECodeVerifier() - const code_challenge: string = - await client.calculatePKCECodeChallenge(code_verifier) - let state: string; - const parameters: Record = { - redirect_uri, - scope, - code_challenge, - code_challenge_method: 'S256', - } - req.session.code_verifier = code_verifier; - - if (!config.serverMetadata().supportsPKCE()) { - /** - * We cannot be sure the server supports PKCE so we're going to use state too. - * Use of PKCE is backwards compatible even if the AS doesn't support it which - * is why we're using it regardless. Like PKCE, random state must be generated - * for every redirect to the authorization_endpoint. - */ - state = client.randomState() - parameters.state = state - req.session.state = state; - } - - const authUrl = client.buildAuthorizationUrl(config, parameters); - return authUrl; - } - - async authorizeIdp(name: string, req: Request) { - const code_verifier_session = req.session.code_verifier; - const state_session = req.session.state; - const url = new URL("http://localhost:3000" + req.url); - - const idp = await lastValueFrom( - this.identityProviderService.FindIdentityProvider({ name }), - ); - - const config: client.Configuration = await client.discovery( - new URL(idp.issueUrl), - idp.clientId, - idp.clientSecret, - ) - - const tokens: client.TokenEndpointResponse = await client.authorizationCodeGrant( - config, - url, - { - pkceCodeVerifier: code_verifier_session, - expectedState: state_session, - }, - ) - - delete req.session.code_verifier; - delete req.session.state; - - console.log('Token Endpoint Response', tokens) - - return tokens; - } } diff --git a/src/modules/auth/dtos/openid.ts b/src/modules/openid/dto/openid.ts similarity index 100% rename from src/modules/auth/dtos/openid.ts rename to src/modules/openid/dto/openid.ts diff --git a/src/modules/openid/openid.controller.ts b/src/modules/openid/openid.controller.ts new file mode 100644 index 0000000..028834e --- /dev/null +++ b/src/modules/openid/openid.controller.ts @@ -0,0 +1,73 @@ +import DadosferaLogger from '@dadosfera/dadosfera-logs'; +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Inject, + Param, + Post, + Redirect, + Req, +} from '@nestjs/common'; +import { OpenidService } from './openid.service'; +import { RequestUser, User } from 'src/decorators/user.decorator'; +import { Language } from 'src/decorators/language.decorator'; +import { LanguageEnum } from 'src/utils/languages.enum'; +import { PackTheMetadata } from 'src/utils/ PackTheMetadata'; +import { ApiOkResponse } from '@nestjs/swagger'; +import { CreatedIdentityProvider } from './dto/openid'; +import { Request } from 'express'; + +@Controller('openid') +export class OpenidController { + logger: DadosferaLogger; + + constructor( + @Inject(DadosferaLogger) + dadosferaLogger: DadosferaLogger, + private openidService: OpenidService, + ) { + this.logger = dadosferaLogger.logger; + } + + @Post() + @HttpCode(HttpStatus.OK) + @ApiOkResponse({ type: CreatedIdentityProvider }) + async addIdentityProvider( + @User() user: RequestUser, + @Body() body: CreatedIdentityProvider, + @Language() language: LanguageEnum, + ) { + this.logger.info('/openid - addIdentityProvider'); + + const metadata = PackTheMetadata({ + customer_id: user.customer_id, + language, + }); + + return await this.openidService.createIdentityProvider(body, metadata); + } + + @Get(':name') + @HttpCode(HttpStatus.OK) + @Redirect() + async loginIdp(@Param('name') name: string , @Req() req: Request) { + this.logger.info('/openid - :name'); + + const redirectUrl = await this.openidService.loginIdp(name, req); + return { + + url: redirectUrl, + }; + } + + @Get('/:name/callback') + @HttpCode(HttpStatus.OK) + async callbackIdp(@Param('name') name: string, @Req() req: Request) { + this.logger.info('/openid - /:name/callback'); + + return await this.openidService.authorizeIdp(name, req); + } +} diff --git a/src/modules/openid/openid.module.ts b/src/modules/openid/openid.module.ts new file mode 100644 index 0000000..ce242d9 --- /dev/null +++ b/src/modules/openid/openid.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { OpenidController } from './openid.controller'; +import { OpenidService } from './openid.service'; +import { ClientsModule } from '@nestjs/microservices'; +import { DucClient } from '../duc/client.config'; +import DadosferaLogger from '@dadosfera/dadosfera-logs'; + +const client = new DucClient(); + +@Module({ + imports: [ClientsModule.register([client.providerOptions])], + controllers: [OpenidController], + providers: [OpenidService, DadosferaLogger] +}) +export class OpenidModule {} diff --git a/src/modules/openid/openid.service.ts b/src/modules/openid/openid.service.ts new file mode 100644 index 0000000..10879aa --- /dev/null +++ b/src/modules/openid/openid.service.ts @@ -0,0 +1,79 @@ +import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; +import { DucClient } from '../duc/client.config'; +import { ClientGrpc } from '@nestjs/microservices'; +import { ProtoServices } from '@dadosfera/protospack-v2/dist/lib/Duc'; +import { IdentityProviderProtoService } from '@dadosfera/protospack-v2/dist/lib/Duc/interfaces/write-service'; +import { lastValueFrom } from 'rxjs'; +import { IdProvider } from '@dadosfera/protospack-v2/dist/lib/Duc/interfaces/messages'; +import { Metadata } from '@grpc/grpc-js'; +import { Issuer, generators } from 'openid-client'; +import { Request } from 'express'; + +@Injectable() +export class OpenidService implements OnModuleInit { + private identityProviderService: IdentityProviderProtoService; + constructor( + @Inject(DucClient.name) private readonly grpcClient: ClientGrpc, + ) {} + + onModuleInit() { + this.identityProviderService = + this.grpcClient.getService( + ProtoServices.IdentityProviderProtoService, + ); + } + + async createIdentityProvider(body: IdProvider, metadata: Metadata) { + return await lastValueFrom( + this.identityProviderService.CreateIdentityProvider(body, metadata), + ); + } + + async loginIdp(name: string, req: Request) { + const client = await this.buildOpenIdClient(name); + + const code_verifier: string = generators.codeVerifier(); + const code_challenge: string = generators.codeChallenge(code_verifier); + + req.session.code_verifier = code_verifier; + + return client.authorizationUrl({ + scope: 'openid email', + response_type: 'code', + code_challenge, + code_challenge_method: 'S256', + }); + } + + async authorizeIdp(name: string, req: Request) { + const client = await this.buildOpenIdClient(name); + const params = client.callbackParams(req); + const code_verifier = req.session.code_verifier; + const tokenSet = await client.callback( + `http://localhost:3333/openid/${name}/callback`, + params, + { code_verifier }, + ); + + delete req.session.code_verifier; + + console.log('received and validated tokens %j', tokenSet); + console.log('validated ID Token claims %j', tokenSet.claims()); + + return tokenSet.claims(); + } + + private async buildOpenIdClient(name: string) { + const idp = await lastValueFrom( + this.identityProviderService.FindIdentityProvider({ name }), + ); + + const issuer = await Issuer.discover(idp.issueUrl); + return new issuer.Client({ + client_id: idp.clientId, + client_secret: idp.clientSecret, + redirect_uris: [`http://localhost:3333/openid/${idp.name}/callback`], + response_types: ['code'], + }); + } +}