From d1e40f864b2d2567b55f365bc48bb9f6b26acbc5 Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Sat, 8 Jun 2024 15:01:32 +0300 Subject: [PATCH 1/2] [1/2]fix: Link existing users to identity providers on user exists error Whenever someone attempts to sign in through OAuth, keycloak automatically attempts to create a new user with the given user data returned from the provider. This behavior results in a conflict, in cases user had already been registered prior to Oauth2 Signin attempt. Resolve this issue, by linking the existing user to the federated identity, whenever keycloak responds with an error User already exists. --- apps/api/src/auth/auth.service.ts | 81 +++++++++++++++++++++++++-- apps/api/src/auth/dto/provider.dto.ts | 19 +++++++ 2 files changed, 96 insertions(+), 4 deletions(-) diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 1e605d075..cc4b2220d 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, + ConflictException, ForbiddenException, Inject, Injectable, @@ -9,7 +10,19 @@ import { UnauthorizedException, } from '@nestjs/common' import { HttpService } from '@nestjs/axios' -import { catchError, firstValueFrom, map, Observable } from 'rxjs' +import { + catchError, + delay, + delayWhen, + firstValueFrom, + map, + Observable, + retry, + retryWhen, + take, + tap, + timer, +} from 'rxjs' import KeycloakConnect from 'keycloak-connect' import { ConfigService } from '@nestjs/config' import { KEYCLOAK_INSTANCE } from 'nest-keycloak-connect' @@ -33,6 +46,7 @@ import { ForgottenPasswordMailDto } from '../email/template.interface' import { NewPasswordDto } from './dto/recovery-password.dto' import { MarketingNotificationsService } from '../notifications/notifications.service' import { PersonService } from '../person/person.service' +import FederatedIdentityRepresentation from '@keycloak/keycloak-admin-client/lib/defs/federatedIdentityRepresentation' type ErrorResponse = { error: string; data: unknown } type KeycloakErrorResponse = { error: string; error_description: string } @@ -71,14 +85,26 @@ export class AuthService { return this.keycloak.grantManager.obtainDirectly(email, password) } + shouldRetry(error: AxiosResponse) { + //Retry request if status is 409. + if (error.status === 409) { + Logger.debug(`Retrying oauth query`) + return timer(1000) // Adding a timer from RxJS to return observable to delay param. + } + + throw error + } + async tokenEndpoint( data: Record<'grant_type' & string, string>, + providerDto?: ProviderDto, ): Promise> { const params = new URLSearchParams({ ...this.requestSecrets(), ...data, }) - return await this.httpService + + return this.httpService .post(this.createTokenUrl(), params.toString()) .pipe( map((res: AxiosResponse) => ({ @@ -86,16 +112,34 @@ export class AuthService { accessToken: res.data.access_token, expires: res.data.expires_in, })), - catchError(({ response }: { response: AxiosResponse }) => { + catchError(async ({ response }: { response: AxiosResponse }) => { const error = response.data Logger.error("Couldn't get authentication from keycloak. Error: " + JSON.stringify(error)) + if ( + error.error === 'invalid_token' && + error.error_description === 'User already exists' + ) { + //User already exists. Link IDP data to it + if (!providerDto) + throw new BadRequestException('ProviderDto must be passed to tokenEndpoint') + const person = await this.prismaService.person.findUnique({ + where: { email: providerDto?.email }, + }) + if (!person || !person.keycloakId) + throw new NotFoundException(`No user found with email ${providerDto?.email}`) + + //Create Oauth Link with existing account + await this.addUserToFederatedIdentity(providerDto, person.keycloakId) + throw new ConflictException('User already exists') + } if (error.error === 'invalid_grant') { throw new UnauthorizedException(error['error_description']) } throw new InternalServerErrorException('CannotIssueTokenError') }), + retry({ count: 2, delay: this.shouldRetry }), ) } @@ -108,7 +152,7 @@ export class AuthService { subject_issuer: providerDto.provider, subject_token_type: 'urn:ietf:params:oauth:token-type:access_token', } - const tokenObs$ = await this.tokenEndpoint(data) + const tokenObs$ = await this.tokenEndpoint(data, providerDto) const keycloakResponse = await firstValueFrom(tokenObs$) const userInfo = await this.keycloak.grantManager.userInfo( keycloakResponse.accessToken as string, @@ -272,6 +316,35 @@ export class AuthService { } } + private async addUserToFederatedIdentity(providerDto: ProviderDto, keycloakId: string) { + const params = { + identityProvider: providerDto.provider, + userId: providerDto.userId, + userName: providerDto.name, + } as FederatedIdentityRepresentation + + try { + await this.authenticateAdmin() + const result = await this.admin.users.addToFederatedIdentity({ + id: keycloakId, + federatedIdentityId: providerDto.provider, + federatedIdentity: params, + }) + Logger.debug(result) + return result + } catch (err) { + Logger.debug(err) + throw err + } + } + + private createIdentityLinkUrl(keycloakId: string, provider: string) { + const serverUrl = this.config.get('keycloak.serverUrl') + const realm = this.config.get('keycloak.realm') + //http://localhost:8180/auth/admin/realms/webapp/users/2545d07e-3e7d-4e8f-932e-c5e5153227a4/federated-identity/google + return `${serverUrl}/realms/${realm}/users/${keycloakId}/federated-identity/${provider}` + } + private createTokenUrl() { const serverUrl = this.config.get('keycloak.serverUrl') const realm = this.config.get('keycloak.realm') diff --git a/apps/api/src/auth/dto/provider.dto.ts b/apps/api/src/auth/dto/provider.dto.ts index 8850acfcd..602faeb56 100644 --- a/apps/api/src/auth/dto/provider.dto.ts +++ b/apps/api/src/auth/dto/provider.dto.ts @@ -14,10 +14,29 @@ export class ProviderDto { @IsNotEmpty() @IsString() public readonly provider: string + + @ApiProperty() + @Expose() + @IsNotEmpty() + @IsString() + public readonly userId: string + + @ApiProperty() + @Expose() + @IsNotEmpty() + @IsString() + public readonly email: string + @ApiProperty() @Expose() @IsNotEmpty() @IsString() @IsUrl() public readonly picture: string + + @ApiProperty() + @Expose() + @IsNotEmpty() + @IsString() + public readonly name: string } From 1a8c5a1ebd8d319a4f2fa3479fa8adabf11d32fd Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Sat, 8 Jun 2024 15:03:05 +0300 Subject: [PATCH 2/2] chore: Remove unused imports --- apps/api/src/auth/auth.service.ts | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index cc4b2220d..fb876b54d 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -10,19 +10,7 @@ import { UnauthorizedException, } from '@nestjs/common' import { HttpService } from '@nestjs/axios' -import { - catchError, - delay, - delayWhen, - firstValueFrom, - map, - Observable, - retry, - retryWhen, - take, - tap, - timer, -} from 'rxjs' +import { catchError, firstValueFrom, map, Observable, retry, timer } from 'rxjs' import KeycloakConnect from 'keycloak-connect' import { ConfigService } from '@nestjs/config' import { KEYCLOAK_INSTANCE } from 'nest-keycloak-connect'