Skip to content

Commit e0b1eca

Browse files
authored
feat: add passkey identifier verification with step-up pattern (#1299)
### Changes Adds identifier verification (OTP) support to `useAuth0().passkey` for passkey signup, using `@auth0/auth0-spa-js` v2.29.0. - **Step-up flow:** when identifier verification is on for the connection, `signup()` throws `PasskeyVerificationRequiredError` before the passkey prompt. Its `verificationRequired` property lists the identifiers to verify (for example `['email']`). Call the new `passkey.continueSignup(codes)` to finish. - **Wrong OTP:** `continueSignup()` throws a `PasskeyGetTokenError` with `isRetryable: true`. Retrying reuses the same passkey, so the user doesn't see a second biometric prompt. - **State:** `continueSignup()` updates `isAuthenticated` and `user` in the same way as `signup()` and `login()`. Errors are rethrown to the caller. - **Exports:** `PasskeyVerificationRequiredError`. - **Dependency:** `@auth0/auth0-spa-js` goes from `^2.28.2` to `^2.29.0`. No breaking changes. `EXAMPLES.md` has a new Identifier Verification section covering the flow, which errors can be retried, what happens on a page reload, and the phone `deliveryMethod` option. ### References - auth0/auth0-spa-js#1802 ### Testing Unit tests added in `__tests__/passkey.test.tsx`. Tested manually on a test tenant with email and phone OTP: a wrong code, then the right code, with `continueSignup()`. - [x] This change adds unit test coverage - [x] This change has been tested on the latest version of the platform/language <!-- This is an auto-generated comment: release notes by coderabbit.ai --> <!-- coderabbit-ai-generated:v1 --> ## Summary by CodeRabbit - **New Features** - Added support for continuing passkey signup after email or phone verification, including submitting verification codes and selecting text or voice delivery. - Exported `PasskeyVerificationRequiredError` so apps can detect when signup requires verification. - Successful signup continuation updates authentication state, with retry support for eligible errors. - **Documentation** - Expanded passkey examples with guidance on retryable errors, missing codes, and restarting signup if the pending session ends. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent df2ad99 commit e0b1eca

8 files changed

Lines changed: 636 additions & 15 deletions

File tree

‎EXAMPLES.md‎

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1498,6 +1498,7 @@ Passkeys provide password-less authentication using platform biometrics (Face ID
14981498
- [Important: Use Refresh Tokens with Passkeys](#important-use-refresh-tokens-with-passkeys)
14991499
- [Signup with Passkey](#signup-with-passkey)
15001500
- [Login with Passkey](#login-with-passkey)
1501+
- [Identifier Verification](#identifier-verification)
15011502
- [Complete Passkey Flow Example](#complete-passkey-flow-example)
15021503
- [Error Handling](#passkey-error-handling)
15031504
@@ -1564,10 +1565,106 @@ const tokens = await passkey.login();
15641565
const tokens = await passkey.login({ realm, organization, scope, audience });
15651566
```
15661567
1568+
### Identifier Verification
1569+
1570+
Your database connection can ask new users to verify their email or phone number with a one-time code (OTP) during passkey signup. This is off by default and is turned on from the server side. When it's on, the server sends the codes and gives tokens only after they are correct. The user sees the passkey prompt only once, even if they type a wrong code.
1571+
1572+
`signup()` stops **before** the passkey prompt and throws `PasskeyVerificationRequiredError`. Its `verificationRequired` property lists the identifiers to verify (for example `['email']`). Ask the user for the codes, then call `continueSignup()` with them:
1573+
1574+
```jsx
1575+
import { useState } from 'react';
1576+
import {
1577+
useAuth0,
1578+
PasskeyError,
1579+
PasskeyVerificationRequiredError,
1580+
PasskeyGetTokenError
1581+
} from '@auth0/auth0-react';
1582+
1583+
function PasskeySignup() {
1584+
const { passkey } = useAuth0();
1585+
const [required, setRequired] = useState([]); // e.g. ['email']
1586+
const [error, setError] = useState();
1587+
1588+
const startSignup = async (email) => {
1589+
setError(undefined);
1590+
try {
1591+
await passkey.signup({ email });
1592+
} catch (e) {
1593+
if (e instanceof PasskeyVerificationRequiredError) {
1594+
setRequired(e.verificationRequired); // show the OTP inputs
1595+
} else {
1596+
setError(e);
1597+
}
1598+
}
1599+
};
1600+
1601+
// codes is e.g. { email: '123456' } or { email: '123456', phone: '654321' }
1602+
const submitCodes = async (codes) => {
1603+
setError(undefined);
1604+
try {
1605+
await passkey.continueSignup(codes);
1606+
// isAuthenticated and user are now updated automatically
1607+
} catch (e) {
1608+
if (e instanceof PasskeyGetTokenError && e.isRetryable) {
1609+
// Wrong code and the session is still active: ask again for these
1610+
setRequired(e.verificationRequired);
1611+
setError(e);
1612+
} else if (
1613+
e instanceof PasskeyError &&
1614+
e.code === 'missing_verification_code'
1615+
) {
1616+
// A code was missing: nothing was sent, so keep the same inputs
1617+
setError(e);
1618+
} else {
1619+
// The session has ended: start the signup again
1620+
setRequired([]);
1621+
setError(e);
1622+
}
1623+
}
1624+
};
1625+
1626+
// ...render an email form, then one OTP input per entry in `required`
1627+
}
1628+
```
1629+
1630+
The first `continueSignup()` call shows the passkey prompt. Retries reuse the same passkey.
1631+
1632+
> [!NOTE]
1633+
> The pending signup is kept in memory only. If the page reloads, `continueSignup()` throws a `PasskeyError` with code `no_pending_signup`. Call `signup()` again to start over.
1634+
1635+
#### Which errors can be retried
1636+
1637+
| Error | Retry? | What to do |
1638+
|---|---|---|
1639+
| `PasskeyGetTokenError` with `isRetryable: true` (wrong code, session still active) | Yes | The error has `code`, `authSession` and `verificationRequired`. Ask for new codes for `error.verificationRequired` and call `continueSignup()` again |
1640+
| `PasskeyError` with code `missing_verification_code` (a code is missing or empty) | Yes | Call `continueSignup()` again with a code for each identifier. Nothing was sent, so the session is still active |
1641+
| `MfaRequiredError` | No | Finish with MFA using `error.mfa_token`. See [Multi-Factor Authentication](#multi-factor-authentication-mfa) |
1642+
| Any other error, for example an expired code or session, or a `PasskeyGetTokenError` with `isRetryable: false` | No | Start the signup again with `signup()` |
1643+
1644+
1645+
1646+
The server decides how many attempts are allowed, so don't keep your own retry count. Retry for as long as `isRetryable` is `true`. Once the server ends the session, the error no longer has an `authSession`, `isRetryable` is `false`, and you need to call `signup()` again.
1647+
1648+
#### Phone delivery method
1649+
1650+
For phone verification, pass `deliveryMethod: 'text'` (SMS) or `'voice'` to `signup()` to choose how the code is sent:
1651+
1652+
```jsx
1653+
await passkey.signup({
1654+
phoneNumber: '+14155550100',
1655+
deliveryMethod: 'voice'
1656+
});
1657+
```
1658+
15671659
### Complete Passkey Flow Example
15681660
15691661
```jsx
1570-
import { useAuth0, PasskeyError, PasskeyRegisterError } from '@auth0/auth0-react';
1662+
import {
1663+
useAuth0,
1664+
PasskeyError,
1665+
PasskeyRegisterError,
1666+
PasskeyVerificationRequiredError
1667+
} from '@auth0/auth0-react';
15711668

15721669
function PasskeyAuth() {
15731670
const { passkey, isAuthenticated, user } = useAuth0();
@@ -1577,7 +1674,10 @@ function PasskeyAuth() {
15771674
await passkey.signup({ email: 'user@example.com' });
15781675
// isAuthenticated and user are now updated automatically
15791676
} catch (error) {
1580-
if (error instanceof PasskeyRegisterError) {
1677+
if (error instanceof PasskeyVerificationRequiredError) {
1678+
// Collect OTP codes for error.verificationRequired, then call
1679+
// passkey.continueSignup(codes). See Identifier Verification above.
1680+
} else if (error instanceof PasskeyRegisterError) {
15811681
console.error('Registration failed:', error.message);
15821682
} else if (error instanceof PasskeyError) {
15831683
console.error('Passkey error:', error.message);
@@ -1630,6 +1730,8 @@ try {
16301730
> [!TIP]
16311731
> 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.
16321732
1733+
When identifier verification is on, `signup()` throws `PasskeyVerificationRequiredError` instead of showing the passkey prompt, and a wrong OTP code makes `continueSignup()` throw a `PasskeyGetTokenError` with `isRetryable: true`. Collect new codes for `error.verificationRequired` and call `continueSignup()` again (the same passkey is reused). See [Identifier Verification](#identifier-verification). Other token exchange failures throw a `GenericError` as before (or an `MfaRequiredError` for `mfa_required`).
1734+
16331735
## MyAccount API
16341736
16351737
The MyAccount API lets you manage the current user's authentication methods, factors, and connected accounts directly from the SPA.

‎__mocks__/@auth0/auth0-spa-js.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const mfaGetEnrollmentFactors = jest.fn(() => Promise.resolve([]));
3030

3131
const passkeySignup = jest.fn(() => Promise.resolve({ access_token: 'passkey-token', id_token: 'passkey-id-token' }));
3232
const passkeyLogin = jest.fn(() => Promise.resolve({ access_token: 'passkey-token', id_token: 'passkey-id-token' }));
33+
const passkeyContinueSignup = jest.fn(() => Promise.resolve({ access_token: 'passkey-token', id_token: 'passkey-id-token' }));
3334

3435
const myAccountGetFactors = jest.fn(() => Promise.resolve([]));
3536
const myAccountGetAuthenticationMethods = jest.fn(() => Promise.resolve([]));
@@ -80,6 +81,7 @@ export const Auth0Client = jest.fn(() => {
8081
passkey: {
8182
signup: passkeySignup,
8283
login: passkeyLogin,
84+
continueSignup: passkeyContinueSignup,
8385
},
8486
myAccount: {
8587
getFactors: myAccountGetFactors,
@@ -117,6 +119,7 @@ export const PasskeyError = actual.PasskeyError;
117119
export const PasskeyRegisterError = actual.PasskeyRegisterError;
118120
export const PasskeyChallengeError = actual.PasskeyChallengeError;
119121
export const PasskeyGetTokenError = actual.PasskeyGetTokenError;
122+
export const PasskeyVerificationRequiredError = actual.PasskeyVerificationRequiredError;
120123
export const MyAccountApiError = actual.MyAccountApiError;
121124
export const AnonymousSessionError = actual.AnonymousSessionError;
122125

0 commit comments

Comments
 (0)