diff --git a/__mocks__/@auth0/auth0-spa-js.tsx b/__mocks__/@auth0/auth0-spa-js.tsx index 97aaabb6..b47cfb2c 100644 --- a/__mocks__/@auth0/auth0-spa-js.tsx +++ b/__mocks__/@auth0/auth0-spa-js.tsx @@ -27,6 +27,9 @@ const mfaChallenge = jest.fn(() => Promise.resolve({ challengeType: 'otp', oobCo const mfaVerify = jest.fn(() => Promise.resolve({ access_token: 'test-token', id_token: 'test-id-token' })); 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' })); + export const Auth0Client = jest.fn(() => { return { buildAuthorizeUrl, @@ -57,6 +60,10 @@ export const Auth0Client = jest.fn(() => { verify: mfaVerify, getEnrollmentFactors: mfaGetEnrollmentFactors, }, + passkey: { + signup: passkeySignup, + login: passkeyLogin, + }, }; }); @@ -67,4 +74,9 @@ export const MfaListAuthenticatorsError = actual.MfaListAuthenticatorsError; export const MfaEnrollmentError = actual.MfaEnrollmentError; export const MfaChallengeError = actual.MfaChallengeError; export const MfaVerifyError = actual.MfaVerifyError; -export const MfaEnrollmentFactorsError = actual.MfaEnrollmentFactorsError; \ No newline at end of file +export const MfaEnrollmentFactorsError = actual.MfaEnrollmentFactorsError; + +export const PasskeyError = actual.PasskeyError; +export const PasskeyRegisterError = actual.PasskeyRegisterError; +export const PasskeyChallengeError = actual.PasskeyChallengeError; +export const PasskeyGetTokenError = actual.PasskeyGetTokenError; \ No newline at end of file diff --git a/__tests__/passkey.test.tsx b/__tests__/passkey.test.tsx new file mode 100644 index 00000000..b9190eba --- /dev/null +++ b/__tests__/passkey.test.tsx @@ -0,0 +1,143 @@ +import { Auth0Client, TokenEndpointResponse } from '@auth0/auth0-spa-js'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import useAuth0 from '../src/use-auth0'; +import { createWrapper } from './helpers'; + +const clientMock = jest.mocked(new Auth0Client({ clientId: '', domain: '' })); + +describe('Passkey API', () => { + describe('Basic Availability', () => { + it('should provide passkey client through useAuth0', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => { + expect(result.current.passkey).toBeDefined(); + }); + }); + + it('should provide signup and login methods', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => { + expect(result.current.passkey.signup).toBeDefined(); + expect(result.current.passkey.login).toBeDefined(); + }); + }); + }); + + describe('passkey.signup', () => { + it('should return 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.signup({ email: 'user@example.com' }); + }); + + expect(tokenResponse).toBeDefined(); + expect(tokenResponse?.access_token).toBe('passkey-token'); + expect(tokenResponse?.id_token).toBe('passkey-id-token'); + }); + + it('should dispatch state update after signup', 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' }); + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + }); + + it('should rethrow errors from passkey.signup', async () => { + clientMock.passkey.signup.mockRejectedValueOnce(new Error('WebAuthn not supported')); + + 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' }); + }) + ).rejects.toThrow('WebAuthn not supported'); + }); + }); + + describe('passkey.login', () => { + it('should return 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.login(); + }); + + expect(tokenResponse).toBeDefined(); + expect(tokenResponse?.access_token).toBe('passkey-token'); + }); + + it('should forward 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.login({ + realm: 'Username-Password-Authentication', + scope: 'openid profile email', + }); + }); + + expect(clientMock.passkey.login).toHaveBeenCalledWith({ + realm: 'Username-Password-Authentication', + scope: 'openid profile email', + }); + }); + + it('should dispatch state update after login', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await result.current.passkey.login(); + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + }); + + it('should rethrow errors from passkey.login', async () => { + clientMock.passkey.login.mockRejectedValueOnce(new Error('User cancelled')); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await expect( + act(async () => { + await result.current.passkey.login(); + }) + ).rejects.toThrow('User cancelled'); + }); + }); +}); diff --git a/src/auth0-context.tsx b/src/auth0-context.tsx index 47be599c..a3e85b9d 100644 --- a/src/auth0-context.tsx +++ b/src/auth0-context.tsx @@ -14,7 +14,8 @@ import { ConnectAccountRedirectResult, CustomTokenExchangeOptions, TokenEndpointResponse, - type MfaApiClient + type MfaApiClient, + type PasskeyApiClient } from '@auth0/auth0-spa-js'; import { createContext } from 'react'; import { AuthState, initialAuthState } from './auth-state'; @@ -390,6 +391,22 @@ export interface Auth0ContextInterface * ``` */ mfa: MfaApiClient; + + /** + * ```js + * const { passkey } = useAuth0(); + * const tokens = await passkey.signup({ email: 'user@example.com' }); + * ``` + * + * Passkey API client for WebAuthn-based passwordless authentication. + * + * - `signup(options)` — register a new user and create a passkey credential + * - `login(options?)` — authenticate an existing user via passkey assertion + * + * Both methods exchange the WebAuthn credential for Auth0 tokens and update + * `isAuthenticated` / `user` in the same way as `loginWithPopup`. + */ + passkey: PasskeyApiClient; } /** @@ -429,6 +446,10 @@ export const initialContext = { verify: stub, getEnrollmentFactors: stub, } as unknown as MfaApiClient, + passkey: { + signup: stub, + login: stub, + } as unknown as PasskeyApiClient, }; /** diff --git a/src/auth0-provider.tsx b/src/auth0-provider.tsx index 9c391de8..c579b154 100644 --- a/src/auth0-provider.tsx +++ b/src/auth0-provider.tsx @@ -19,7 +19,10 @@ import { ConnectAccountRedirectResult, ResponseType, CustomTokenExchangeOptions, - TokenEndpointResponse + TokenEndpointResponse, + type PasskeyApiClient, + type PasskeySignupOptions, + type PasskeyLoginOptions } from '@auth0/auth0-spa-js'; import Auth0Context, { Auth0ContextInterface, @@ -391,6 +394,47 @@ const Auth0Provider = (opts: Auth0ProviderOptions client.mfa, [client]); + const passkeySignup = useCallback( + async (options: PasskeySignupOptions): Promise => { + let tokenResponse; + try { + tokenResponse = await client.passkey.signup(options); + } catch (error) { + throw tokenError(error); + } finally { + dispatch({ + type: 'GET_ACCESS_TOKEN_COMPLETE', + user: await client.getUser(), + }); + } + return tokenResponse; + }, + [client] + ); + + const passkeyLogin = useCallback( + async (options?: PasskeyLoginOptions): Promise => { + let tokenResponse; + try { + tokenResponse = await client.passkey.login(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 }) as unknown as PasskeyApiClient, + [passkeySignup, passkeyLogin] + ); + const contextValue = useMemo>(() => { return { ...state, @@ -411,6 +455,7 @@ const Auth0Provider = (opts: Auth0ProviderOptions(opts: Auth0ProviderOptions{children}; diff --git a/src/index.tsx b/src/index.tsx index 1fffea83..68714e5e 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -57,6 +57,11 @@ export { MfaChallengeError, MfaVerifyError, MfaEnrollmentFactorsError, + // Passkey Errors + PasskeyError, + PasskeyRegisterError, + PasskeyChallengeError, + PasskeyGetTokenError, } from '@auth0/auth0-spa-js'; export type { FetcherConfig, @@ -76,5 +81,16 @@ export type { ChallengeResponse, VerifyParams, EnrollmentFactor, + // Passkey Types + PasskeyApiClient, + PasskeySignupOptions, + PasskeyLoginOptions, + PasskeyCredentialResponse, + PasskeySignupChallengeOptions, + PasskeySignupChallengeResponse, + PasskeyLoginChallengeOptions, + PasskeyLoginChallengeResponse, + PasskeyCreationOptions, + PasskeyRequestOptions, } from '@auth0/auth0-spa-js'; export { OAuthError } from './errors';