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
+
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