Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
<Auth0Provider
domain="YOUR_AUTH0_DOMAIN"
clientId="YOUR_AUTH0_CLIENT_ID"
useRefreshTokens={true}
authorizationParams={{ redirect_uri: window.location.origin }}
>
<App />
</Auth0Provider>
```

You must also enable **Refresh Token Rotation** in your Auth0 Dashboard under **Applications** > your app > **Settings** > **Refresh Token Rotation**.

Comment thread
amitsingh05667 marked this conversation as resolved.
### 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 <p>Welcome, {user.name}!</p>;
}

return (
<>
<button onClick={handleSignup}>Sign up with Passkey</button>
<button onClick={handleLogin}>Sign in with Passkey</button>
</>
);
}
```

### 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.
14 changes: 13 additions & 1 deletion __mocks__/@auth0/auth0-spa-js.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -57,6 +60,10 @@ export const Auth0Client = jest.fn(() => {
verify: mfaVerify,
getEnrollmentFactors: mfaGetEnrollmentFactors,
},
passkey: {
signup: passkeySignup,
login: passkeyLogin,
},
};
});

Expand All @@ -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;
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;
143 changes: 143 additions & 0 deletions __tests__/passkey.test.tsx
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
23 changes: 22 additions & 1 deletion src/auth0-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -390,6 +391,22 @@ export interface Auth0ContextInterface<TUser extends User = User>
* ```
*/
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;
}

/**
Expand Down Expand Up @@ -429,6 +446,10 @@ export const initialContext = {
verify: stub,
getEnrollmentFactors: stub,
} as unknown as MfaApiClient,
passkey: {
signup: stub,
login: stub,
} as unknown as PasskeyApiClient,
};

/**
Expand Down
Loading
Loading