diff --git a/.gitignore b/.gitignore index ad341773..7fd22282 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,8 @@ node_modules/ npm-debug.log yarn-debug.log yarn-error.log +# This repo uses Yarn; npm's lock file must not be committed +package-lock.json # BUCK buck-out/ diff --git a/A0Auth0.podspec b/A0Auth0.podspec index 5ed048be..aacad0df 100644 --- a/A0Auth0.podspec +++ b/A0Auth0.podspec @@ -17,7 +17,7 @@ Pod::Spec.new do |s| s.resource_bundles = { 'A0Auth0_privacy' => 'ios/PrivacyInfo.xcprivacy' } s.requires_arc = true - s.dependency 'Auth0', '3.0.1' + s.dependency 'Auth0', '3.2.0' s.dependency 'JWTDecode', '4.0.0' s.dependency 'SimpleKeychain', '1.3.0' diff --git a/EXAMPLES-WEB.md b/EXAMPLES-WEB.md index e62ba88d..94727a98 100644 --- a/EXAMPLES-WEB.md +++ b/EXAMPLES-WEB.md @@ -285,6 +285,8 @@ Passkeys are supported on web via `@auth0/auth0-spa-js`. The flow is the same th Because `navigator.credentials.create()`/`.get()` require a user gesture, call `passkeySignupChallenge` / `passkeyLoginChallenge` from within a click handler (not, for example, from a `useEffect`). +**Identifier verification.** When the database connection requires the user's email or phone number to be verified, `passkeySignupChallenge` returns `verificationRequired`. Collect a one-time code for each identifier **before** calling `navigator.credentials.create()`, then pass them as `verification` to `getTokenByPasskey`. Pass a non-empty code for every identifier in `verificationRequired`: an empty or whitespace-only code is rejected before anything is sent (`PASSKEY_INVALID_PARAMETER`), but on web a missing code is not checked locally, so it is sent and Auth0 rejects the exchange (`PASSKEY_EXCHANGE_FAILED`). `verificationRequired` is accepted but ignored on web (auth0-spa-js works from `verification` alone); passing it keeps one code path for native and web. A wrong code rejects with `PASSKEY_VERIFICATION_RETRYABLE`: read `error.getVerificationRetryPayload()` for `authSession` (the session to retry with) and `verificationRequired` (the identifiers whose code was wrong), then call `getTokenByPasskey` again with the same `PublicKeyCredential` and new codes for those identifiers only. `deliveryMethod` (`'text'` or `'voice'`) chooses how a phone code is delivered. See [Signup with Passkey and Identifier Verification](./EXAMPLES.md#signup-with-passkey-identifier-verification) for the full flow. + ```tsx import { useAuth0, PasskeyError } from 'react-native-auth0'; diff --git a/EXAMPLES.md b/EXAMPLES.md index ca247ec5..32f26cfa 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -58,6 +58,7 @@ - [Overview](#overview-1) - [Prerequisites](#prerequisites-1) - [Signup with Passkey](#signup-with-passkey) + - [Signup with Passkey and Identifier Verification](#signup-with-passkey-identifier-verification) - [Signin with Passkey](#signin-with-passkey) - [Signup with Passkey (Web)](#signup-with-passkey-web) - [Signin with Passkey (Web)](#signin-with-passkey-web) @@ -115,7 +116,7 @@ ## iOS framework linkage (`use_frameworks!`) -This SDK's native iOS dependencies — Auth0 3.0.1, JWTDecode 4.0.0, and SimpleKeychain 1.3.0 — are Swift pods. They install with the default React Native static-library linkage, so **most apps need no extra Podfile changes**: +This SDK's native iOS dependencies — Auth0 3.2.0, JWTDecode 4.0.0, and SimpleKeychain 1.3.0 — are Swift pods. They install with the default React Native static-library linkage, so **most apps need no extra Podfile changes**: ```bash cd ios && pod install @@ -1427,6 +1428,114 @@ function PasskeySignupScreen() { } ``` + + +### Signup with Passkey and Identifier Verification + +When your database connection requires the user's email address or phone number to be verified, Auth0 sends a one-time code (OTP) to each identifier that needs verification when you request the signup challenge. The user has to enter those codes before the signup can complete. + +> **iOS, Android and web.** The WebAuthn ceremony is the only difference: on native, `authResponse` is the JSON string from your credential manager; on web it is the `PublicKeyCredential` from `navigator.credentials.create()` (see [Signup with Passkey (Web)](#signup-with-passkey-web)). + +The flow is the regular [signup](#signup-with-passkey) with two additions: + +1. The signup challenge lists the identifiers to verify in `verificationRequired` (`'email'` and/or `'phone'`). It is `undefined` when no verification is needed, so signups that don't need it behave exactly as before. +2. You pass the codes the user entered to `getTokenByPasskey` as `verification`, a map from identifier to code, and the identifiers they are for as `verificationRequired`. On iOS and Android, `verificationRequired` is passed to Auth0.swift / Auth0.Android, which check that every listed identifier has a code before sending anything. On web it is ignored. Both are optional: when the challenge has no `verificationRequired`, leave both out. + +> **Prerequisites:** the same as a [regular signup](#passkeys-prerequisites). In addition, identifier verification must be enabled for your tenant and required by the database connection. Otherwise `verificationRequired` is `undefined`. + +**Collect the codes before you create the passkey.** Auth0 checks the codes only in `getTokenByPasskey`, after the passkey exists. Collecting them first lets the user cancel before a passkey is created, but it does not prevent an unused passkey if the exchange fails without a retry. A retryable failure reuses the same `authResponse`, so it does not prompt for a second passkey. + +If a code is wrong while its session is still alive, `getTokenByPasskey` rejects with a `PasskeyError` of type `PASSKEY_VERIFICATION_RETRYABLE`, and the signup can be retried without starting over. + +**What the retryable error carries.** Call `error.getVerificationRetryPayload()`. It is the same call on iOS, Android and web, so don't read `error.json` yourself. It returns `{ authSession, verificationRequired }` for this error, and `null` for any other error. + +| Field | Type | What to do with it | +| ---------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `authSession` | `string \| undefined` | The session to send on the retry. Auth0 returns it with every retryable error: it is the same, still valid session. Fall back to the session of the original challenge when it is `undefined`. | +| `verificationRequired` | `('email' \| 'phone')[] \| undefined` | The identifiers whose code was wrong. Ask for new codes for these only: the others are already verified in this session. When it is `undefined`, Auth0 did not say which failed: ask again for every identifier of the original challenge. | + +To retry: + +- Call `getTokenByPasskey` again with the **same** `authResponse` (so the user is not asked to create the passkey a second time), the returned `authSession`, the returned `verificationRequired`, and the new codes for those identifiers only as `verification`. This is how Auth0.swift and auth0-spa-js document the retry. +- With `useAuth0()`, a retryable error is also stored in the hook's `error` until a retry succeeds. Don't treat it as a final failure while you are retrying. + +Any other failure is not retryable: `getVerificationRetryPayload()` returns `null`, so handle the error by its `error.type`. Wrong codes after Auth0's attempt limit and an expired session reject with `PASSKEY_EXCHANGE_FAILED` on every platform: start over from `passkeySignupChallenge`. A missing code (an identifier in `verificationRequired` whose key is absent from `verification`) also rejects with `PASSKEY_EXCHANGE_FAILED`: on iOS and Android, Auth0.swift / Auth0.Android catch it before sending anything, so the session is untouched and you can call again with the code; on web, or when you leave `verificationRequired` out, Auth0 rejects it as an `invalid_request` error. An empty or whitespace-only code is caught on every platform before anything is sent and rejects with `PASSKEY_INVALID_PARAMETER`: the session is untouched, so ask for the code and call again. Auth0 limits wrong codes but doesn't say how many attempts are left, so cap the number of retries in your app. + +```tsx +import { + useAuth0, + PasskeyError, + DeliveryMethod, + type PasskeyVerificationMethod, +} from 'react-native-auth0'; + +type Codes = Record; + +const MAX_ATTEMPTS = 3; // retry limit; adjust it to suit your app + +function PasskeySignupWithVerificationScreen() { + const { passkeySignupChallenge, getTokenByPasskey } = useAuth0(); + + const handleSignup = async () => { + try { + // Step 1: Request the challenge. Auth0 sends a code to each identifier that must be verified. + const challenge = await passkeySignupChallenge({ + email: 'user@example.com', + phoneNumber: '+14155552671', + realm: 'Username-Password-Authentication', + deliveryMethod: DeliveryMethod.TEXT, // optional: how the phone code is delivered ('text' or 'voice') + }); + + // Step 2: Collect a code for every identifier BEFORE creating the passkey. + let verificationRequired: PasskeyVerificationMethod[] | undefined = + challenge.verificationRequired; // undefined when nothing must be verified + let verification: Codes = await promptForCodes( + verificationRequired ?? [] + ); // app-defined + + // Step 3: Create the passkey once. The same credential is reused for every retry. + const credentialJson = await yourCredentialManagerCreate( + challenge.authParamsPublicKey + ); + + // Step 4: Exchange the credential and the codes for tokens. + let authSession = challenge.authSession; + for (let attempt = 1; ; attempt++) { + try { + const credentials = await getTokenByPasskey({ + authSession, + authResponse: credentialJson, + realm: 'Username-Password-Authentication', + verificationRequired, + verification, + }); + console.log('Signed up with passkey'); + return credentials; + } catch (error) { + const retry = + error instanceof PasskeyError + ? error.getVerificationRetryPayload() + : null; + if (!retry || attempt >= MAX_ATTEMPTS) throw error; // not retryable: restart the signup + + // A code was wrong. Retry with new codes for the listed identifiers only. + authSession = retry.authSession ?? authSession; + verificationRequired = + retry.verificationRequired ?? challenge.verificationRequired; + verification = await promptForCodes(verificationRequired ?? []); + } + } + } catch (error) { + if (error instanceof PasskeyError) { + console.error('Passkey signup failed:', error.type, error.message); + } + } + }; + + return