From c98bd0ed52472acf1419378aa36a9b98f0af6c9c Mon Sep 17 00:00:00 2001 From: Attila Cseh <77381875+csehatt741@users.noreply.github.com> Date: Fri, 11 Jul 2025 09:43:32 +0200 Subject: [PATCH] Bugfix/biometric authentication issue related to matching passkeys (#5137) * Fix for biometric authentication issue related to matching passkeys * Update changelog --- CHANGELOG.md | 4 ++ apps/api/src/app/auth/auth.controller.ts | 14 +++-- apps/api/src/app/auth/web-auth.service.ts | 59 ++++++++++--------- .../src/app/services/web-authn.service.ts | 4 +- 4 files changed, 44 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 884401532..47042b173 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improved the language localization for Spanish (`es`) - Improved the language localization for Turkish (`tr`) +### Fixed + +- Fixed an issue in the biometric authentication related to matching passkeys + ## 2.180.0 - 2025-07-08 ### Added diff --git a/apps/api/src/app/auth/auth.controller.ts b/apps/api/src/app/auth/auth.controller.ts index a91525269..13d8e37f6 100644 --- a/apps/api/src/app/auth/auth.controller.ts +++ b/apps/api/src/app/auth/auth.controller.ts @@ -133,17 +133,19 @@ export class AuthController { return this.webAuthService.verifyAttestation(body.credential); } - @Post('webauthn/generate-assertion-options') - public async generateAssertionOptions(@Body() body: { deviceId: string }) { - return this.webAuthService.generateAssertionOptions(body.deviceId); + @Post('webauthn/generate-authentication-options') + public async generateAuthenticationOptions( + @Body() body: { deviceId: string } + ) { + return this.webAuthService.generateAuthenticationOptions(body.deviceId); } - @Post('webauthn/verify-assertion') - public async verifyAssertion( + @Post('webauthn/verify-authentication') + public async verifyAuthentication( @Body() body: { deviceId: string; credential: AssertionCredentialJSON } ) { try { - const authToken = await this.webAuthService.verifyAssertion( + const authToken = await this.webAuthService.verifyAuthentication( body.deviceId, body.credential ); diff --git a/apps/api/src/app/auth/web-auth.service.ts b/apps/api/src/app/auth/web-auth.service.ts index c2223e716..d14ef7798 100644 --- a/apps/api/src/app/auth/web-auth.service.ts +++ b/apps/api/src/app/auth/web-auth.service.ts @@ -25,6 +25,7 @@ import { VerifyRegistrationResponseOpts } from '@simplewebauthn/server'; import { isoBase64URL, isoUint8Array } from '@simplewebauthn/server/helpers'; +import ms from 'ms'; import { AssertionCredentialJSON, @@ -41,42 +42,42 @@ export class WebAuthService { @Inject(REQUEST) private readonly request: RequestWithUser ) {} - get rpID() { - return new URL(this.configurationService.get('ROOT_URL')).hostname; + private get expectedOrigin() { + return this.configurationService.get('ROOT_URL'); } - get expectedOrigin() { - return this.configurationService.get('ROOT_URL'); + private get rpID() { + return new URL(this.configurationService.get('ROOT_URL')).hostname; } public async generateRegistrationOptions() { const user = this.request.user; const opts: GenerateRegistrationOptionsOpts = { - rpName: 'Ghostfolio', - rpID: this.rpID, - userID: isoUint8Array.fromUTF8String(user.id), - userName: '', - timeout: 60000, authenticatorSelection: { authenticatorAttachment: 'platform', - requireResidentKey: false, - userVerification: 'required' - } + residentKey: 'required', + userVerification: 'preferred' + }, + rpID: this.rpID, + rpName: 'Ghostfolio', + timeout: ms('60 seconds'), + userID: isoUint8Array.fromUTF8String(user.id), + userName: '' }; - const options = await generateRegistrationOptions(opts); + const registrationOptions = await generateRegistrationOptions(opts); await this.userService.updateUser({ data: { - authChallenge: options.challenge + authChallenge: registrationOptions.challenge }, where: { id: user.id } }); - return options; + return registrationOptions; } public async verifyAttestation( @@ -84,13 +85,14 @@ export class WebAuthService { ): Promise { const user = this.request.user; const expectedChallenge = user.authChallenge; - let verification: VerifiedRegistrationResponse; + try { const opts: VerifyRegistrationResponseOpts = { expectedChallenge, expectedOrigin: this.expectedOrigin, expectedRPID: this.rpID, + requireUserVerification: false, response: { clientExtensionResults: credential.clientExtensionResults, id: credential.id, @@ -99,6 +101,7 @@ export class WebAuthService { type: 'public-key' } }; + verification = await verifyRegistrationResponse(opts); } catch (error) { Logger.error(error, 'WebAuthService'); @@ -144,7 +147,7 @@ export class WebAuthService { throw new InternalServerErrorException('An unknown error occurred'); } - public async generateAssertionOptions(deviceId: string) { + public async generateAuthenticationOptions(deviceId: string) { const device = await this.deviceService.authDevice({ id: deviceId }); if (!device) { @@ -152,32 +155,27 @@ export class WebAuthService { } const opts: GenerateAuthenticationOptionsOpts = { - allowCredentials: [ - { - id: isoBase64URL.fromBuffer(device.credentialId), - transports: ['internal'] - } - ], + allowCredentials: [], rpID: this.rpID, - timeout: 60000, + timeout: ms('60 seconds'), userVerification: 'preferred' }; - const options = await generateAuthenticationOptions(opts); + const authenticationOptions = await generateAuthenticationOptions(opts); await this.userService.updateUser({ data: { - authChallenge: options.challenge + authChallenge: authenticationOptions.challenge }, where: { id: device.userId } }); - return options; + return authenticationOptions; } - public async verifyAssertion( + public async verifyAuthentication( deviceId: string, credential: AssertionCredentialJSON ) { @@ -190,6 +188,7 @@ export class WebAuthService { const user = await this.userService.user({ id: device.userId }); let verification: VerifiedAuthenticationResponse; + try { const opts: VerifyAuthenticationResponseOpts = { credential: { @@ -200,6 +199,7 @@ export class WebAuthService { expectedChallenge: `${user.authChallenge}`, expectedOrigin: this.expectedOrigin, expectedRPID: this.rpID, + requireUserVerification: false, response: { clientExtensionResults: credential.clientExtensionResults, id: credential.id, @@ -208,13 +208,14 @@ export class WebAuthService { type: 'public-key' } }; + verification = await verifyAuthenticationResponse(opts); } catch (error) { Logger.error(error, 'WebAuthService'); throw new InternalServerErrorException({ error: error.message }); } - const { verified, authenticationInfo } = verification; + const { authenticationInfo, verified } = verification; if (verified) { device.counter = authenticationInfo.newCounter; diff --git a/apps/client/src/app/services/web-authn.service.ts b/apps/client/src/app/services/web-authn.service.ts index 76352cb7b..3885b2f94 100644 --- a/apps/client/src/app/services/web-authn.service.ts +++ b/apps/client/src/app/services/web-authn.service.ts @@ -85,7 +85,7 @@ export class WebAuthnService { return this.http .post( - `/api/v1/auth/webauthn/generate-assertion-options`, + '/api/v1/auth/webauthn/generate-authentication-options', { deviceId } ) .pipe( @@ -94,7 +94,7 @@ export class WebAuthnService { }), switchMap((credential) => { return this.http.post<{ authToken: string }>( - `/api/v1/auth/webauthn/verify-assertion`, + '/api/v1/auth/webauthn/verify-authentication', { credential, deviceId