diff --git a/EXAMPLES.md b/EXAMPLES.md index 4215658c..7e20f4b3 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -1498,6 +1498,7 @@ Passkeys provide password-less authentication using platform biometrics (Face ID - [Important: Use Refresh Tokens with Passkeys](#important-use-refresh-tokens-with-passkeys) - [Signup with Passkey](#signup-with-passkey) - [Login with Passkey](#login-with-passkey) +- [Identifier Verification](#identifier-verification) - [Complete Passkey Flow Example](#complete-passkey-flow-example) - [Error Handling](#passkey-error-handling) @@ -1564,10 +1565,106 @@ const tokens = await passkey.login(); const tokens = await passkey.login({ realm, organization, scope, audience }); ``` +### 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 user sees the passkey prompt only once, even if they type a wrong code. + +`signup()` stops **before** the passkey prompt and throws `PasskeyVerificationRequiredError`. Its `verificationRequired` property lists the identifiers to verify (for example `['email']`). Ask the user for the codes, then call `continueSignup()` with them: + +```jsx +import { useState } from 'react'; +import { + useAuth0, + PasskeyError, + PasskeyVerificationRequiredError, + PasskeyGetTokenError +} from '@auth0/auth0-react'; + +function PasskeySignup() { + const { passkey } = useAuth0(); + const [required, setRequired] = useState([]); // e.g. ['email'] + const [error, setError] = useState(); + + const startSignup = async (email) => { + setError(undefined); + try { + await passkey.signup({ email }); + } catch (e) { + if (e instanceof PasskeyVerificationRequiredError) { + setRequired(e.verificationRequired); // show the OTP inputs + } else { + setError(e); + } + } + }; + + // codes is e.g. { email: '123456' } or { email: '123456', phone: '654321' } + const submitCodes = async (codes) => { + setError(undefined); + try { + await passkey.continueSignup(codes); + // isAuthenticated and user are now updated automatically + } catch (e) { + if (e instanceof PasskeyGetTokenError && e.isRetryable) { + // Wrong code and the session is still active: ask again for these + setRequired(e.verificationRequired); + setError(e); + } else if ( + e instanceof PasskeyError && + e.code === 'missing_verification_code' + ) { + // A code was missing: nothing was sent, so keep the same inputs + setError(e); + } else { + // The session has ended: start the signup again + setRequired([]); + setError(e); + } + } + }; + + // ...render an email form, then one OTP input per entry in `required` +} +``` + +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. + +#### Which errors can be retried + +| Error | Retry? | What to do | +|---|---|---| +| `PasskeyGetTokenError` with `isRetryable: true` (wrong code, session still active) | Yes | The error has `code`, `authSession` and `verificationRequired`. Ask for new codes for `error.verificationRequired` and call `continueSignup()` again | +| `PasskeyError` with code `missing_verification_code` (a code is missing or empty) | Yes | Call `continueSignup()` 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 [Multi-Factor Authentication](#multi-factor-authentication-mfa) | +| Any other error, for example an expired code or session, or a `PasskeyGetTokenError` with `isRetryable: false` | No | Start the signup again with `signup()` | + + + +The server decides how many attempts are allowed, so don't keep your own retry count. Retry for as long as `isRetryable` is `true`. Once the server ends the session, the error no longer has an `authSession`, `isRetryable` is `false`, and you need to call `signup()` again. + +#### Phone delivery method + +For phone verification, pass `deliveryMethod: 'text'` (SMS) or `'voice'` to `signup()` to choose how the code is sent: + +```jsx +await passkey.signup({ + phoneNumber: '+14155550100', + deliveryMethod: 'voice' +}); +``` + ### Complete Passkey Flow Example ```jsx -import { useAuth0, PasskeyError, PasskeyRegisterError } from '@auth0/auth0-react'; +import { + useAuth0, + PasskeyError, + PasskeyRegisterError, + PasskeyVerificationRequiredError +} from '@auth0/auth0-react'; function PasskeyAuth() { const { passkey, isAuthenticated, user } = useAuth0(); @@ -1577,7 +1674,10 @@ function PasskeyAuth() { await passkey.signup({ email: 'user@example.com' }); // isAuthenticated and user are now updated automatically } catch (error) { - if (error instanceof PasskeyRegisterError) { + if (error instanceof PasskeyVerificationRequiredError) { + // Collect OTP codes for error.verificationRequired, then call + // passkey.continueSignup(codes). See Identifier Verification above. + } else if (error instanceof PasskeyRegisterError) { console.error('Registration failed:', error.message); } else if (error instanceof PasskeyError) { console.error('Passkey error:', error.message); @@ -1630,6 +1730,8 @@ try { > [!TIP] > Both `signup()` and `login()` throw an error if the user cancels the biometric prompt. Wrap calls in `try/catch` to handle cancellation, network failures, or misconfigured connections. +When identifier verification is on, `signup()` throws `PasskeyVerificationRequiredError` instead of showing the passkey prompt, and a wrong OTP code makes `continueSignup()` throw a `PasskeyGetTokenError` with `isRetryable: true`. Collect new codes for `error.verificationRequired` and call `continueSignup()` again (the same passkey is reused). See [Identifier Verification](#identifier-verification). Other token exchange failures throw a `GenericError` as before (or an `MfaRequiredError` for `mfa_required`). + ## MyAccount API The MyAccount API lets you manage the current user's authentication methods, factors, and connected accounts directly from the SPA. diff --git a/__mocks__/@auth0/auth0-spa-js.tsx b/__mocks__/@auth0/auth0-spa-js.tsx index a70b276b..5421382d 100644 --- a/__mocks__/@auth0/auth0-spa-js.tsx +++ b/__mocks__/@auth0/auth0-spa-js.tsx @@ -30,6 +30,7 @@ const mfaGetEnrollmentFactors = jest.fn(() => Promise.resolve([])); const passkeySignup = jest.fn(() => Promise.resolve({ access_token: 'passkey-token', id_token: 'passkey-id-token' })); const passkeyLogin = jest.fn(() => Promise.resolve({ access_token: 'passkey-token', id_token: 'passkey-id-token' })); +const passkeyContinueSignup = jest.fn(() => Promise.resolve({ access_token: 'passkey-token', id_token: 'passkey-id-token' })); const myAccountGetFactors = jest.fn(() => Promise.resolve([])); const myAccountGetAuthenticationMethods = jest.fn(() => Promise.resolve([])); @@ -80,6 +81,7 @@ export const Auth0Client = jest.fn(() => { passkey: { signup: passkeySignup, login: passkeyLogin, + continueSignup: passkeyContinueSignup, }, myAccount: { getFactors: myAccountGetFactors, @@ -117,6 +119,7 @@ export const PasskeyError = actual.PasskeyError; export const PasskeyRegisterError = actual.PasskeyRegisterError; export const PasskeyChallengeError = actual.PasskeyChallengeError; export const PasskeyGetTokenError = actual.PasskeyGetTokenError; +export const PasskeyVerificationRequiredError = actual.PasskeyVerificationRequiredError; export const MyAccountApiError = actual.MyAccountApiError; export const AnonymousSessionError = actual.AnonymousSessionError; diff --git a/__tests__/passkey.test.tsx b/__tests__/passkey.test.tsx index b9190eba..10dc7272 100644 --- a/__tests__/passkey.test.tsx +++ b/__tests__/passkey.test.tsx @@ -1,11 +1,38 @@ -import { Auth0Client, TokenEndpointResponse } from '@auth0/auth0-spa-js'; +import { + Auth0Client, + PasskeyError, + PasskeyGetTokenError, + PasskeyVerificationRequiredError, + TokenEndpointResponse, +} from '@auth0/auth0-spa-js'; import { act, renderHook, waitFor } from '@testing-library/react'; import useAuth0 from '../src/use-auth0'; +import { OAuthError } from '../src/errors'; +import * as sdk from '../src'; import { createWrapper } from './helpers'; const clientMock = jest.mocked(new Auth0Client({ clientId: '', domain: '' })); +// Not re-exported by the manual mock, so load the real classes. +const { GenericError, MfaRequiredError } = jest.requireActual< + typeof import('@auth0/auth0-spa-js') +>('@auth0/auth0-spa-js'); + +const user = { sub: 'auth0|passkey-user', email: 'user@example.com' }; + +const retryableError = (verificationRequired: Array<'email' | 'phone'>) => + new PasskeyGetTokenError('Invalid or expired session', { + error: 'invalid_grant', + error_description: 'Invalid or expired session', + auth_session: 'auth-session-a', + verification_required: verificationRequired, + }); + describe('Passkey API', () => { + afterEach(() => { + clientMock.getUser.mockReset(); + }); + describe('Basic Availability', () => { it('should provide passkey client through useAuth0', async () => { const wrapper = createWrapper(); @@ -23,8 +50,35 @@ describe('Passkey API', () => { await waitFor(() => { expect(result.current.passkey.signup).toBeDefined(); expect(result.current.passkey.login).toBeDefined(); + expect(result.current.passkey.continueSignup).toBeDefined(); }); }); + + it('should keep the same passkey client across re-renders', async () => { + const wrapper = createWrapper(); + const { result, rerender } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + const passkey = result.current.passkey; + rerender(); + + expect(result.current.passkey).toBe(passkey); + }); + + it('should throw when continueSignup is used outside Auth0Provider', () => { + const { result } = renderHook(() => useAuth0()); + + expect(result.current.passkey.continueSignup).toThrow( + 'You forgot to wrap your component in .' + ); + }); + + it('should export PasskeyVerificationRequiredError from the SDK', () => { + expect(sdk.PasskeyVerificationRequiredError).toBe( + PasskeyVerificationRequiredError + ); + }); }); describe('passkey.signup', () => { @@ -73,6 +127,439 @@ describe('Passkey API', () => { }) ).rejects.toThrow('WebAuthn not supported'); }); + + it('should forward deliveryMethod and other options to the underlying client', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await result.current.passkey.signup({ + email: 'user@example.com', + phoneNumber: '+14155550100', + realm: 'Username-Password-Authentication', + deliveryMethod: 'voice', + }); + }); + + expect(clientMock.passkey.signup).toHaveBeenCalledWith({ + email: 'user@example.com', + phoneNumber: '+14155550100', + realm: 'Username-Password-Authentication', + deliveryMethod: 'voice', + }); + }); + + it('should rethrow PasskeyVerificationRequiredError unchanged when verification is required', async () => { + clientMock.passkey.signup.mockRejectedValueOnce( + new PasskeyVerificationRequiredError(['email']) + ); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + let error: unknown; + await act(async () => { + try { + await result.current.passkey.signup({ email: 'user@example.com' }); + } catch (e) { + error = e; + } + }); + + expect(error).toBeInstanceOf(PasskeyVerificationRequiredError); + expect(error).toBeInstanceOf(GenericError); + expect((error as PasskeyVerificationRequiredError).error).toBe( + 'verification_required' + ); + expect( + (error as PasskeyVerificationRequiredError).verificationRequired + ).toEqual(['email']); + expect(result.current.isAuthenticated).toBe(false); + expect(result.current.user).toBeUndefined(); + }); + + it('should expose every identifier that requires verification', async () => { + clientMock.passkey.signup.mockRejectedValueOnce( + new PasskeyVerificationRequiredError(['email', 'phone']) + ); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await expect( + act(async () => { + await result.current.passkey.signup({ + email: 'user@example.com', + phoneNumber: '+14155550100', + }); + }) + ).rejects.toMatchObject({ verificationRequired: ['email', 'phone'] }); + }); + + it('should update isAuthenticated and user after a successful signup', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.isAuthenticated).toBe(false); + + clientMock.getUser.mockResolvedValue(user); + await act(async () => { + await result.current.passkey.signup({ email: 'user@example.com' }); + }); + + expect(result.current.isAuthenticated).toBe(true); + expect(result.current.user).toEqual(user); + }); + }); + + describe('passkey.continueSignup', () => { + it('should forward the OTP codes and return the token response', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + let tokenResponse: TokenEndpointResponse | undefined; + await act(async () => { + tokenResponse = await result.current.passkey.continueSignup({ + email: '123456', + }); + }); + + expect(clientMock.passkey.continueSignup).toHaveBeenCalledWith({ + email: '123456', + }); + expect(tokenResponse?.access_token).toBe('passkey-token'); + expect(tokenResponse?.id_token).toBe('passkey-id-token'); + }); + + it('should forward codes for both email and phone', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await result.current.passkey.continueSignup({ + email: '123456', + phone: '654321', + }); + }); + + expect(clientMock.passkey.continueSignup).toHaveBeenCalledWith({ + email: '123456', + phone: '654321', + }); + }); + + it('should update isAuthenticated and user after a successful continueSignup', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.isAuthenticated).toBe(false); + + clientMock.getUser.mockResolvedValue(user); + await act(async () => { + await result.current.passkey.continueSignup({ email: '123456' }); + }); + + expect(result.current.isAuthenticated).toBe(true); + expect(result.current.user).toEqual(user); + }); + + it('should complete the step-up flow from signup to continueSignup', async () => { + clientMock.passkey.signup.mockRejectedValueOnce( + new PasskeyVerificationRequiredError(['email']) + ); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + let required: string[] = []; + await act(async () => { + try { + await result.current.passkey.signup({ email: 'user@example.com' }); + } catch (e) { + required = (e as PasskeyVerificationRequiredError) + .verificationRequired; + } + }); + + expect(required).toEqual(['email']); + expect(result.current.isAuthenticated).toBe(false); + + clientMock.getUser.mockResolvedValue(user); + await act(async () => { + await result.current.passkey.continueSignup({ email: '123456' }); + }); + + expect(result.current.isAuthenticated).toBe(true); + expect(result.current.user).toEqual(user); + }); + + it('should rethrow a retryable PasskeyGetTokenError unchanged when an OTP code is wrong', async () => { + clientMock.passkey.continueSignup.mockRejectedValueOnce( + retryableError(['email']) + ); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + let error: unknown; + await act(async () => { + try { + await result.current.passkey.continueSignup({ email: '000000' }); + } catch (e) { + error = e; + } + }); + + expect(error).toBeInstanceOf(PasskeyGetTokenError); + const tokenError = error as PasskeyGetTokenError; + expect(tokenError.isRetryable).toBe(true); + expect(tokenError.authSession).toBe('auth-session-a'); + expect(tokenError.verificationRequired).toEqual(['email']); + expect(result.current.isAuthenticated).toBe(false); + expect(result.current.user).toBeUndefined(); + }); + + it('should list every identifier whose code was wrong', async () => { + clientMock.passkey.continueSignup.mockRejectedValueOnce( + retryableError(['email', 'phone']) + ); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await expect( + act(async () => { + await result.current.passkey.continueSignup({ + email: '000000', + phone: '000000', + }); + }) + ).rejects.toMatchObject({ + isRetryable: true, + verificationRequired: ['email', 'phone'], + }); + }); + + it('should authenticate when a retry with the corrected code succeeds', async () => { + clientMock.passkey.continueSignup.mockRejectedValueOnce( + retryableError(['phone']) + ); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + let required: string[] | undefined; + await act(async () => { + try { + await result.current.passkey.continueSignup({ + email: '123456', + phone: '000000', + }); + } catch (e) { + required = (e as PasskeyGetTokenError).verificationRequired; + } + }); + + expect(required).toEqual(['phone']); + expect(result.current.isAuthenticated).toBe(false); + + clientMock.getUser.mockResolvedValue(user); + await act(async () => { + await result.current.passkey.continueSignup({ phone: '654321' }); + }); + + expect(clientMock.passkey.continueSignup).toHaveBeenCalledTimes(2); + expect(clientMock.passkey.continueSignup).toHaveBeenNthCalledWith(2, { + phone: '654321', + }); + expect(result.current.isAuthenticated).toBe(true); + expect(result.current.user).toEqual(user); + }); + + it('should rethrow a terminal GenericError unchanged, keeping its data', async () => { + const terminalError = new GenericError( + 'invalid_grant', + 'Invalid or expired session', + { + error: 'invalid_grant', + error_description: 'Invalid or expired session', + } + ); + clientMock.passkey.continueSignup.mockRejectedValueOnce(terminalError); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await expect( + act(async () => { + await result.current.passkey.continueSignup({ email: '000000' }); + }) + ).rejects.toBe(terminalError); + expect(terminalError).not.toBeInstanceOf(PasskeyGetTokenError); + expect(terminalError.data).toEqual({ + error: 'invalid_grant', + error_description: 'Invalid or expired session', + }); + expect(result.current.isAuthenticated).toBe(false); + }); + + it('should rethrow PasskeyError with code missing_verification_code', async () => { + clientMock.passkey.continueSignup.mockRejectedValueOnce( + new PasskeyError( + 'missing_verification_code', + 'Missing OTP code for: phone. Pass a code for each identifier in verificationRequired.' + ) + ); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await expect( + act(async () => { + await result.current.passkey.continueSignup({ email: '123456' }); + }) + ).rejects.toMatchObject({ code: 'missing_verification_code' }); + }); + + it('should rethrow PasskeyError with code no_pending_signup', async () => { + clientMock.passkey.continueSignup.mockRejectedValueOnce( + new PasskeyError( + 'no_pending_signup', + 'No pending signup found. Call signup() first and catch PasskeyVerificationRequiredError.' + ) + ); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + let error: unknown; + await act(async () => { + try { + await result.current.passkey.continueSignup({ email: '123456' }); + } catch (e) { + error = e; + } + }); + + expect(error).toBeInstanceOf(PasskeyError); + expect((error as PasskeyError).code).toBe('no_pending_signup'); + }); + + it('should rethrow PasskeyError with code passkey_cancelled when no credential is returned', async () => { + clientMock.passkey.continueSignup.mockRejectedValueOnce( + new PasskeyError( + 'passkey_cancelled', + 'Passkey creation was cancelled or no credential was returned.' + ) + ); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await expect( + act(async () => { + await result.current.passkey.continueSignup({ email: '123456' }); + }) + ).rejects.toMatchObject({ code: 'passkey_cancelled' }); + }); + + it('should rethrow MfaRequiredError with its mfa_token', async () => { + clientMock.passkey.continueSignup.mockRejectedValueOnce( + new MfaRequiredError( + 'mfa_required', + 'Multifactor authentication required', + 'mfa-token-123', + { challenge: [{ type: 'otp' }] } + ) + ); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + let error: unknown; + await act(async () => { + try { + await result.current.passkey.continueSignup({ email: '123456' }); + } catch (e) { + error = e; + } + }); + + expect(error).toBeInstanceOf(MfaRequiredError); + expect((error as InstanceType).mfa_token).toBe( + 'mfa-token-123' + ); + expect(result.current.isAuthenticated).toBe(false); + }); + + it('should normalize a non-Error rejection into an OAuthError', async () => { + clientMock.passkey.continueSignup.mockRejectedValueOnce({ + error: 'invalid_request', + error_description: 'Invalid request', + }); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + let error: unknown; + await act(async () => { + try { + await result.current.passkey.continueSignup({ phone: '654321' }); + } catch (e) { + error = e; + } + }); + + expect(error).toBeInstanceOf(OAuthError); + expect((error as OAuthError).error).toBe('invalid_request'); + }); + + it('should fall back to a generic error for an unknown rejection', async () => { + clientMock.passkey.continueSignup.mockRejectedValueOnce('boom'); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await expect( + act(async () => { + await result.current.passkey.continueSignup({ email: '123456' }); + }) + ).rejects.toThrow('Get access token failed'); + }); }); describe('passkey.login', () => { diff --git a/package-lock.json b/package-lock.json index 348ae4b1..d977ac6b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "2.28.2", "license": "MIT", "dependencies": { - "@auth0/auth0-spa-js": "^2.28.2" + "@auth0/auth0-spa-js": "^2.29.0" }, "devDependencies": { "@rollup/plugin-node-resolve": "^15.0.1", @@ -80,8 +80,8 @@ "license": "ISC" }, "node_modules/@auth0/auth0-auth-js": { - "version": "1.16.1", - "integrity": "sha512-dtH12GiKEZ+YGnwUe0LTe7gJtEEAvhE5ztUL3i8iXN7D3siXe/MUssQq735UlXJzasXNqZOQUkMbv1VMXMZj+g==", + "version": "1.18.0", + "integrity": "sha512-3O3fo2IdToP8x1ZlpQy9/hl37E7Xz1/+bFZR/nLwbmb+zXQT1UYFST1/YY0fqHnqHG2zxzJfBhZ/I0ACjKjY5g==", "license": "MIT", "dependencies": { "jose": "^6.0.8", @@ -89,11 +89,11 @@ } }, "node_modules/@auth0/auth0-spa-js": { - "version": "2.28.4", - "integrity": "sha512-09djIML28o1qTW96inOnyuLZGhGNwkyqySpm6VS/gfWceeqvCjfVAiZtssaNohwvwK7F2NJ1IyC1YHBek5t4Cg==", + "version": "2.29.0", + "integrity": "sha512-Y7HUe47kMOgkMJmDWX34HpbXMeyiuPkHBxWistQUstFdDHLEBX3pUE5pwm/fHI5Qb+yLC+Z+lTXop3uX4i0XkA==", "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" @@ -7812,8 +7812,8 @@ } }, "node_modules/jose": { - "version": "6.2.12", - "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "version": "6.2.13", + "integrity": "sha512-7TIg10ZE1x82YediN+lcrPNvGMKge9yFHQD+Qho/b8y2adfugU3AsvvHfxV0fzyFllT6l9xCjf4RWY9g/Z9PJQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" diff --git a/package.json b/package.json index 61c3b2b8..5ab13f8f 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,6 @@ "react-dom": "^16.11.0 || ^17 || ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" }, "dependencies": { - "@auth0/auth0-spa-js": "^2.28.2" + "@auth0/auth0-spa-js": "^2.29.0" } } diff --git a/src/auth0-context.tsx b/src/auth0-context.tsx index 415179a6..7ba85991 100644 --- a/src/auth0-context.tsx +++ b/src/auth0-context.tsx @@ -479,8 +479,10 @@ export interface Auth0ContextInterface * * - `signup(options)` — register a new user and create a passkey credential * - `login(options?)` — authenticate an existing user via passkey assertion + * - `continueSignup(verification)` — finish a signup that threw + * `PasskeyVerificationRequiredError`, passing the OTP codes the user entered * - * Both methods exchange the WebAuthn credential for Auth0 tokens and update + * These methods exchange the WebAuthn credential for Auth0 tokens and update * `isAuthenticated` / `user` in the same way as `loginWithPopup`. * * @category Sub-clients @@ -594,6 +596,7 @@ export const initialContext = { passkey: { signup: stub, login: stub, + continueSignup: stub, } as unknown as PasskeyApiClient, myAccount: { getFactors: stub, diff --git a/src/auth0-provider.tsx b/src/auth0-provider.tsx index 1fe693d4..f8d98a98 100644 --- a/src/auth0-provider.tsx +++ b/src/auth0-provider.tsx @@ -540,9 +540,34 @@ const Auth0Provider = (opts: Auth0ProviderOptions[0] + ): Promise => { + let tokenResponse; + try { + tokenResponse = await client.passkey.continueSignup(verification); + } catch (error) { + throw tokenError(error); + } finally { + dispatch({ + type: 'GET_ACCESS_TOKEN_COMPLETE', + user: await client.getUser(), + }); + } + return tokenResponse; + }, + [client] + ); + const passkey = useMemo( - () => ({ signup: passkeySignup, login: passkeyLogin }) as unknown as PasskeyApiClient, - [passkeySignup, passkeyLogin] + () => + ({ + signup: passkeySignup, + login: passkeyLogin, + continueSignup: passkeyContinueSignup, + }) as unknown as PasskeyApiClient, + [passkeySignup, passkeyLogin, passkeyContinueSignup] ); const contextValue = useMemo>(() => { diff --git a/src/index.tsx b/src/index.tsx index eb3530eb..0e3166d9 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -84,6 +84,7 @@ export { PasskeyRegisterError, PasskeyChallengeError, PasskeyGetTokenError, + PasskeyVerificationRequiredError, // MyAccount Errors MyAccountApiError, // Anonymous Session Errors