From 32e27cf49f4e265ca22043b1f88a52e2751b4be2 Mon Sep 17 00:00:00 2001 From: Amit Singh Date: Wed, 3 Jun 2026 10:56:51 +0530 Subject: [PATCH 1/6] feat: add passkey API support for signup and login methods --- __mocks__/@auth0/auth0-spa-js.tsx | 14 ++- __tests__/passkey.test.tsx | 143 ++++++++++++++++++++++++++++++ src/auth0-context.tsx | 23 ++++- src/auth0-provider.tsx | 48 +++++++++- src/index.tsx | 16 ++++ 5 files changed, 241 insertions(+), 3 deletions(-) create mode 100644 __tests__/passkey.test.tsx 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..14cc4ea6 --- /dev/null +++ b/__tests__/passkey.test.tsx @@ -0,0 +1,143 @@ +import { Auth0Client } 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: any; + 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: any; + 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'; From b26245e7a90494f39640f1a3cbc6f8e2918270c8 Mon Sep 17 00:00:00 2001 From: Amit Singh Date: Wed, 3 Jun 2026 11:19:54 +0530 Subject: [PATCH 2/6] fix test case --- __tests__/passkey.test.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/__tests__/passkey.test.tsx b/__tests__/passkey.test.tsx index 14cc4ea6..b9190eba 100644 --- a/__tests__/passkey.test.tsx +++ b/__tests__/passkey.test.tsx @@ -1,4 +1,4 @@ -import { Auth0Client } from '@auth0/auth0-spa-js'; +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'; @@ -34,14 +34,14 @@ describe('Passkey API', () => { await waitFor(() => expect(result.current.isLoading).toBe(false)); - let tokenResponse: any; + 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'); + expect(tokenResponse?.access_token).toBe('passkey-token'); + expect(tokenResponse?.id_token).toBe('passkey-id-token'); }); it('should dispatch state update after signup', async () => { @@ -82,13 +82,13 @@ describe('Passkey API', () => { await waitFor(() => expect(result.current.isLoading).toBe(false)); - let tokenResponse: any; + let tokenResponse: TokenEndpointResponse | undefined; await act(async () => { tokenResponse = await result.current.passkey.login(); }); expect(tokenResponse).toBeDefined(); - expect(tokenResponse.access_token).toBe('passkey-token'); + expect(tokenResponse?.access_token).toBe('passkey-token'); }); it('should forward options to the underlying client', async () => { From e6d19453edf7313ad46ffaee41f58081c5117521 Mon Sep 17 00:00:00 2001 From: Amit Singh Date: Tue, 9 Jun 2026 15:42:32 +0530 Subject: [PATCH 3/6] docs: add passkeys section with setup, signup, login, and error handling examples --- EXAMPLES.md | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/EXAMPLES.md b/EXAMPLES.md index 04a22b41..7341841c 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,77 @@ await loginWithRedirect({ }, }); ``` + +## Passkeys + +Access passkey operations through the `passkey` property from `useAuth0()`. The SDK handles the entire WebAuthn flow internally — requesting a challenge from Auth0, triggering the browser's biometric prompt, and exchanging the credential for tokens. + +> [!NOTE] +> Passkeys support is currently in Early Access. To request access to this feature, contact your Auth0 representative. + +- [Setup](#passkey-setup) +- [Signup with Passkey](#signup-with-passkey) +- [Login with Passkey](#login-with-passkey) +- [Error Handling](#passkey-error-handling) + +### Passkey 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. A [custom domain](https://auth0.com/docs/customize/custom-domains) must be configured — passkeys will not work on the default `*.auth0.com` domain. + +### Signup with Passkey + +Register a new user with a passkey. 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(); +``` + +### 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. From 3574bf25f0b66e70169a18cd5503dc9c4e6346b5 Mon Sep 17 00:00:00 2001 From: Amit Singh Date: Tue, 9 Jun 2026 18:55:04 +0530 Subject: [PATCH 4/6] docs: add Passkey API support documentation with usage examples and test plan --- EXAMPLES.md | 64 ++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 56 insertions(+), 8 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 7341841c..f1519d48 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -1240,34 +1240,35 @@ await loginWithRedirect({ ## Passkeys -Access passkey operations through the `passkey` property from `useAuth0()`. The SDK handles the entire WebAuthn flow internally — requesting a challenge from Auth0, triggering the browser's biometric prompt, and exchanging the credential for tokens. +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: -> [!NOTE] -> Passkeys support is currently in Early Access. To request access to this feature, contact your Auth0 representative. +- **Signup**: Register a new user with a passkey +- **Login**: Authenticate an existing user with a passkey -- [Setup](#passkey-setup) +- [Setup](#setup) - [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) -### Passkey Setup +### 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. A [custom domain](https://auth0.com/docs/customize/custom-domains) must be configured — passkeys will not work on the default `*.auth0.com` domain. +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. ### Signup with Passkey -Register a new user with a passkey. After a successful call, `isAuthenticated`, `user`, and `getAccessTokenSilently()` all work as expected. +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 + name: 'Jane Doe' // optional display name }); ``` @@ -1289,6 +1290,53 @@ Authenticate an existing user with their registered passkey. A single call handl 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 From 28b6c0104eda4b28422325f7d7201f987e027c45 Mon Sep 17 00:00:00 2001 From: Amit Singh Date: Tue, 9 Jun 2026 19:06:31 +0530 Subject: [PATCH 5/6] docs: add important note on using refresh tokens with passkeys --- EXAMPLES.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/EXAMPLES.md b/EXAMPLES.md index f1519d48..0ab06e97 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -1246,6 +1246,7 @@ Passkeys provide password-less authentication using platform biometrics (Face ID - **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) @@ -1259,6 +1260,26 @@ Before using passkeys, ensure the following are configured in your [Auth0 Dashbo 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. From 20277e3c9a4dc3b3d5660e9b330505390959ef23 Mon Sep 17 00:00:00 2001 From: Amit Singh Date: Tue, 9 Jun 2026 19:27:12 +0530 Subject: [PATCH 6/6] remove unused passkey-related types from index export --- src/index.tsx | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index 68714e5e..fe87ac18 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -85,12 +85,5 @@ export type { PasskeyApiClient, PasskeySignupOptions, PasskeyLoginOptions, - PasskeyCredentialResponse, - PasskeySignupChallengeOptions, - PasskeySignupChallengeResponse, - PasskeyLoginChallengeOptions, - PasskeyLoginChallengeResponse, - PasskeyCreationOptions, - PasskeyRequestOptions, } from '@auth0/auth0-spa-js'; export { OAuthError } from './errors';