diff --git a/__mocks__/@auth0/auth0-spa-js.tsx b/__mocks__/@auth0/auth0-spa-js.tsx index 5421382d..1a5e0aab 100644 --- a/__mocks__/@auth0/auth0-spa-js.tsx +++ b/__mocks__/@auth0/auth0-spa-js.tsx @@ -31,6 +31,9 @@ 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 passkeyGetSignupChallenge = jest.fn(() => Promise.resolve({ authSession: 'signup-auth-session', publicKey: {} })); +const passkeyGetLoginChallenge = jest.fn(() => Promise.resolve({ authSession: 'login-auth-session', publicKey: {} })); +const passkeyGetTokenWithPasskey = 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([])); @@ -82,6 +85,9 @@ export const Auth0Client = jest.fn(() => { signup: passkeySignup, login: passkeyLogin, continueSignup: passkeyContinueSignup, + getSignupChallenge: passkeyGetSignupChallenge, + getLoginChallenge: passkeyGetLoginChallenge, + getTokenWithPasskey: passkeyGetTokenWithPasskey, }, myAccount: { getFactors: myAccountGetFactors, diff --git a/__tests__/passkey.test.tsx b/__tests__/passkey.test.tsx index 10dc7272..6d445886 100644 --- a/__tests__/passkey.test.tsx +++ b/__tests__/passkey.test.tsx @@ -627,4 +627,99 @@ describe('Passkey API', () => { ).rejects.toThrow('User cancelled'); }); }); + + describe('passkey granular flow', () => { + const credential = {} as PublicKeyCredential; + + it('should forward getSignupChallenge to the underlying client', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + const challenge = await result.current.passkey.getSignupChallenge({ + email: 'user@example.com', + }); + + expect(clientMock.passkey.getSignupChallenge).toHaveBeenCalledWith({ + email: 'user@example.com', + }); + expect(challenge.authSession).toBe('signup-auth-session'); + }); + + it('should forward getLoginChallenge to the underlying client', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + const challenge = await result.current.passkey.getLoginChallenge({ + realm: 'Username-Password-Authentication', + }); + + expect(clientMock.passkey.getLoginChallenge).toHaveBeenCalledWith({ + realm: 'Username-Password-Authentication', + }); + expect(challenge.authSession).toBe('login-auth-session'); + }); + + it('should update isAuthenticated and user after getTokenWithPasskey', 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); + let tokenResponse: TokenEndpointResponse | undefined; + await act(async () => { + tokenResponse = await result.current.passkey.getTokenWithPasskey({ + authSession: 'login-auth-session', + credential, + }); + }); + + expect(clientMock.passkey.getTokenWithPasskey).toHaveBeenCalledWith({ + authSession: 'login-auth-session', + credential, + }); + expect(tokenResponse?.access_token).toBe('passkey-token'); + expect(result.current.isAuthenticated).toBe(true); + expect(result.current.user).toEqual(user); + }); + + it('should rethrow errors from getTokenWithPasskey', async () => { + clientMock.passkey.getTokenWithPasskey.mockRejectedValueOnce( + new PasskeyError('passkey_invalid_credential', 'Invalid credential') + ); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await expect( + act(async () => { + await result.current.passkey.getTokenWithPasskey({ + authSession: 'login-auth-session', + credential, + }); + }) + ).rejects.toThrow('Invalid credential'); + }); + + it('should throw when the granular methods are used outside Auth0Provider', () => { + const { result } = renderHook(() => useAuth0()); + + expect(result.current.passkey.getSignupChallenge).toThrow( + 'You forgot to wrap your component in .' + ); + expect(result.current.passkey.getLoginChallenge).toThrow( + 'You forgot to wrap your component in .' + ); + expect(result.current.passkey.getTokenWithPasskey).toThrow( + 'You forgot to wrap your component in .' + ); + }); + }); }); diff --git a/src/auth0-context.tsx b/src/auth0-context.tsx index 7ba85991..c6cc946f 100644 --- a/src/auth0-context.tsx +++ b/src/auth0-context.tsx @@ -481,9 +481,13 @@ export interface Auth0ContextInterface * - `login(options?)` — authenticate an existing user via passkey assertion * - `continueSignup(verification)` — finish a signup that threw * `PasskeyVerificationRequiredError`, passing the OTP codes the user entered + * - `getSignupChallenge(options)` / `getLoginChallenge(options?)` — request + * a challenge so you can run the WebAuthn ceremony yourself + * - `getTokenWithPasskey(options)` — exchange the credential from that + * ceremony for tokens * - * These methods exchange the WebAuthn credential for Auth0 tokens and update - * `isAuthenticated` / `user` in the same way as `loginWithPopup`. + * The methods that return tokens update `isAuthenticated` / `user` in the + * same way as `loginWithPopup`. * * @category Sub-clients */ @@ -597,6 +601,9 @@ export const initialContext = { signup: stub, login: stub, continueSignup: stub, + getSignupChallenge: stub, + getLoginChallenge: stub, + getTokenWithPasskey: stub, } as unknown as PasskeyApiClient, myAccount: { getFactors: stub, diff --git a/src/auth0-provider.tsx b/src/auth0-provider.tsx index f8d98a98..b808fc5e 100644 --- a/src/auth0-provider.tsx +++ b/src/auth0-provider.tsx @@ -560,14 +560,52 @@ const Auth0Provider = (opts: Auth0ProviderOptions((options) => client.passkey.getSignupChallenge(options), [client]); + + const passkeyGetLoginChallenge = useCallback< + PasskeyApiClient['getLoginChallenge'] + >((options) => client.passkey.getLoginChallenge(options), [client]); + + const passkeyGetTokenWithPasskey = useCallback( + async ( + options: Parameters[0] + ): Promise => { + let tokenResponse; + try { + tokenResponse = await client.passkey.getTokenWithPasskey(options); + } 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, continueSignup: passkeyContinueSignup, + getSignupChallenge: passkeyGetSignupChallenge, + getLoginChallenge: passkeyGetLoginChallenge, + getTokenWithPasskey: passkeyGetTokenWithPasskey, }) as unknown as PasskeyApiClient, - [passkeySignup, passkeyLogin, passkeyContinueSignup] + [ + passkeySignup, + passkeyLogin, + passkeyContinueSignup, + passkeyGetSignupChallenge, + passkeyGetLoginChallenge, + passkeyGetTokenWithPasskey, + ] ); const contextValue = useMemo>(() => {