diff --git a/EXAMPLES.md b/EXAMPLES.md index 04a22b41..0ab06e97 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -16,6 +16,7 @@ - [Multi-Factor Authentication (MFA)](#multi-factor-authentication-mfa) - [Step-Up Authentication](#step-up-authentication) - [Native to Web SSO](#native-to-web-sso) +- [Passkeys](#passkeys) ## Use with a Class Component @@ -1236,3 +1237,146 @@ await loginWithRedirect({ }, }); ``` + +## Passkeys + +Passkeys provide password-less authentication using platform biometrics (Face ID, Touch ID, Windows Hello) or security keys via the WebAuthn standard. The SDK supports two flows: + +- **Signup**: Register a new user with a passkey +- **Login**: Authenticate an existing user with a passkey + +- [Setup](#setup) +- [Important: Use Refresh Tokens with Passkeys](#important-use-refresh-tokens-with-passkeys) +- [Signup with Passkey](#signup-with-passkey) +- [Login with Passkey](#login-with-passkey) +- [Complete Passkey Flow Example](#complete-passkey-flow-example) +- [Error Handling](#passkey-error-handling) + +### Setup + +Before using passkeys, ensure the following are configured in your [Auth0 Dashboard](https://manage.auth0.com): + +1. **Enable passkey authentication method**: Go to **Authentication** > **Database** > your connection > **Authentication Methods** > **Passkey**. +2. **Enable the WebAuthn passkey grant**: Go to your **Application** > **Advanced Settings** > **Grant Types** and enable the **Passkey** grant. +3. **Custom domain required**: Passkeys are bound to an origin (domain). A [custom domain](https://auth0.com/docs/customize/custom-domains) must be configured — passkeys will not work on the default `*.auth0.com` domain. + +### Important: Use Refresh Tokens with Passkeys + +> [!IMPORTANT] +> When using passkeys, you **must** configure `Auth0Provider` with `useRefreshTokens={true}`. + +Passkey authentication uses a direct token exchange (`/oauth/token` with the WebAuthn grant type) and does **not** create an Auth0 session cookie. Without refresh tokens, `getAccessTokenSilently()` will fail with `login_required` when the access token expires — or worse, silently return tokens for a different user if a prior redirect-based session cookie exists. + +```jsx + + + +``` + +You must also enable **Refresh Token Rotation** in your Auth0 Dashboard under **Applications** > your app > **Settings** > **Refresh Token Rotation**. + +### Signup with Passkey + +Register a new user with a passkey. The SDK handles the entire flow internally — requesting a challenge from Auth0, triggering the browser's WebAuthn credential creation ceremony, and exchanging the credential for tokens. After a successful call, `isAuthenticated`, `user`, and `getAccessTokenSilently()` all work as expected. + +```jsx +const { passkey } = useAuth0(); + +const tokens = await passkey.signup({ + email: 'user@example.com', + name: 'Jane Doe' // optional display name +}); +``` + +You can also pass `scope` and `audience` to control the access token: + +```jsx +const tokens = await passkey.signup({ + email: 'user@example.com', + scope: 'openid profile email read:products', + audience: 'https://api.example.com' +}); +``` + +### Login with Passkey + +Authenticate an existing user with their registered passkey. A single call handles the entire assertion flow. + +```jsx +const { passkey } = useAuth0(); + +const tokens = await passkey.login(); +// Or with optional params: +const tokens = await passkey.login({ realm, organization, scope, audience }); +``` + +### Complete Passkey Flow Example + +```jsx +import { useAuth0, PasskeyError, PasskeyRegisterError } from '@auth0/auth0-react'; + +function PasskeyAuth() { + const { passkey, isAuthenticated, user } = useAuth0(); + + const handleSignup = async () => { + try { + await passkey.signup({ email: 'user@example.com' }); + // isAuthenticated and user are now updated automatically + } catch (error) { + if (error instanceof PasskeyRegisterError) { + console.error('Registration failed:', error.message); + } else if (error instanceof PasskeyError) { + console.error('Passkey error:', error.message); + } + } + }; + + const handleLogin = async () => { + try { + await passkey.login(); + // isAuthenticated and user are now updated automatically + } catch (error) { + if (error instanceof PasskeyError) { + console.error('Passkey error:', error.message); + } + } + }; + + if (isAuthenticated) { + return

Welcome, {user.name}!

; + } + + return ( + <> + + + + ); +} +``` + +### Passkey Error Handling + +```jsx +import { PasskeyError, PasskeyRegisterError } from '@auth0/auth0-react'; + +const { passkey } = useAuth0(); + +try { + await passkey.signup({ email: 'user@example.com' }); +} catch (error) { + if (error instanceof PasskeyRegisterError) { + console.error('Registration failed:', error.message); + } else { + console.error('Passkey error:', error.message); + } +} +``` + +> [!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. 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..fe87ac18 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,9 @@ export type { ChallengeResponse, VerifyParams, EnrollmentFactor, + // Passkey Types + PasskeyApiClient, + PasskeySignupOptions, + PasskeyLoginOptions, } from '@auth0/auth0-spa-js'; export { OAuthError } from './errors';