diff --git a/__tests__/Auth0Client/requestTokenForPasskey.test.ts b/__tests__/Auth0Client/requestTokenForPasskey.test.ts index dfdca3783..dc00d2973 100644 --- a/__tests__/Auth0Client/requestTokenForPasskey.test.ts +++ b/__tests__/Auth0Client/requestTokenForPasskey.test.ts @@ -2,7 +2,8 @@ import { verify } from '../../src/jwt'; import { MessageChannel } from 'worker_threads'; import * as utils from '../../src/utils'; import * as scope from '../../src/scope'; -import { MfaRequiredError } from '../../src/errors'; +import { GenericError, MfaRequiredError } from '../../src/errors'; +import { PasskeyGetTokenError } from '../../src/passkey'; import { fetchResponse, setupFn } from './helpers'; @@ -179,6 +180,38 @@ describe('Auth0Client', () => { expect(capturedOptions.realm).toBe('Username-Password-Authentication'); }); + it('includes organization and verification when provided', async () => { + const auth0 = setup(); + + let capturedOptions: any; + auth0['_requestToken'] = async function (options: any) { + capturedOptions = options; + return { + decodedToken: { + encoded: { header: 'h', payload: 'p', signature: 's' }, + header: {}, + claims: { sub: 'me', __raw: 'raw' }, + user: { sub: 'me' } + }, + id_token: TEST_ID_TOKEN, + access_token: TEST_ACCESS_TOKEN, + token_type: 'Bearer', + expires_in: 86400, + scope: options.scope + }; + }; + + await auth0._requestTokenForPasskey({ + authSession: TEST_AUTH_SESSION, + credential: TEST_CREDENTIAL as any, + organization: 'org_abc123', + verification: { email: '123456' } + }); + + expect(capturedOptions.organization).toBe('org_abc123'); + expect(capturedOptions.verification).toEqual({ email: '123456' }); + }); + it('does not include realm when not provided', async () => { const auth0 = setup(); @@ -208,6 +241,36 @@ describe('Auth0Client', () => { expect(capturedOptions.realm).toBeUndefined(); }); + it('does not include verification when it is empty', async () => { + const auth0 = setup(); + + let capturedOptions: any; + auth0['_requestToken'] = async function (options: any) { + capturedOptions = options; + return { + decodedToken: { + encoded: { header: 'h', payload: 'p', signature: 's' }, + header: {}, + claims: { sub: 'me', __raw: 'raw' }, + user: { sub: 'me' } + }, + id_token: TEST_ID_TOKEN, + access_token: TEST_ACCESS_TOKEN, + token_type: 'Bearer', + expires_in: 86400, + scope: options.scope + }; + }; + + await auth0._requestTokenForPasskey({ + authSession: TEST_AUTH_SESSION, + credential: TEST_CREDENTIAL as any, + verification: {} + }); + + expect(capturedOptions).not.toHaveProperty('verification'); + }); + it('caches tokens and establishes session via _requestToken', async () => { const auth0 = setup(); @@ -262,7 +325,7 @@ describe('Auth0Client', () => { ); }); - it('propagates token endpoint errors', async () => { + it('propagates terminal token endpoint errors as GenericError', async () => { const auth0 = setup(); mockFetch.mockResolvedValueOnce( @@ -272,17 +335,118 @@ describe('Auth0Client', () => { }) ); - await expect( - auth0._requestTokenForPasskey({ + const err = await auth0 + ._requestTokenForPasskey({ authSession: TEST_AUTH_SESSION, credential: TEST_CREDENTIAL as any }) - ).rejects.toMatchObject({ + .catch(e => e); + + expect(err).toBeInstanceOf(GenericError); + expect(err).not.toBeInstanceOf(PasskeyGetTokenError); + expect(err).toMatchObject({ error: 'invalid_grant', error_description: 'Auth session has expired' }); }); + it('throws a retryable PasskeyGetTokenError on a wrong OTP code', async () => { + const auth0 = setup(); + + mockFetch.mockResolvedValueOnce( + fetchResponse(false, { + error: 'invalid_grant', + error_description: 'Invalid or expired session', + auth_session: 'auth-session-retry', + verification_required: ['email'] + }) + ); + + const err = await auth0 + ._requestTokenForPasskey({ + authSession: TEST_AUTH_SESSION, + credential: TEST_CREDENTIAL as any, + verification: { email: '000000' } + }) + .catch(e => e); + + expect(err).toBeInstanceOf(PasskeyGetTokenError); + expect(err.isRetryable).toBe(true); + expect(err.authSession).toBe('auth-session-retry'); + expect(err.verificationRequired).toEqual(['email']); + }); + + it('lists every identifier when several OTP codes are wrong', async () => { + const auth0 = setup(); + + mockFetch.mockResolvedValueOnce( + fetchResponse(false, { + error: 'invalid_grant', + error_description: 'Invalid or expired session', + auth_session: 'auth-session-retry', + verification_required: ['email', 'phone'] + }) + ); + + const err = await auth0 + ._requestTokenForPasskey({ + authSession: TEST_AUTH_SESSION, + credential: TEST_CREDENTIAL as any, + verification: { email: '000000', phone: '000000' } + }) + .catch(e => e); + + expect(err).toBeInstanceOf(PasskeyGetTokenError); + expect(err.isRetryable).toBe(true); + expect(err.authSession).toBe('auth-session-retry'); + expect(err.verificationRequired).toEqual(['email', 'phone']); + }); + + it('propagates a missing code error as GenericError', async () => { + const auth0 = setup(); + + mockFetch.mockResolvedValueOnce( + fetchResponse(false, { + error: 'invalid_request', + error_description: 'Missing required parameter: verification.email' + }) + ); + + const err = await auth0 + ._requestTokenForPasskey({ + authSession: TEST_AUTH_SESSION, + credential: TEST_CREDENTIAL as any + }) + .catch(e => e); + + expect(err).toBeInstanceOf(GenericError); + expect(err).not.toBeInstanceOf(PasskeyGetTokenError); + expect(err.error).toBe('invalid_request'); + }); + + it('propagates an error with only auth_session as GenericError', async () => { + const auth0 = setup(); + + mockFetch.mockResolvedValueOnce( + fetchResponse(false, { + error: 'invalid_grant', + error_description: 'Invalid or expired session', + auth_session: 'auth-session-retry' + }) + ); + + const err = await auth0 + ._requestTokenForPasskey({ + authSession: TEST_AUTH_SESSION, + credential: TEST_CREDENTIAL as any + }) + .catch(e => e); + + expect(err).toBeInstanceOf(GenericError); + expect(err).not.toBeInstanceOf(PasskeyGetTokenError); + expect(err.data).toEqual({ auth_session: 'auth-session-retry' }); + }); + it('stores MFA context and re-throws MfaRequiredError', async () => { const auth0 = setup(); const setMFAAuthDetailsSpy = jest.spyOn(auth0.mfa, 'setMFAAuthDetails'); diff --git a/__tests__/http.test.ts b/__tests__/http.test.ts index 740d3ecf5..e6cc8ad53 100644 --- a/__tests__/http.test.ts +++ b/__tests__/http.test.ts @@ -66,6 +66,29 @@ describe('getJson', () => { ).rejects.toHaveProperty('mfa_token', '1234'); }); + it('keeps the other error response fields on GenericError.data', async () => { + mockUnfetch.mockImplementation(() => + Promise.resolve({ + ok: false, + json: () => + Promise.resolve({ + error: 'invalid_grant', + error_description: 'Invalid or expired session', + auth_session: 'abc' + }), + headers: new Headers() + }) + ); + + await expect( + getJSON('https://test.com/', null, null, null, {}, undefined) + ).rejects.toMatchObject({ + error: 'invalid_grant', + error_description: 'Invalid or expired session', + data: { auth_session: 'abc' } + }); + }); + it('throws MissingRefreshTokenError when missing_refresh_token is returned', async () => { mockUnfetch.mockImplementation(() => Promise.resolve({ diff --git a/__tests__/passkey/PasskeyApiClient.test.ts b/__tests__/passkey/PasskeyApiClient.test.ts index 132242e08..0be36e8d5 100644 --- a/__tests__/passkey/PasskeyApiClient.test.ts +++ b/__tests__/passkey/PasskeyApiClient.test.ts @@ -1,10 +1,15 @@ jest.mock('@auth0/auth0-auth-js', () => ({ + ...jest.requireActual('@auth0/auth0-auth-js'), PasskeyClient: jest.fn() })); import { PasskeyApiClient } from '../../src/passkey/PasskeyApiClient'; -import { MfaRequiredError } from '../../src/errors'; -import { PasskeyError } from '../../src/passkey/errors'; +import { + GenericError, + MfaRequiredError, + PasskeyVerificationRequiredError +} from '../../src/errors'; +import { PasskeyError, PasskeyGetTokenError } from '../../src/passkey/errors'; const TEST_AUTH_SESSION = 'auth-session-abc123'; @@ -325,6 +330,7 @@ describe('PasskeyApiClient', () => { userMetadata: { plan: 'pro' }, realm: 'Username-Password-Authentication', organization: 'org_abc123', + deliveryMethod: 'voice', scope: 'openid profile email', audience: 'https://api.example.com' }); @@ -340,7 +346,8 @@ describe('PasskeyApiClient', () => { picture: 'https://example.com/avatar.png', userMetadata: { plan: 'pro' }, realm: 'Username-Password-Authentication', - organization: 'org_abc123' + organization: 'org_abc123', + deliveryMethod: 'voice' }); expect(mockAuth0Client._requestTokenForPasskey).toHaveBeenCalledWith( expect.objectContaining({ @@ -513,6 +520,245 @@ describe('PasskeyApiClient', () => { }) ).rejects.toThrow(mfaError); }); + + it('should throw PasskeyVerificationRequiredError when verification is required', async () => { + mockPasskeyClient.register.mockResolvedValue({ + authSession: TEST_AUTH_SESSION, + authnParamsPublicKey: { + challenge: 'Y2hhbGxlbmdl', + rp: { id: 'example.auth0.com', name: 'Example' }, + user: { id: 'dXNlci0x', name: 'user@example.com', displayName: 'User' }, + pubKeyCredParams: [{ type: 'public-key', alg: -7 }] + }, + verificationRequired: ['email'] + }); + const createMock = jest.fn(); + Object.defineProperty(global.navigator, 'credentials', { + value: { create: createMock }, + configurable: true + }); + + const err = await passkeyClient + .signup({ email: 'user@example.com' }) + .catch(e => e); + + expect(err).toBeInstanceOf(PasskeyVerificationRequiredError); + expect(err).toBeInstanceOf(GenericError); + expect(err.error).toBe('verification_required'); + expect(err.verificationRequired).toEqual(['email']); + expect(createMock).not.toHaveBeenCalled(); + expect(mockAuth0Client._requestTokenForPasskey).not.toHaveBeenCalled(); + }); + }); + + describe('continueSignup', () => { + const verificationChallenge = { + authSession: TEST_AUTH_SESSION, + authnParamsPublicKey: { + challenge: 'Y2hhbGxlbmdl', + rp: { id: 'example.auth0.com', name: 'Example' }, + user: { id: 'dXNlci0x', name: 'user@example.com', displayName: 'User' }, + pubKeyCredParams: [{ type: 'public-key', alg: -7 }] + }, + verificationRequired: ['email'] + }; + + let createMock: jest.Mock; + + beforeEach(async () => { + mockPasskeyClient.register.mockResolvedValue(verificationChallenge); + createMock = jest.fn().mockResolvedValue(createMockPublicKeyCredential('create')); + Object.defineProperty(global.navigator, 'credentials', { + value: { create: createMock }, + configurable: true + }); + + await passkeyClient.signup({ email: 'user@example.com' }).catch(() => {}); + }); + + it('should reuse the session and passkey after a retryable error', async () => { + mockAuth0Client._requestTokenForPasskey + .mockRejectedValueOnce( + new PasskeyGetTokenError('Invalid or expired session', { + error: 'invalid_grant', + error_description: 'Invalid or expired session', + auth_session: 'auth-session-retry', + verification_required: ['email'] + }) + ) + .mockResolvedValueOnce({ access_token: 'at_123' }); + + const err = await passkeyClient.continueSignup({ email: '000000' }).catch(e => e); + expect(err).toBeInstanceOf(PasskeyGetTokenError); + expect(err.isRetryable).toBe(true); + + await expect(passkeyClient.continueSignup({ email: '123456' })).resolves.toEqual({ + access_token: 'at_123' + }); + + expect(createMock).toHaveBeenCalledTimes(1); + const [first, second] = mockAuth0Client._requestTokenForPasskey.mock.calls; + expect(first[0].authSession).toBe(TEST_AUTH_SESSION); + expect(second[0].authSession).toBe('auth-session-retry'); + expect(second[0].credential).toEqual(first[0].credential); + expect(second[0].verification).toEqual({ email: '123456' }); + }); + + it('should clear the pending signup after a non-retryable error', async () => { + mockAuth0Client._requestTokenForPasskey.mockRejectedValueOnce( + new GenericError('invalid_grant', 'Invalid or expired session') + ); + + await expect(passkeyClient.continueSignup({ email: '000000' })).rejects.toBeInstanceOf( + GenericError + ); + + await expect(passkeyClient.continueSignup({ email: '123456' })).rejects.toMatchObject({ + code: 'no_pending_signup' + }); + }); + + it('should keep the pending signup when the passkey prompt is cancelled', async () => { + createMock.mockResolvedValueOnce(null); + mockAuth0Client._requestTokenForPasskey.mockResolvedValueOnce({ access_token: 'at_123' }); + + await expect(passkeyClient.continueSignup({ email: '123456' })).rejects.toMatchObject({ + code: 'passkey_cancelled' + }); + + await expect(passkeyClient.continueSignup({ email: '123456' })).resolves.toEqual({ + access_token: 'at_123' + }); + expect(mockAuth0Client._requestTokenForPasskey.mock.calls[0][0].authSession).toBe( + TEST_AUTH_SESSION + ); + }); + + it('should keep the pending signup when passkey creation throws', async () => { + createMock.mockRejectedValueOnce(new DOMException('Not allowed', 'NotAllowedError')); + mockAuth0Client._requestTokenForPasskey.mockResolvedValueOnce({ access_token: 'at_123' }); + + await expect(passkeyClient.continueSignup({ email: '123456' })).rejects.toMatchObject({ + name: 'NotAllowedError' + }); + + await expect(passkeyClient.continueSignup({ email: '123456' })).resolves.toEqual({ + access_token: 'at_123' + }); + }); + + it('should reject a concurrent continueSignup call', async () => { + mockAuth0Client._requestTokenForPasskey.mockResolvedValueOnce({ access_token: 'at_123' }); + + const first = passkeyClient.continueSignup({ email: '123456' }); + const second = passkeyClient.continueSignup({ email: '123456' }); + + await expect(second).rejects.toMatchObject({ code: 'no_pending_signup' }); + await expect(first).resolves.toEqual({ access_token: 'at_123' }); + expect(createMock).toHaveBeenCalledTimes(1); + }); + + it.each([undefined, {}, { email: '' }])( + 'should throw missing_verification_code before the passkey prompt for %p', + async verification => { + mockAuth0Client._requestTokenForPasskey.mockResolvedValueOnce({ access_token: 'at_123' }); + + await expect(passkeyClient.continueSignup(verification as any)).rejects.toMatchObject({ + code: 'missing_verification_code' + }); + expect(createMock).not.toHaveBeenCalled(); + expect(mockAuth0Client._requestTokenForPasskey).not.toHaveBeenCalled(); + + // Pending signup is kept + await expect(passkeyClient.continueSignup({ email: '123456' })).resolves.toEqual({ + access_token: 'at_123' + }); + } + ); + + it('should list each identifier that has no code', async () => { + mockPasskeyClient.register.mockResolvedValueOnce({ + ...verificationChallenge, + verificationRequired: ['email', 'phone'] + }); + await passkeyClient.signup({ email: 'user@example.com' }).catch(() => {}); + + const err = await passkeyClient.continueSignup({ email: '123456' }).catch(e => e); + + expect(err).toBeInstanceOf(PasskeyError); + expect(err.code).toBe('missing_verification_code'); + expect(err.message).toContain('phone'); + expect(err.message).not.toContain('email'); + expect(createMock).not.toHaveBeenCalled(); + }); + + it.each([ + [{ email: '', phone: '123456' }, 'email', 'phone'], + [{ email: '123456', phone: '' }, 'phone', 'email'] + ])( + 'should treat an empty code as missing for %p', + async (verification, missing, present) => { + mockPasskeyClient.register.mockResolvedValueOnce({ + ...verificationChallenge, + verificationRequired: ['email', 'phone'] + }); + await passkeyClient.signup({ email: 'user@example.com' }).catch(() => {}); + + const err = await passkeyClient.continueSignup(verification).catch(e => e); + + expect(err.code).toBe('missing_verification_code'); + expect(err.message).toContain(missing); + expect(err.message).not.toContain(present); + expect(createMock).not.toHaveBeenCalled(); + expect(mockAuth0Client._requestTokenForPasskey).not.toHaveBeenCalled(); + } + ); + + it('should check codes against verificationRequired from a retryable error', async () => { + mockPasskeyClient.register.mockResolvedValueOnce({ + ...verificationChallenge, + verificationRequired: ['email', 'phone'] + }); + await passkeyClient.signup({ email: 'user@example.com' }).catch(() => {}); + mockAuth0Client._requestTokenForPasskey + .mockRejectedValueOnce( + new PasskeyGetTokenError('Invalid or expired session', { + error: 'invalid_grant', + error_description: 'Invalid or expired session', + auth_session: 'auth-session-retry', + verification_required: ['phone'] + }) + ) + .mockResolvedValueOnce({ access_token: 'at_123' }); + + await expect( + passkeyClient.continueSignup({ email: '123456', phone: '000000' }) + ).rejects.toBeInstanceOf(PasskeyGetTokenError); + + await expect(passkeyClient.continueSignup({ email: '123456' })).rejects.toMatchObject({ + code: 'missing_verification_code' + }); + + await expect(passkeyClient.continueSignup({ phone: '654321' })).resolves.toEqual({ + access_token: 'at_123' + }); + expect(createMock).toHaveBeenCalledTimes(1); + expect(mockAuth0Client._requestTokenForPasskey).toHaveBeenCalledTimes(2); + }); + + it('should clear the pending signup when a new signup does not need verification', async () => { + mockPasskeyClient.register.mockResolvedValueOnce({ + ...verificationChallenge, + verificationRequired: undefined + }); + mockAuth0Client._requestTokenForPasskey.mockResolvedValueOnce({ access_token: 'at_123' }); + + await passkeyClient.signup({ email: 'other@example.com' }); + + await expect(passkeyClient.continueSignup({ email: '123456' })).rejects.toMatchObject({ + code: 'no_pending_signup' + }); + }); }); describe('login', () => { @@ -795,6 +1041,46 @@ describe('PasskeyApiClient', () => { id: 'example.auth0.com', name: 'Example' }); + expect(result.verificationRequired).toBeUndefined(); + }); + + it('should return verificationRequired when the server requires it', async () => { + mockPasskeyClient.register.mockResolvedValue({ + authSession: TEST_AUTH_SESSION, + authnParamsPublicKey: { + challenge: 'Y2hhbGxlbmdl', + rp: { id: 'example.auth0.com', name: 'Example' }, + user: { id: 'dXNlci0x', name: 'user@example.com', displayName: 'User' }, + pubKeyCredParams: [{ type: 'public-key', alg: -7 }] + }, + verificationRequired: ['email'] + }); + + const result = await passkeyClient.getSignupChallenge({ + email: 'user@example.com' + }); + + expect(result.verificationRequired).toEqual(['email']); + }); + + it('should return every identifier when several need verification', async () => { + mockPasskeyClient.register.mockResolvedValue({ + authSession: TEST_AUTH_SESSION, + authnParamsPublicKey: { + challenge: 'Y2hhbGxlbmdl', + rp: { id: 'example.auth0.com', name: 'Example' }, + user: { id: 'dXNlci0x', name: 'user@example.com', displayName: 'User' }, + pubKeyCredParams: [{ type: 'public-key', alg: -7 }] + }, + verificationRequired: ['email', 'phone'] + }); + + const result = await passkeyClient.getSignupChallenge({ + email: 'user@example.com', + phoneNumber: '+1234567890' + }); + + expect(result.verificationRequired).toEqual(['email', 'phone']); }); it('should forward all supported signup properties to register', async () => { @@ -819,7 +1105,8 @@ describe('PasskeyApiClient', () => { picture: 'https://example.com/avatar.png', userMetadata: { plan: 'pro' }, realm: 'Username-Password-Authentication', - organization: 'org_abc123' + organization: 'org_abc123', + deliveryMethod: 'voice' }); // Every supported property is passed straight through to the challenge @@ -836,7 +1123,8 @@ describe('PasskeyApiClient', () => { picture: 'https://example.com/avatar.png', userMetadata: { plan: 'pro' }, realm: 'Username-Password-Authentication', - organization: 'org_abc123' + organization: 'org_abc123', + deliveryMethod: 'voice' }); }); @@ -1066,7 +1354,7 @@ describe('PasskeyApiClient', () => { expect(result).toEqual(mockTokenResponse); }); - it('should forward realm, organization, scope, and audience', async () => { + it('should forward realm, organization, scope, audience, and verification', async () => { mockAuth0Client._requestTokenForPasskey.mockResolvedValue({ access_token: 'at_123', token_type: 'Bearer', @@ -1079,7 +1367,8 @@ describe('PasskeyApiClient', () => { realm: 'Username-Password-Authentication', organization: 'org_abc123', scope: 'openid profile email', - audience: 'https://api.example.com' + audience: 'https://api.example.com', + verification: { email: '123456' } }); expect(mockAuth0Client._requestTokenForPasskey).toHaveBeenCalledWith( @@ -1088,11 +1377,41 @@ describe('PasskeyApiClient', () => { realm: 'Username-Password-Authentication', organization: 'org_abc123', scope: 'openid profile email', - audience: 'https://api.example.com' + audience: 'https://api.example.com', + verification: { email: '123456' } }) ); }); + it.each([{ email: '' }, { email: '123456', phone: '' }])( + 'should throw missing_verification_code without a request for %p', + async verification => { + const err = await passkeyClient + .getTokenWithPasskey({ + authSession: TEST_AUTH_SESSION, + credential: createTypedPublicKeyCredential('create'), + verification + }) + .catch(e => e); + + expect(err).toBeInstanceOf(PasskeyError); + expect(err.code).toBe('missing_verification_code'); + expect(mockAuth0Client._requestTokenForPasskey).not.toHaveBeenCalled(); + } + ); + + it('should not treat an undefined code as missing', async () => { + mockAuth0Client._requestTokenForPasskey.mockResolvedValue({ access_token: 'at_123' }); + + await passkeyClient.getTokenWithPasskey({ + authSession: TEST_AUTH_SESSION, + credential: createTypedPublicKeyCredential('create'), + verification: { email: undefined, phone: '123456' } + }); + + expect(mockAuth0Client._requestTokenForPasskey).toHaveBeenCalledTimes(1); + }); + it('should throw a PasskeyError if the credential is neither attestation nor assertion', async () => { const invalidCredential = { id: 'credential-id-789', diff --git a/examples/passkeys.md b/examples/passkeys.md index b3663522c..f7cea4fa9 100644 --- a/examples/passkeys.md +++ b/examples/passkeys.md @@ -9,6 +9,7 @@ Passkeys provide password-less authentication using platform biometrics (Face ID - [Signup with Passkey](#signup-with-passkey) - [Login with Passkey](#login-with-passkey) - [Granular Passkey APIs](#granular-passkey-apis) +- [Identifier Verification](#identifier-verification) - [Complete Passkey Flow Example](#complete-passkey-flow-example) - [Error Handling](#passkey-error-handling) @@ -264,6 +265,108 @@ async function granularLogin() { > [!NOTE] > The granular APIs (`getSignupChallenge`, `getLoginChallenge`, `getTokenWithPasskey`) provide the same automatic token caching and session establishment as the simplified `signup()` and `login()` methods. After successful completion, `isAuthenticated()`, `getUser()`, and `getTokenSilently()` work as expected. +### Identifier Verification + +Your database connection can ask new users to verify their email or phone number with a one-time code (OTP) during passkey signup. This is off by default and is turned on from the server side. When it's on, the server sends the codes and gives tokens only after they are correct. + +The SDK supports this in both signup flows. The user sees the passkey prompt only once, even if they type a wrong code. + +#### With `signup()` + +`signup()` stops before the passkey prompt and throws `PasskeyVerificationRequiredError`. Ask the user for the codes and call `continueSignup()`: + +```js +import { + PasskeyVerificationRequiredError, + PasskeyGetTokenError +} from '@auth0/auth0-spa-js'; + +const MAX_ATTEMPTS = 3; + +async function signupWithVerification(email) { + try { + return await auth0.passkey.signup({ email }); + } catch (error) { + if (!(error instanceof PasskeyVerificationRequiredError)) throw error; + + let required = error.verificationRequired; // e.g. ['email'] + for (let attempt = 1; ; attempt++) { + const codes = await askUserForCodes(required); // your UI, e.g. { email: '123456' } + try { + return await auth0.passkey.continueSignup(codes); + } catch (e) { + if (!(e instanceof PasskeyGetTokenError && e.isRetryable) || attempt === MAX_ATTEMPTS) { + throw e; + } + required = e.verificationRequired; + } + } + } +} +``` + +The first `continueSignup()` call shows the passkey prompt. Retries reuse the same passkey. + +> [!NOTE] +> The pending signup is kept in memory only. If the page reloads, `continueSignup()` throws a `PasskeyError` with code `no_pending_signup`. Call `signup()` again to start over. + +#### With the granular APIs + +Check `verificationRequired` on the challenge and collect the codes **before** calling `navigator.credentials.create()`. On a retry, send the same `credential` with the new `error.authSession`: + +```js +const MAX_ATTEMPTS = 3; + +async function granularSignupWithVerification(email) { + const challenge = await auth0.passkey.getSignupChallenge({ email }); + + // Collect codes first, so the passkey prompt shows only once + let codes = challenge.verificationRequired + ? await askUserForCodes(challenge.verificationRequired) + : undefined; + + const credential = await navigator.credentials.create({ + publicKey: challenge.publicKey + }); + + if (!credential) { + throw new Error('Credential creation cancelled'); + } + + let authSession = challenge.authSession; + for (let attempt = 1; ; attempt++) { + try { + return await auth0.passkey.getTokenWithPasskey({ + authSession, + credential, // same credential on every retry + verification: codes + }); + } catch (e) { + if (!(e instanceof PasskeyGetTokenError && e.isRetryable) || attempt === MAX_ATTEMPTS) { + throw e; + } + authSession = e.authSession; + codes = await askUserForCodes(e.verificationRequired); + } + } +} +``` + +#### Which errors can be retried + +| Error | Retry? | What to do | +|---|---|---| +| `PasskeyGetTokenError` with `isRetryable: true` (wrong code, session still active) | Yes | Ask for new codes for `error.verificationRequired`. With the granular APIs, also pass `error.authSession` | +| `PasskeyError` with code `missing_verification_code` (a code is missing or empty) | Yes | Call the same method again with a code for each identifier. Nothing was sent, so the session is still active | +| `MfaRequiredError` | No | Finish with MFA using `error.mfa_token`. See [MFA](mfa.md#handling-mfa-required-errors) | +| Any other error, for example an expired code or session, or a missing code with the granular APIs | No | Start the signup again | + +Always cap retries in your app, like `MAX_ATTEMPTS` above. + +#### Phone delivery method + +For phone verification, pass `deliveryMethod: 'text'` (SMS) or `'voice'` to `signup()` or `getSignupChallenge()` to choose how the code is sent. + ### Complete Passkey Flow Example ```js @@ -295,3 +398,19 @@ async function loginWithPasskey() { > [!TIP] > Both `signup()` and `login()` throw an `Error` with a descriptive message if the user cancels the biometric prompt (i.e., the WebAuthn API returns `null`). Wrap calls in try/catch to handle cancellation, network failures, or misconfigured connections. + +When identifier verification is required and the user enters a wrong OTP code, the SDK throws a `PasskeyGetTokenError` with `isRetryable: true`. Collect new codes for `error.verificationRequired` and call `continueSignup()` again (the same passkey is reused), or call `getTokenWithPasskey()` again with `error.authSession` and the same `credential`. See [Identifier Verification](#identifier-verification). Other token exchange failures throw a `GenericError` as before (or an `MfaRequiredError` for `mfa_required`). + +```ts +import { PasskeyGetTokenError } from '@auth0/auth0-spa-js'; + +try { + await auth0.passkey.continueSignup(codes); +} catch (error) { + if (error instanceof PasskeyGetTokenError && error.isRetryable) { + // Wrong code, session still active. Ask again for error.verificationRequired + } else { + throw error; + } +} +``` diff --git a/package-lock.json b/package-lock.json index 2fb905aec..0f504f396 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "2.28.4", "license": "MIT", "dependencies": { - "@auth0/auth0-auth-js": "^1.16.1", + "@auth0/auth0-auth-js": "^1.17.0", "browser-tabs-lock": "^1.3.0", "dpop": "^2.1.1", "es-cookie": "~1.3.2" diff --git a/package.json b/package.json index 321c4edea..dd1b6718d 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ } }, "dependencies": { - "@auth0/auth0-auth-js": "^1.16.1", + "@auth0/auth0-auth-js": "^1.17.0", "browser-tabs-lock": "^1.3.0", "dpop": "^2.1.1", "es-cookie": "~1.3.2" diff --git a/src/Auth0Client.ts b/src/Auth0Client.ts index 3f198c519..118f84edd 100644 --- a/src/Auth0Client.ts +++ b/src/Auth0Client.ts @@ -124,7 +124,7 @@ import { } from './fetcher'; import { MyAccountApiClient } from './myaccount'; import { MfaApiClient } from './mfa'; -import { PasskeyApiClient } from './passkey'; +import { PasskeyApiClient, PasskeyGetTokenError } from './passkey'; import type { PasskeyCredentialResponse } from './passkey/types'; import { AuthClient as Auth0AuthJsClient } from '@auth0/auth0-auth-js'; import { AnonymousSessionApiClient } from './anonymous'; @@ -2342,6 +2342,9 @@ export class Auth0Client { * @internal * Internal method used by PasskeyApiClient to exchange passkey credentials for tokens. * Routes through _requestToken() so tokens are cached, ID token verified, and session established. + * A wrong OTP code (the error response includes `auth_session` and + * `verification_required`) is thrown as a retryable + * `PasskeyGetTokenError`. Other failures are re-thrown unchanged. */ async _requestTokenForPasskey( options: { @@ -2351,19 +2354,39 @@ export class Auth0Client { scope?: string; audience?: string; organization?: string; + verification?: { email?: string; phone?: string }; } ): Promise { const audience = options.audience || this.options.authorizationParams.audience; const organization = options.organization || this.options.authorizationParams.organization; - return this._requestToken({ - grant_type: 'urn:okta:params:oauth:grant-type:webauthn', - auth_session: options.authSession, - authn_response: options.credential, - ...(options.realm && { realm: options.realm }), - ...(organization && { organization }), - scope: scopesToRequest(this.scope, options.scope, audience), - audience, - }); + const hasVerification = + !!options.verification && Object.keys(options.verification).length > 0; + try { + return await this._requestToken({ + grant_type: 'urn:okta:params:oauth:grant-type:webauthn', + auth_session: options.authSession, + authn_response: options.credential, + ...(options.realm && { realm: options.realm }), + ...(organization && { organization }), + ...(hasVerification && { verification: options.verification }), + scope: scopesToRequest(this.scope, options.scope, audience), + audience, + }); + } catch (e) { + if ( + e instanceof GenericError && + e.data?.auth_session && + e.data.verification_required + ) { + throw new PasskeyGetTokenError(e.error_description, { + error: e.error, + error_description: e.error_description, + auth_session: e.data.auth_session, + verification_required: e.data.verification_required + }); + } + throw e; + } } /** diff --git a/src/errors.ts b/src/errors.ts index 9cec77282..478c4c099 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -16,7 +16,16 @@ export interface MfaRequirements { * @category Errors */ export class GenericError extends Error { - constructor(public error: string, public error_description: string) { + /** + * @param error - The `error` code from the server response. + * @param error_description - The `error_description` from the server response. + * @param data - Any other fields from the server's error response (for example `auth_session`). + */ + constructor( + public error: string, + public error_description: string, + public data?: Record + ) { super(error_description); Object.setPrototypeOf(this, GenericError.prototype); } @@ -155,6 +164,27 @@ export class MfaRequiredError extends GenericError { } } +/** + * Thrown by `signup()` when the server requires identifier verification before + * passkey creation. Catch this error, collect OTP codes for each identifier in + * `verificationRequired`, then call `passkey.continueSignup(codes)` to complete + * the flow. The SDK retains the auth session internally — you do not need to + * pass it yourself. + * + * @category Errors + */ +export class PasskeyVerificationRequiredError extends GenericError { + constructor(public readonly verificationRequired: Array<'email' | 'phone'>) { + super( + 'verification_required', + 'Identifier verification is required before passkey creation. ' + + 'Collect OTP codes for each identifier and call passkey.continueSignup().' + ); + //https://github.com/Microsoft/TypeScript-wiki/blob/master/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work + Object.setPrototypeOf(this, PasskeyVerificationRequiredError.prototype); + } +} + /** * Error thrown when there is no refresh token to use * diff --git a/src/http.ts b/src/http.ts index 72f530bfd..6019d10a2 100644 --- a/src/http.ts +++ b/src/http.ts @@ -243,7 +243,7 @@ export async function getJSON( ); } - throw new GenericError(error || 'request_error', errorMessage); + throw new GenericError(error || 'request_error', errorMessage, data); } return data; diff --git a/src/index.ts b/src/index.ts index e8e685130..e4eac948d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -60,7 +60,8 @@ export { MfaRequiredError, MissingRefreshTokenError, MissingScopesError, - UseDpopNonceError + UseDpopNonceError, + PasskeyVerificationRequiredError } from './errors'; export type { MfaRequirements } from './errors'; diff --git a/src/passkey/PasskeyApiClient.ts b/src/passkey/PasskeyApiClient.ts index 6622effa7..abae4640d 100644 --- a/src/passkey/PasskeyApiClient.ts +++ b/src/passkey/PasskeyApiClient.ts @@ -13,7 +13,8 @@ import type { PasskeyGetTokenOptions } from './types'; import type { PasskeyClient } from '@auth0/auth0-auth-js'; -import { PasskeyError } from './errors'; +import { PasskeyError, PasskeyGetTokenError } from './errors'; +import { PasskeyVerificationRequiredError } from '../errors'; /** * Client for Auth0 Passkey operations. @@ -36,6 +37,17 @@ import { PasskeyError } from './errors'; export class PasskeyApiClient { #passkeyClient: PasskeyClient; #auth0Client: Auth0Client; + #pendingSignup: { + authSession: string; + publicKey: PublicKeyCredentialCreationOptions; + verificationRequired: Array<'email' | 'phone'>; + /** Set after the first attempt, so a retry reuses it without a second biometric prompt. */ + credential?: PasskeyCredentialResponse; + realm?: string; + organization?: string; + scope?: string; + audience?: string; + } | null = null; /** * @internal @@ -53,10 +65,15 @@ export class PasskeyApiClient { * WebAuthn credential creation ceremony, serializes the result, and exchanges * it for tokens. * + * If the server requires identifier verification, it stops before creating the + * passkey and throws `PasskeyVerificationRequiredError`. Collect the OTP codes + * and call `continueSignup()` to finish. + * * @param options - Passkey signup options (user identifier, optional scope/audience) * @returns A promise that resolves to the token endpoint response containing access/ID tokens * @throws {PasskeyError} If WebAuthn is not supported in the browser * @throws {PasskeyRegisterError} If the challenge request fails + * @throws {PasskeyVerificationRequiredError} If OTP codes are needed before the passkey is created * @throws {GenericError} If the token exchange fails * @throws {PasskeyError} If the user cancels the WebAuthn prompt */ @@ -65,10 +82,25 @@ export class PasskeyApiClient { throw new PasskeyError('passkey_not_supported', 'WebAuthn is not supported in this browser.'); } + this.#pendingSignup = null; + const { scope, audience, ...challengeOptions } = options; const challenge = await this.#passkeyClient.register(challengeOptions); + if (challenge.verificationRequired && challenge.verificationRequired.length > 0) { + this.#pendingSignup = { + authSession: challenge.authSession, + publicKey: prepareCreationOptions(challenge.authnParamsPublicKey), + verificationRequired: challenge.verificationRequired, + realm: challengeOptions.realm, + organization: challengeOptions.organization, + scope, + audience, + }; + throw new PasskeyVerificationRequiredError(challenge.verificationRequired); + } + const publicKeyOptions = prepareCreationOptions( challenge.authnParamsPublicKey ); @@ -173,7 +205,8 @@ export class PasskeyApiClient { const challenge = await this.#passkeyClient.register(options); return { authSession: challenge.authSession, - publicKey: prepareCreationOptions(challenge.authnParamsPublicKey) + publicKey: prepareCreationOptions(challenge.authnParamsPublicKey), + ...(challenge.verificationRequired && { verificationRequired: challenge.verificationRequired }), }; } @@ -217,6 +250,8 @@ export class PasskeyApiClient { * @returns A promise resolving to the token endpoint response * @throws {PasskeyError} If WebAuthn is not supported in the browser * @throws {PasskeyError} If the credential is not a valid attestation or assertion response + * @throws {PasskeyError} If a code in `verification` is empty. Nothing is sent, so the same session can be retried + * @throws {PasskeyGetTokenError} If an OTP code is wrong and the session is still active (`isRetryable: true`) * @throws {GenericError} If the token exchange fails */ async getTokenWithPasskey( @@ -226,7 +261,13 @@ export class PasskeyApiClient { throw new PasskeyError('passkey_not_supported', 'WebAuthn is not supported in this browser.'); } - const { authSession, credential, realm, organization, scope, audience } = options; + const { authSession, credential, realm, organization, scope, audience, verification } = options; + + const codes: Record = verification ?? {}; + if (Object.keys(codes).some(id => codes[id] === '')) { + throw new PasskeyError('missing_verification_code', 'OTP codes in verification must not be empty.'); + } + const response = credential.response; let serialized: PasskeyCredentialResponse; @@ -248,10 +289,96 @@ export class PasskeyApiClient { realm, organization, scope, - audience + audience, + verification, }); } + /** + * Continue a signup that was interrupted by identifier verification. + * + * Call this after catching `PasskeyVerificationRequiredError` from `signup()`, + * collecting OTP codes from the user, and passing them here. The SDK + * internally retains the auth session — you do not need to pass it. + * + * If the codes are wrong but the session is still alive, this throws a + * `PasskeyGetTokenError` with `isRetryable: true`. Collect new codes for + * `error.verificationRequired` and call `continueSignup()` again; the same + * passkey is reused, so the user is not prompted again. + * + * @param verification - Map of identifier → OTP code (e.g. `{ phone: "123456" }`) + * @returns A promise resolving to the token endpoint response + * @throws {PasskeyError} If there is no pending signup (signup() was not called first) + * @throws {PasskeyError} If a code is missing for a required identifier. The pending signup is kept + * @throws {PasskeyGetTokenError} If an OTP code is wrong and the session is still active (`isRetryable: true`) + * @throws {GenericError} If the token exchange fails + */ + async continueSignup(verification: { email?: string; phone?: string }): Promise { + if (!this.#pendingSignup) { + throw new PasskeyError( + 'no_pending_signup', + 'No pending signup found. Call signup() first and catch PasskeyVerificationRequiredError.' + ); + } + + // Check before the passkey prompt, so a missing code doesn't leave an unused passkey. + const missing = this.#pendingSignup.verificationRequired.filter(id => !verification?.[id]); + if (missing.length > 0) { + throw new PasskeyError( + 'missing_verification_code', + `Missing OTP code for: ${missing.join(', ')}. Pass a code for each identifier in verificationRequired.` + ); + } + + // Clear before any await so a concurrent call can't reuse the same session. + const pending = this.#pendingSignup; + this.#pendingSignup = null; + const { authSession, publicKey, realm, organization, scope, audience } = pending; + + let serialized = pending.credential; + if (!serialized) { + let credential: Credential | null; + try { + credential = await navigator.credentials.create({ publicKey }); + } catch (e) { + this.#pendingSignup = pending; + throw e; + } + + if (!credential) { + this.#pendingSignup = pending; + throw new PasskeyError( + 'passkey_cancelled', + 'Passkey creation was cancelled or no credential was returned.' + ); + } + + serialized = serializeCreationCredential(credential as PublicKeyCredential); + } + + try { + return await this.#auth0Client._requestTokenForPasskey({ + authSession, + credential: serialized, + realm, + organization, + scope, + audience, + verification, + }); + } catch (e) { + if (e instanceof PasskeyGetTokenError && e.isRetryable) { + this.#pendingSignup = { + ...pending, + authSession: e.authSession!, + verificationRequired: e.verificationRequired!, + credential: serialized + }; + } + throw e; + } + } + } function bufferToBase64url(buffer: ArrayBuffer): string { diff --git a/src/passkey/types.ts b/src/passkey/types.ts index eb315bae7..8744bc26e 100644 --- a/src/passkey/types.ts +++ b/src/passkey/types.ts @@ -43,6 +43,13 @@ export type PasskeyLoginOptions = PasskeyLoginChallengeOptions & { export type PasskeySignupChallenge = { authSession: string; publicKey: PublicKeyCredentialCreationOptions; + /** + * Identifiers that require OTP verification before token issuance (e.g. `["email"]`). + * Absent when no verification is required. Collect OTP codes for each identifier + * and pass them as `verification` to `getTokenWithPasskey()` — do NOT call + * `navigator.credentials.create()` until all codes have been collected. + */ + verificationRequired?: Array<'email' | 'phone'>; }; /** @@ -71,4 +78,11 @@ export type PasskeyGetTokenOptions = { organization?: string; scope?: string; audience?: string; + /** + * OTP codes collected for identifier verification. Required when + * `PasskeySignupChallenge.verificationRequired` is non-empty. Keys are the + * identifier names (e.g. `"email"`, `"phone"`); values are the OTP codes. + * Omit entirely when no verification is required. + */ + verification?: { email?: string; phone?: string }; };