From c7bd1be3e470a94277356b0156b07285a9d984e4 Mon Sep 17 00:00:00 2001 From: Hampus Date: Wed, 30 Sep 2026 23:01:37 +0200 Subject: [PATCH] fix(auth): offer every transport for passkeys stored without any (#3077) --- fluxer_api/src/api/auth/AuthMfa.ts | 11 +++-- .../api/auth/tests/WebAuthnMfaLogin.test.ts | 43 +++++++++++++++++++ .../src/api/auth/tests/WebAuthnTestUtils.ts | 6 ++- 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/fluxer_api/src/api/auth/AuthMfa.ts b/fluxer_api/src/api/auth/AuthMfa.ts index ab8b7aec5..2128103c3 100644 --- a/fluxer_api/src/api/auth/AuthMfa.ts +++ b/fluxer_api/src/api/auth/AuthMfa.ts @@ -168,12 +168,17 @@ export async function verifyMfaCode(ctx: ApiContext, params: VerifyMfaCodeParams return false; } +type CredentialTransport = 'usb' | 'nfc' | 'ble' | 'internal' | 'cable' | 'hybrid'; + +const ALL_CREDENTIAL_TRANSPORTS: Array = ['internal', 'hybrid', 'usb', 'nfc', 'ble']; + function toCredentialDescriptor(credential: WebAuthnCredential) { return { id: credential.credentialId, - transports: credential.transports - ? (Array.from(credential.transports) as Array<'usb' | 'nfc' | 'ble' | 'internal' | 'cable' | 'hybrid'>) - : undefined, + transports: + credential.transports && credential.transports.size > 0 + ? (Array.from(credential.transports) as Array) + : ALL_CREDENTIAL_TRANSPORTS, }; } diff --git a/fluxer_api/src/api/auth/tests/WebAuthnMfaLogin.test.ts b/fluxer_api/src/api/auth/tests/WebAuthnMfaLogin.test.ts index e95668e2e..be6e95ba5 100644 --- a/fluxer_api/src/api/auth/tests/WebAuthnMfaLogin.test.ts +++ b/fluxer_api/src/api/auth/tests/WebAuthnMfaLogin.test.ts @@ -65,6 +65,7 @@ describe('WebAuthn MFA login', () => { expect(mfaOptions.userVerification).toBe('discouraged'); expect(mfaOptions.allowCredentials).toBeTruthy(); expect(mfaOptions.allowCredentials!.length).toBeGreaterThan(0); + expect(mfaOptions.allowCredentials![0]!.transports).toEqual(['internal']); if (mfaOptions.rpId) { device.rpId = mfaOptions.rpId; } @@ -87,6 +88,48 @@ describe('WebAuthn MFA login', () => { .execute(); expect(userInfo.id).toBe(account.userId); }); + it('offers every transport for a passkey registered without transports', async () => { + const account = await createTestAccount(harness); + const device = createWebAuthnDevice(); + device.transports = null; + const secret = createTotpSecret(); + await createBuilder(harness, account.token) + .post('/users/@me/mfa/totp/enable') + .body({secret, code: generateTotpCode(secret), password: account.password}) + .execute(); + await registerWebAuthnCredential(harness, account.token, device, () => ({ + mfa_method: 'totp', + mfa_code: generateTotpCode(secret), + })); + await setWebAuthnTwoFactor(harness, account.token, true, { + mfa_method: 'totp', + mfa_code: generateTotpCode(secret), + }); + const loginResp = (await loginUser(harness, { + email: account.email, + password: account.password, + })) as LoginMfaResponse; + const mfaOptions = await createBuilderWithoutAuth(harness) + .post('/auth/login/mfa/webauthn/authentication-options') + .body({ticket: loginResp.ticket}) + .execute(); + expect(mfaOptions.allowCredentials).toEqual([ + { + id: device.credentialId.toString('base64url'), + type: 'public-key', + transports: ['internal', 'hybrid', 'usb', 'nfc', 'ble'], + }, + ]); + const webauthnMfaLogin = await createBuilderWithoutAuth<{token: string}>(harness) + .post('/auth/login/mfa/webauthn') + .body({ + response: createAuthenticationResponse(device, mfaOptions), + challenge: mfaOptions.challenge, + ticket: loginResp.ticket, + }) + .execute(); + expect(webauthnMfaLogin.token).toBeTruthy(); + }); it('issues a session token instead of an MFA ticket when passkey two-factor is left off', async () => { const account = await createTestAccount(harness); const device = createWebAuthnDevice(); diff --git a/fluxer_api/src/api/auth/tests/WebAuthnTestUtils.ts b/fluxer_api/src/api/auth/tests/WebAuthnTestUtils.ts index 5588a44d9..f61ec0e7e 100644 --- a/fluxer_api/src/api/auth/tests/WebAuthnTestUtils.ts +++ b/fluxer_api/src/api/auth/tests/WebAuthnTestUtils.ts @@ -21,6 +21,7 @@ export interface WebAuthnDevice { rpId: string; origin: string; signCount: number; + transports?: Array | null; } export interface WebAuthnRegistrationOptions { @@ -47,6 +48,7 @@ export interface WebAuthnAuthenticationOptions { allowCredentials?: Array<{ id: string; type: string; + transports?: Array; }>; userVerification: string; } @@ -75,7 +77,7 @@ export interface WebAuthnTwoFactorResult { interface AuthenticatorAttestationResponse { clientDataJSON: string; attestationObject: string; - transports: Array; + transports?: Array; } interface AuthenticatorAssertionResponse { @@ -363,7 +365,7 @@ export function createRegistrationResponse( response: { clientDataJSON: encodeBase64URL(clientDataJSON), attestationObject: encodeBase64URL(attestationObject), - transports: ['internal'], + ...(device.transports === null ? {} : {transports: device.transports ?? ['internal']}), }, }; }