From 80069ea45e1948afdf9133548de869ed1ba4883b Mon Sep 17 00:00:00 2001 From: nandan_prabhu Date: Wed, 29 Jul 2026 21:52:25 +0530 Subject: [PATCH 1/3] feat: add passkeys support for web (#1604) --- CLAUDE.md | 2 +- EXAMPLES-WEB.md | 58 ++- EXAMPLES.md | 175 +++++-- README.md | 6 +- example/src/App.web.tsx | 113 +++++ package.json | 2 +- src/Auth0.ts | 6 - src/core/models/PasskeyError.ts | 94 +++- .../models/__tests__/PasskeyError.spec.ts | 289 +++++++++++ .../native/adapters/NativeAuth0Client.ts | 9 + .../__tests__/NativeAuth0Client.spec.ts | 30 +- src/platforms/web/adapters/WebAuth0Client.ts | 173 ++++++- .../adapters/__tests__/WebAuth0Client.spec.ts | 473 ++++++++++++++++++ src/types/parameters.ts | 14 +- yarn.lock | 32 +- 15 files changed, 1387 insertions(+), 89 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6d879498..d0e40c3f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,7 +27,7 @@ Apply these on every task in this repo — they keep changes correct, small, and - **Tech Stack:** React Native (New Architecture / TurboModules), iOS (Swift + ObjC++), Android (Kotlin), web (auth0-spa-js) - **Package Manager:** Yarn (Berry, `.yarnrc.yml`); Node pinned via `.nvmrc` (v22.15.0) - **Minimum Platform Version:** React Native ≥ 0.78.0, React ≥ 19.0.0 (peer deps); iOS/Android minimums come from the native SDKs -- **Dependencies:** `@auth0/auth0-spa-js` 2.19.3, `jwt-decode` 4, `base-64`, `url` · test: Jest 29 + `fetch-mock`, `@testing-library/react`. See `package.json` for the authoritative list. +- **Dependencies:** `@auth0/auth0-spa-js` 2.24.0, `jwt-decode` 4, `base-64`, `url` · test: Jest 29 + `fetch-mock`, `@testing-library/react`. See `package.json` for the authoritative list. --- diff --git a/EXAMPLES-WEB.md b/EXAMPLES-WEB.md index c6fded61..6539072c 100644 --- a/EXAMPLES-WEB.md +++ b/EXAMPLES-WEB.md @@ -201,7 +201,7 @@ function MfaScreen({ mfaToken }: { mfaToken: string }) { const verifyOtp = async () => { try { const credentials = await mfa.verify({ mfaToken, otp }); - console.log('Authenticated!', credentials.accessToken); + console.log('Authenticated!'); } catch (error) { if (error instanceof MfaError) { switch (error.type) { @@ -277,6 +277,62 @@ const credentials = await auth0.mfa.verify({ }); ``` +## 4. Passkeys (Web) + +Passkeys are supported on web via `@auth0/auth0-spa-js`. The flow is the same three steps as native (challenge → WebAuthn ceremony → exchange), but step 2 uses the browser's built-in [WebAuthn API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API) (`navigator.credentials.create()`/`.get()`) instead of a native module — the app calls it directly, the SDK does not perform this step for you. `getTokenByPasskey`'s `authResponse` accepts the raw `PublicKeyCredential` returned by `navigator.credentials` directly on web — no manual serialization needed (unlike native, which takes a JSON string; see [Signup with Passkey (Web)](./EXAMPLES.md#signup-with-passkey-web) in `EXAMPLES.md` for the full signup example). + +**Secure context requirement:** `navigator.credentials.create()`/`.get()` only work over HTTPS (or `localhost` for local development). Apps served over plain HTTP will fail at the WebAuthn ceremony with a `SecurityError`. + +Because `navigator.credentials.create()`/`.get()` require a user gesture, call `passkeySignupChallenge` / `passkeyLoginChallenge` from within a click handler (not, for example, from a `useEffect`). + +```tsx +import { useAuth0, PasskeyError } from 'react-native-auth0'; + +function PasskeyLoginButton() { + const { passkeyLoginChallenge, getTokenByPasskey } = useAuth0(); + + const handleLogin = async () => { + try { + const challenge = await passkeyLoginChallenge({ + realm: 'Username-Password-Authentication', + }); + + // App calls navigator.credentials directly (not wrapped by SDK) + const credential = (await navigator.credentials.get({ + publicKey: challenge.authParamsPublicKey as PublicKeyCredentialRequestOptions, + })) as PublicKeyCredential | null; + + if (!credential) { + // User cancelled or no credential available + throw new Error('No passkey credential returned'); + } + + const credentials = await getTokenByPasskey({ + authSession: challenge.authSession, + authResponse: credential, + realm: 'Username-Password-Authentication', + }); + + console.log('Signed in with passkey'); + } catch (error) { + if (error instanceof PasskeyError) { + console.error('Passkey login failed:', error.type, error.message); + + // Handle MFA required scenario + const mfaPayload = error.getMfaRequiredPayload(); + if (mfaPayload) { + console.log('MFA required. Token:', mfaPayload.mfaToken); + console.log('Available factors:', mfaPayload.mfaRequirements); + // Continue with mfa.challenge() and mfa.verify() + } + } + } + }; + + return ; +} +``` + ## Web Platform Notes The web platform supports direct authentication grants including `auth.passwordRealm()`, `auth.createUser()`, `auth.resetPassword()`, and the MFA Flexible Factors Grant. These methods make direct HTTP calls to the Auth0 API. diff --git a/EXAMPLES.md b/EXAMPLES.md index 795a015c..db44b0b8 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -57,7 +57,9 @@ - [Prerequisites](#prerequisites-1) - [Signup with Passkey](#signup-with-passkey) - [Signin with Passkey](#signin-with-passkey) - - [Auth Response Format](#auth-response-format) + - [Signup with Passkey (Web)](#signup-with-passkey-web) + - [Signin with Passkey (Web)](#signin-with-passkey-web) + - [Auth Response Format](#passkeys-auth-response-format) - [Using Passkeys with Auth0 Class](#using-passkeys-with-auth0-class) - [Signup Challenge Parameters](#signup-challenge-parameters) - [Error Handling](#error-handling-1) @@ -522,7 +524,7 @@ function MyComponent() { // The retry mechanism is automatically applied to all credential renewal attempts const credentials = await getCredentials(); - console.log('Access Token:', credentials.accessToken); + console.log('Authenticated successfully'); // Use credentials for API calls... } catch (error) { console.error('Failed to get credentials after retries:', error); @@ -559,7 +561,7 @@ const auth0 = new Auth0({ try { const credentials = await auth0.credentialsManager.getCredentials(); - console.log('Access Token:', credentials.accessToken); + console.log('Credentials retrieved successfully'); } catch (error) { console.error('Credential renewal failed after retries:', error); } @@ -848,7 +850,7 @@ function MyComponent() { 'https://first-api.example.com', 'read:data write:data' ); - console.log('First API Access Token:', credentials.accessToken); + console.log('First API authenticated successfully'); console.log('Expires At:', new Date(credentials.expiresAt * 1000)); } catch (error) { console.error('Error:', error); @@ -862,7 +864,7 @@ function MyComponent() { 'https://second-api.example.com', 'read:reports' ); - console.log('Second API Access Token:', credentials.accessToken); + console.log('Second API authenticated successfully'); } catch (error) { console.error('Error:', error); } @@ -1231,15 +1233,15 @@ For detailed examples of validating different token types in Actions, see: ### Overview -Passkeys provide a passwordless authentication experience using platform biometrics (Face ID, Touch ID, fingerprint) backed by public-key cryptography. The SDK provides the Auth0 challenge and token exchange steps, while you handle the platform credential manager interaction using native modules or libraries like `react-native-passkey`. +Passkeys provide a passwordless authentication experience using platform biometrics (Face ID, Touch ID, fingerprint) backed by public-key cryptography. The same three functions — `passkeySignupChallenge`, `passkeyLoginChallenge`, and `getTokenByPasskey` — are used on native and web; no web-specific methods were added. Only the WebAuthn ceremony step differs: on native you call your own native module or a library like `react-native-passkey`; on web you call the browser's built-in `navigator.credentials.create()`/`.get()` API directly, and pass the resulting `PublicKeyCredential` straight to `getTokenByPasskey` — the SDK does not perform this step for you on either platform. The passkey flow has three steps: 1. **Challenge** — Request a WebAuthn challenge from Auth0 (`passkeySignupChallenge` or `passkeyLoginChallenge`) -2. **Credential Manager** — Present the OS credential manager UI to create or assert a passkey (using your own native module or a library) +2. **WebAuthn Ceremony** — Create or assert the passkey yourself, using whatever mechanism your platform provides. On native, use your own native module or a library (e.g. `react-native-passkey`, which may in turn use Android's `CredentialManager` API or iOS's `ASAuthorizationController`); on web, call `navigator.credentials.create()`/`.get()` directly. 3. **Exchange** — Send the credential response back to Auth0 to get tokens (`getTokenByPasskey`) -> **Platform Support:** Native only (iOS 16.6+ / Android). Not supported on Web. +> **Platform Support:** iOS 16.6+, Android, and Web (modern browsers with WebAuthn support). @@ -1251,6 +1253,7 @@ Before using passkeys: 2. **Configure a custom domain** on your Auth0 tenant (required for passkeys) 3. **iOS:** Requires iOS 16.6 or later. Add an Associated Domain with the `webcredentials` service pointing to your Auth0 custom domain 4. **Android:** Requires Android API 28+. Configure your app's Digital Asset Links for the Auth0 custom domain +5. **Web:** Requires a browser with WebAuthn support (all modern browsers). Passkeys must be triggered from a user gesture (e.g. a button click) due to browser security restrictions. > **Important:** `passkeySignupChallenge` is for creating **new** user accounts with a passkey. It will fail if the email already exists in the database connection. Use `passkeyLoginChallenge` for existing users who have already registered a passkey. @@ -1289,7 +1292,7 @@ function PasskeySignupScreen() { scope: 'openid profile email offline_access', }); - console.log('Signed up with passkey:', credentials.accessToken); + console.log('Signed up with passkey'); } catch (error) { if (error instanceof PasskeyError) { console.error('Passkey signup failed:', error.type, error.message); @@ -1334,7 +1337,7 @@ function PasskeySigninScreen() { scope: 'openid profile email offline_access', }); - console.log('Signed in with passkey:', credentials.accessToken); + console.log('Signed in with passkey'); } catch (error) { if (error instanceof PasskeyError) { console.error('Passkey signin failed:', error.type, error.message); @@ -1346,9 +1349,109 @@ function PasskeySigninScreen() { } ``` + + +### Signup with Passkey (Web) + +Web uses the **exact same `passkeySignupChallenge` / `passkeyLoginChallenge` / `getTokenByPasskey` functions as native** — no web-specific methods were added. Only step 2 (the WebAuthn ceremony) differs: the app calls the browser's built-in [WebAuthn API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API) — `navigator.credentials.create()` for signup and `navigator.credentials.get()` for login — instead of a native module or third-party library. Unlike native, `authResponse` on web accepts the raw `PublicKeyCredential` object returned directly by `navigator.credentials` — no manual serialization needed. (See [Auth Response Format](#passkeys-auth-response-format) for the native, JSON-string form.) + +```tsx +import { useAuth0, PasskeyError } from 'react-native-auth0'; + +function PasskeySignupScreenWeb() { + const { passkeySignupChallenge, getTokenByPasskey } = useAuth0(); + + // Must be called from a user gesture (e.g. an onClick handler). + const handleSignup = async () => { + try { + const challenge = await passkeySignupChallenge({ + email: 'user@example.com', + name: 'John Doe', + realm: 'Username-Password-Authentication', + }); + + // navigator.credentials isn't wrapped by the SDK — normalize a + // cancelled/failed WebAuthn ceremony (e.g. the user dismissed the + // prompt) into a PasskeyError so it's handled the same way as any + // other passkey error below. + let credential: PublicKeyCredential; + try { + credential = (await navigator.credentials.create({ + publicKey: challenge.authParamsPublicKey as PublicKeyCredentialCreationOptions, + })) as PublicKeyCredential; + } catch (e) { + throw new PasskeyError(e as Error); + } + + const credentials = await getTokenByPasskey({ + authSession: challenge.authSession, + authResponse: credential, + realm: 'Username-Password-Authentication', + }); + + console.log('Signed up with passkey'); + } catch (error) { + if (error instanceof PasskeyError) { + console.error('Passkey signup failed:', error.type, error.message); + } + } + }; + + return ; +} +``` + + + +### Signin with Passkey (Web) + +Same idea for login: `passkeyLoginChallenge` and `getTokenByPasskey` are unchanged from native — only the credential-manager step (`navigator.credentials.get()` instead of a native module) is web-specific. + +```tsx +import { useAuth0, PasskeyError } from 'react-native-auth0'; + +function PasskeySigninScreenWeb() { + const { passkeyLoginChallenge, getTokenByPasskey } = useAuth0(); + + // Must be called from a user gesture (e.g. an onClick handler). + const handleSignin = async () => { + try { + const challenge = await passkeyLoginChallenge({ + realm: 'Username-Password-Authentication', + }); + + let credential: PublicKeyCredential; + try { + credential = (await navigator.credentials.get({ + publicKey: challenge.authParamsPublicKey as PublicKeyCredentialRequestOptions, + })) as PublicKeyCredential; + } catch (e) { + throw new PasskeyError(e as Error); + } + + const credentials = await getTokenByPasskey({ + authSession: challenge.authSession, + authResponse: credential, + realm: 'Username-Password-Authentication', + }); + + console.log('Signed in with passkey'); + } catch (error) { + if (error instanceof PasskeyError) { + console.error('Passkey signin failed:', error.type, error.message); + } + } + }; + + return ; +} +``` + + + ### Auth Response Format -The `authResponse` parameter passed to `getTokenByPasskey` must be a JSON string representing the [PublicKeyCredential](https://www.w3.org/TR/webauthn-2/#publickeycredential) response from the platform credential manager. +On **iOS and Android**, the `authResponse` parameter passed to `getTokenByPasskey` must be a JSON string representing the [PublicKeyCredential](https://www.w3.org/TR/webauthn-2/#publickeycredential) response from the platform credential manager. On **web**, pass the raw `PublicKeyCredential` object returned by `navigator.credentials.create()`/`.get()` directly — the SDK serializes it internally. **For registration (signup):** @@ -1431,7 +1534,7 @@ const loginCredentials = await auth0.getTokenByPasskey({ ### Signup Challenge Parameters -The `passkeySignupChallenge` method accepts the following parameters to create a user profile along with the passkey: +The `passkeySignupChallenge` method accepts the following parameters to create a user profile along with the passkey. At least one of `email`, `phoneNumber`, or `username` is required — which of these your database connection actually accepts depends on its configuration (e.g. **Flexible Identifiers**). The SDK does not validate this client-side; an unsupported or missing identifier is rejected by the Auth0 API and surfaces as a `PASSKEY_CHALLENGE_FAILED` error. | Parameter | Type | Description | | -------------- | ------------------------- | ------------------------------------ | @@ -1453,26 +1556,40 @@ The `passkeySignupChallenge` method accepts the following parameters to create a Passkey operations throw `PasskeyError` (extends `AuthError`) with a normalized `type` property. Use `PasskeyErrorCodes` for type-safe error handling: -| Error Code | Description | -| ------------------------------ | --------------------------------------------------- | -| `PASSKEY_CHALLENGE_FAILED` | Auth0 challenge request failed | -| `PASSKEY_EXCHANGE_FAILED` | Token exchange with credential response failed | -| `PASSKEY_NOT_AVAILABLE` | Passkeys not available on this device or OS version | -| `PASSKEY_UNSUPPORTED_PLATFORM` | Passkeys not supported on this platform (Web) | -| `PASSKEY_UNKNOWN_ERROR` | Unknown or uncategorized passkey error | +| Error Code | Description | +| ------------------------------ | ----------------------------------------------------------------------------------------------------------------- | +| `PASSKEY_CHALLENGE_FAILED` | Auth0 challenge request failed | +| `PASSKEY_EXCHANGE_FAILED` | Token exchange with credential response failed | +| `PASSKEY_NOT_AVAILABLE` | Passkeys not available on this device/OS version, or WebAuthn is not supported in this browser | +| `PASSKEY_UNSUPPORTED_PLATFORM` | Passkeys not supported on this platform | +| `PASSKEY_INVALID_PARAMETER` | **Native only.** `authResponse` passed to `getTokenByPasskey` was not a JSON string | +| `PASSKEY_INVALID_CREDENTIAL` | **Web only.** The credential passed to `getTokenByPasskey` is neither a valid attestation (signup) nor assertion (login) response | +| `PASSKEY_MFA_REQUIRED` | **Web only.** MFA is required to complete the exchange — use `error.getMfaRequiredPayload()` to extract `mfaToken` and `mfaRequirements`, then continue with `mfa.challenge()`/`mfa.verify()` | +| `PASSKEY_UNKNOWN_ERROR` | Unknown or uncategorized passkey error — check `error.message` for the underlying description | ```typescript import { PasskeyError, PasskeyErrorCodes } from 'react-native-auth0'; try { - const challenge = await auth0.passkeyLoginChallenge({ - realm: 'Username-Password-Authentication', + const credentials = await auth0.getTokenByPasskey({ + authSession: challenge.authSession, + authResponse: credential, }); } catch (error) { if (error instanceof PasskeyError) { console.log('Error type:', error.type); // e.g. "PASSKEY_CHALLENGE_FAILED" console.log('Error message:', error.message); - console.log('Error code:', error.code); // Raw native error code + console.log('Error code:', error.code); // Raw error code + + // Handle MFA required + if (error.type === PasskeyErrorCodes.MFA_REQUIRED) { + const mfaPayload = error.getMfaRequiredPayload(); + if (mfaPayload) { + console.log('MFA token:', mfaPayload.mfaToken); + console.log('Available factors:', mfaPayload.mfaRequirements); + // Continue with mfa.challenge() / mfa.verify() + } + } } } ``` @@ -1481,13 +1598,13 @@ try { ### Platform Support -| Platform | Support | Requirements | -| ----------- | ---------------- | --------------------------------------------------------- | -| **iOS** | ✅ Supported | iOS 16.6+, Associated Domains with `webcredentials` | -| **Android** | ✅ Supported | Android API 28+, Digital Asset Links configured | -| **Web** | ❌ Not Supported | Throws `PasskeyError` with `PASSKEY_UNSUPPORTED_PLATFORM` | +| Platform | Support | Requirements | +| ----------- | ------------ | ------------------------------------------------------------- | +| **iOS** | ✅ Supported | iOS 16.6+, Associated Domains with `webcredentials` | +| **Android** | ✅ Supported | Android API 28+, Digital Asset Links configured | +| **Web** | ✅ Supported | Modern browser with WebAuthn support; call from a user gesture | -> **Note:** Passkeys require a real device for the full flow. Simulators/emulators may have limited support. +> **Note:** On native platforms, passkeys require a real device for the full flow — simulators/emulators may have limited support. On web, the credential-manager step (step 2) uses the browser's built-in `navigator.credentials` API instead of a native module or third-party library — see [Signup with Passkey (Web)](#signup-with-passkey-web) below. Because `navigator.credentials.create()`/`.get()` require a user gesture, call `passkeySignupChallenge`/`passkeyLoginChallenge` from within a click handler. ## My Account API @@ -2031,7 +2148,7 @@ function MfaScreen({ mfaToken }: { mfaToken: string }) { const verifyOtp = async () => { try { const credentials = await mfa.verify({ mfaToken, otp }); - console.log('Authentication complete!', credentials.accessToken); + console.log('Authentication complete!'); // User is now logged in - state is automatically updated } catch (error) { if (error instanceof MfaError) { diff --git a/README.md b/README.md index 198e9877..1f36d83c 100644 --- a/README.md +++ b/README.md @@ -916,9 +916,9 @@ This library provides a unified API across Native (iOS/Android) and Web platform | `auth.passwordless...()` | ✅ | ❌ | **Not supported on Web.** Passwordless flows on the web should be configured via Universal Login and initiated with `webAuth.authorize()`. | | `auth.loginWith...()` (OTP/SMS etc) | ✅ | ❌ | **Not supported on Web.** These direct grant flows are not secure for public clients like browsers. | | **Passkeys** | | | --- | -| `passkeySignupChallenge()` | ✅ | ❌ | **Native-only.** Gets a WebAuthn registration challenge from Auth0. Requires iOS 16.6+ or Android API 28+. | -| `passkeyLoginChallenge()` | ✅ | ❌ | **Native-only.** Gets a WebAuthn assertion challenge from Auth0. Requires iOS 16.6+ or Android API 28+. | -| `getTokenByPasskey()` | ✅ | ❌ | **Native-only.** Exchanges a passkey credential response for Auth0 tokens. Requires iOS 16.6+ or Android API 28+. | +| `passkeySignupChallenge()` | ✅ | ✅ | Gets a WebAuthn registration challenge from Auth0. Requires iOS 16.6+, Android API 28+, or a browser with WebAuthn support. | +| `passkeyLoginChallenge()` | ✅ | ✅ | Gets a WebAuthn assertion challenge from Auth0. Requires iOS 16.6+, Android API 28+, or a browser with WebAuthn support. | +| `getTokenByPasskey()` | ✅ | ✅ | Exchanges a passkey credential response for Auth0 tokens. On Web, uses `@auth0/auth0-spa-js`. | | **Token & User Management** | | | --- | | `auth.refreshToken()` | ✅ | ❌ | **Not supported on Web.** Token refresh is handled automatically by `getCredentials()` via `getTokenSilently()` on the web. | | `auth.userInfo()` | ✅ | ✅ | Fetches the user's profile from the `/userinfo` endpoint using an access token. | diff --git a/example/src/App.web.tsx b/example/src/App.web.tsx index b53c1a94..337f50e0 100644 --- a/example/src/App.web.tsx +++ b/example/src/App.web.tsx @@ -71,12 +71,17 @@ const HooksAuthContent = (): React.JSX.Element => { mfa, users, myAccount, + passkeySignupChallenge, + passkeyLoginChallenge, + getTokenByPasskey, } = useAuth0(); const [result, setResult] = useState(null); const [apiError, setApiError] = useState(null); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); + const [passkeyEmail, setPasskeyEmail] = useState(''); + const [passkeyLoading, setPasskeyLoading] = useState(false); // Custom Token Exchange (RFC 8693) state const [subjectToken, setSubjectToken] = useState(''); @@ -188,6 +193,91 @@ const HooksAuthContent = (): React.JSX.Element => { } }; + const handlePasskeyError = (e: unknown) => { + if (e instanceof PasskeyError) { + setApiError(e); + return; + } + setApiError(e as Error); + }; + + const onPasskeySignup = async () => { + setResult(null); + setApiError(null); + setPasskeyLoading(true); + try { + const challenge = await passkeySignupChallenge({ + email: passkeyEmail || undefined, + realm: 'Username-Password-Authentication', + }); + + // navigator.credentials isn't wrapped by the SDK — normalize a + // cancelled/failed WebAuthn ceremony into a PasskeyError ourselves + // so callers get the same PasskeyErrorCodes regardless of where the + // failure occurred. + let credential: PublicKeyCredential; + try { + credential = (await navigator.credentials.create({ + publicKey: + challenge.authParamsPublicKey as PublicKeyCredentialCreationOptions, + })) as PublicKeyCredential; + } catch (e) { + throw new PasskeyError(e as Error); + } + + const credentials = await getTokenByPasskey({ + authSession: challenge.authSession, + authResponse: credential, + realm: 'Username-Password-Authentication', + }); + + setResult({ + success: true, + accessToken: `${credentials.accessToken.substring(0, 30)}...`, + }); + } catch (e) { + handlePasskeyError(e); + } finally { + setPasskeyLoading(false); + } + }; + + const onPasskeyLogin = async () => { + setResult(null); + setApiError(null); + setPasskeyLoading(true); + try { + const challenge = await passkeyLoginChallenge({ + realm: 'Username-Password-Authentication', + }); + + let credential: PublicKeyCredential; + try { + credential = (await navigator.credentials.get({ + publicKey: + challenge.authParamsPublicKey as PublicKeyCredentialRequestOptions, + })) as PublicKeyCredential; + } catch (e) { + throw new PasskeyError(e as Error); + } + + const credentials = await getTokenByPasskey({ + authSession: challenge.authSession, + authResponse: credential, + realm: 'Username-Password-Authentication', + }); + + setResult({ + success: true, + accessToken: `${credentials.accessToken.substring(0, 30)}...`, + }); + } catch (e) { + handlePasskeyError(e); + } finally { + setPasskeyLoading(false); + } + }; + const onMfaStart = async () => { setMfaLoading(true); setApiError(null); @@ -1135,6 +1225,29 @@ const HooksAuthContent = (): React.JSX.Element => { )} +
+ + Uses the browser's built-in WebAuthn API (navigator.credentials) + via @auth0/auth0-spa-js. + + +
)} diff --git a/package.json b/package.json index e9695393..c403d448 100644 --- a/package.json +++ b/package.json @@ -135,7 +135,7 @@ "typescript-eslint": "^8.62.0" }, "dependencies": { - "@auth0/auth0-spa-js": "^2.22.0", + "@auth0/auth0-spa-js": "^2.24.0", "base-64": "^1.0.0", "jwt-decode": "^4.0.0", "url": "^0.11.4" diff --git a/src/Auth0.ts b/src/Auth0.ts index 960140d9..2e5a9d20 100644 --- a/src/Auth0.ts +++ b/src/Auth0.ts @@ -185,8 +185,6 @@ class Auth0 { * Returns WebAuthn creation options that should be passed to the platform's * credential manager to create a new passkey credential. * - * @remarks Native only (iOS, Android). Not supported on web. - * * @param parameters The parameters for the signup challenge. * @returns A promise resolving with the challenge response containing authSession and authParamsPublicKey. */ @@ -202,8 +200,6 @@ class Auth0 { * Returns WebAuthn request options that should be passed to the platform's * credential manager to assert an existing passkey. * - * @remarks Native only (iOS, Android). Not supported on web. - * * @param parameters The parameters for the login challenge. * @returns A promise resolving with the challenge response containing authSession and authParamsPublicKey. */ @@ -219,8 +215,6 @@ class Auth0 { * Call this after the platform credential manager returns the passkey * credential (from either signup or login flow). * - * @remarks Native only (iOS, Android). Not supported on web. - * * @param parameters The exchange parameters including authSession and authResponse. * @returns A promise resolving with the user's credentials. */ diff --git a/src/core/models/PasskeyError.ts b/src/core/models/PasskeyError.ts index 4ff68e5c..e2046836 100644 --- a/src/core/models/PasskeyError.ts +++ b/src/core/models/PasskeyError.ts @@ -1,4 +1,5 @@ import { AuthError } from './AuthError'; +import type { MfaRequiredErrorPayload } from '../../types/common'; /** * Platform-agnostic error code constants for Passkey operations. @@ -42,8 +43,23 @@ export const PasskeyErrorCodes = { CHALLENGE_FAILED: 'PASSKEY_CHALLENGE_FAILED', /** Token exchange with the passkey credential response failed */ EXCHANGE_FAILED: 'PASSKEY_EXCHANGE_FAILED', + /** + * The credential response is neither a valid attestation (signup) nor + * assertion (login) response — e.g. it was malformed, tampered with, or + * came from an unexpected source. + */ + INVALID_CREDENTIAL: 'PASSKEY_INVALID_CREDENTIAL', /** Passkeys are not supported on the web platform */ UNSUPPORTED_PLATFORM: 'PASSKEY_UNSUPPORTED_PLATFORM', + /** The parameters provided for the passkey operation were invalid */ + INVALID_PARAMETER: 'PASSKEY_INVALID_PARAMETER', + /** + * Multi-factor authentication is required to complete this passkey + * exchange. Use {@link PasskeyError.getMfaRequiredPayload} for + * structured access to `mfaToken` and `mfaRequirements`, then + * continue with the `mfa` client (`mfa.challenge()` / `mfa.verify()`). + */ + MFA_REQUIRED: 'PASSKEY_MFA_REQUIRED', /** Unknown or uncategorized passkey error */ UNKNOWN_ERROR: 'PASSKEY_UNKNOWN_ERROR', } as const; @@ -52,9 +68,27 @@ const ERROR_CODE_MAP: Record = { PASSKEY_NOT_AVAILABLE: PasskeyErrorCodes.NOT_AVAILABLE, PASSKEY_CHALLENGE_FAILED: PasskeyErrorCodes.CHALLENGE_FAILED, PASSKEY_EXCHANGE_FAILED: PasskeyErrorCodes.EXCHANGE_FAILED, + InvalidParameter: PasskeyErrorCodes.INVALID_PARAMETER, - // --- Web platform --- + // --- Web platform (auth0-spa-js PasskeyApiClient / PasskeyClient) --- UnsupportedOperation: PasskeyErrorCodes.UNSUPPORTED_PLATFORM, + passkey_not_supported: PasskeyErrorCodes.NOT_AVAILABLE, + passkey_register_error: PasskeyErrorCodes.CHALLENGE_FAILED, + passkey_challenge_error: PasskeyErrorCodes.CHALLENGE_FAILED, + passkey_get_token_error: PasskeyErrorCodes.EXCHANGE_FAILED, + passkey_invalid_credential: PasskeyErrorCodes.INVALID_CREDENTIAL, + + // --- Web platform (auth0-spa-js token endpoint / OAuth2, surfaced via + // GenericError and its subclasses during the token-exchange step — + // these only carry an `.error` field, not `.code`, so the web adapter + // extracts `.error` as a fallback before constructing the AuthError + // passed to PasskeyError; see WebAuth0Client.ts) --- + invalid_grant: PasskeyErrorCodes.EXCHANGE_FAILED, + access_denied: PasskeyErrorCodes.EXCHANGE_FAILED, + invalid_request: PasskeyErrorCodes.EXCHANGE_FAILED, + mfa_required: PasskeyErrorCodes.MFA_REQUIRED, + missing_refresh_token: PasskeyErrorCodes.EXCHANGE_FAILED, + use_dpop_nonce: PasskeyErrorCodes.EXCHANGE_FAILED, }; /** @@ -99,7 +133,8 @@ export class PasskeyError extends AuthError { public readonly type: string; /** - * @param originalError The underlying auth error being wrapped. + * @param originalError An `AuthError` from an SDK method (challenge/exchange + * failures from auth0-spa-js or OAuth2 token endpoint). * @param fallbackType The {@link PasskeyErrorCodes} value to use when * `originalError.code` is not a recognized passkey code. Callers that know * which phase failed — e.g. the web My Account adapter, where the error @@ -109,15 +144,60 @@ export class PasskeyError extends AuthError { * defaulting to `UNKNOWN_ERROR`. */ constructor( - originalError: AuthError, + originalError: AuthError | Error, fallbackType: string = PasskeyErrorCodes.UNKNOWN_ERROR ) { + const isAuthError = originalError instanceof AuthError; + const code = isAuthError ? originalError.code : originalError.name; + super(originalError.name, originalError.message, { - status: originalError.status, - code: originalError.code, - json: originalError.json, + status: isAuthError ? originalError.status : undefined, + code, + json: isAuthError ? originalError.json : originalError, }); - this.type = ERROR_CODE_MAP[originalError.code] ?? fallbackType; + this.type = ERROR_CODE_MAP[code] ?? fallbackType; + } + + /** + * Extracts structured MFA details when this error is of type + * `PASSKEY_MFA_REQUIRED`. Use this to obtain both `mfaToken` and + * `mfaRequirements` for continuing the MFA flow with `mfa.challenge()` + * and `mfa.verify()`. + * + * @returns Structured MFA payload if this is an MFA_REQUIRED error, + * otherwise `null`. + * + * @example + * ```typescript + * try { + * const credentials = await auth0.getTokenByPasskey({ + * authSession: challenge.authSession, + * authResponse: credential, + * }); + * } catch (error) { + * if (error instanceof PasskeyError) { + * const mfaPayload = error.getMfaRequiredPayload(); + * if (mfaPayload) { + * // MFA required - continue with mfa.challenge() / mfa.verify() + * console.log('MFA token:', mfaPayload.mfaToken); + * console.log('Available factors:', mfaPayload.mfaRequirements); + * } + * } + * } + * ``` + */ + public getMfaRequiredPayload(): MfaRequiredErrorPayload | null { + if (this.type !== PasskeyErrorCodes.MFA_REQUIRED) { + return null; + } + + const json = this.json as any; + return { + mfaToken: json.mfa_token ?? '', + error: json.error ?? this.code, + errorDescription: json.error_description ?? this.message, + mfaRequirements: json.mfa_requirements, + }; } } diff --git a/src/core/models/__tests__/PasskeyError.spec.ts b/src/core/models/__tests__/PasskeyError.spec.ts index 77ea73fc..551cd91f 100644 --- a/src/core/models/__tests__/PasskeyError.spec.ts +++ b/src/core/models/__tests__/PasskeyError.spec.ts @@ -61,4 +61,293 @@ describe('PasskeyError', () => { expect(err.type).toBe(PasskeyErrorCodes.NOT_AVAILABLE); }); }); + + describe('error code mapping', () => { + const testCases: [string, string, string][] = [ + [ + 'PASSKEY_NOT_AVAILABLE', + 'PASSKEY_NOT_AVAILABLE', + 'native: passkeys unavailable on this device/OS', + ], + [ + 'PASSKEY_CHALLENGE_FAILED', + 'PASSKEY_CHALLENGE_FAILED', + 'native: challenge request failed', + ], + [ + 'PASSKEY_EXCHANGE_FAILED', + 'PASSKEY_EXCHANGE_FAILED', + 'native: token exchange failed', + ], + [ + 'InvalidParameter', + 'PASSKEY_INVALID_PARAMETER', + 'SDK-level parameter validation (native authResponse-must-be-a-string guard)', + ], + [ + 'UnsupportedOperation', + 'PASSKEY_UNSUPPORTED_PLATFORM', + 'operation not supported on this platform', + ], + [ + 'passkey_not_supported', + 'PASSKEY_NOT_AVAILABLE', + 'spa-js: WebAuthn unsupported in this browser', + ], + [ + 'passkey_register_error', + 'PASSKEY_CHALLENGE_FAILED', + 'spa-js: signup challenge request failed', + ], + [ + 'passkey_challenge_error', + 'PASSKEY_CHALLENGE_FAILED', + 'spa-js: login challenge request failed', + ], + [ + 'passkey_get_token_error', + 'PASSKEY_EXCHANGE_FAILED', + 'spa-js: token exchange failed', + ], + [ + 'passkey_invalid_credential', + 'PASSKEY_INVALID_CREDENTIAL', + 'spa-js: credential is neither an attestation nor an assertion response', + ], + [ + 'invalid_grant', + 'PASSKEY_EXCHANGE_FAILED', + 'spa-js: GenericError from the /oauth/token webauthn grant', + ], + [ + 'access_denied', + 'PASSKEY_EXCHANGE_FAILED', + 'spa-js: GenericError, access denied during token exchange', + ], + [ + 'invalid_request', + 'PASSKEY_EXCHANGE_FAILED', + 'spa-js: GenericError, malformed token request', + ], + [ + 'mfa_required', + 'PASSKEY_MFA_REQUIRED', + 'spa-js: MfaRequiredError during the webauthn grant token exchange', + ], + [ + 'missing_refresh_token', + 'PASSKEY_EXCHANGE_FAILED', + 'spa-js: MissingRefreshTokenError', + ], + [ + 'use_dpop_nonce', + 'PASSKEY_EXCHANGE_FAILED', + 'spa-js: UseDpopNonceError (DPoP nonce retry exhausted)', + ], + ]; + + it.each(testCases)( + 'should map code "%s" to type "%s" (%s)', + (code, expectedType) => { + const original = new AuthError('error', 'message', { code }); + const error = new PasskeyError(original); + expect(error.type).toBe(expectedType); + } + ); + + it('should fall back to UNKNOWN_ERROR for unmapped codes', () => { + const original = new AuthError('some_error', 'Something', { + code: 'completely_unknown_code', + }); + const error = new PasskeyError(original); + expect(error.type).toBe(PasskeyErrorCodes.UNKNOWN_ERROR); + }); + + it('should preserve the original descriptive message even when the code is unmapped', () => { + // e.g. auth0-spa-js's ID token verification throws a bare Error with + // no .code/.error at all — the message is the only signal available, + // and it must survive even though .type can only be UNKNOWN_ERROR. + const original = new AuthError( + 'Error', + 'Signature algorithm of "none" is not supported. Expected the ID token to be signed with "RS256".', + { code: 'unknown_error' } + ); + const error = new PasskeyError(original); + expect(error.type).toBe(PasskeyErrorCodes.UNKNOWN_ERROR); + expect(error.message).toBe( + 'Signature algorithm of "none" is not supported. Expected the ID token to be signed with "RS256".' + ); + }); + }); + + describe('Unmapped error codes', () => { + it('should fall back to UNKNOWN_ERROR for unrecognized AuthError code while preserving the message', () => { + const original = new AuthError( + 'unknown_passkey_error', + 'Some future passkey error from spa-js', + { code: 'unknown_passkey_error' } + ); + const error = new PasskeyError(original); + expect(error.type).toBe(PasskeyErrorCodes.UNKNOWN_ERROR); + expect(error.message).toBe('Some future passkey error from spa-js'); + }); + + it('should fall back to UNKNOWN_ERROR for a plain Error with no matching name', () => { + const e = new Error('Network request failed'); + const error = new PasskeyError(e); + expect(error.type).toBe(PasskeyErrorCodes.UNKNOWN_ERROR); + expect(error.message).toBe('Network request failed'); + }); + + it('should expose the original error via .json for app-level inspection', () => { + const original = new AuthError('unknown_error', 'Something went wrong', { + code: 'unknown_error', + json: { detail: 'Internal error' }, + }); + const error = new PasskeyError(original); + expect(error.json).toEqual({ detail: 'Internal error' }); + }); + }); + + describe('getMfaRequiredPayload()', () => { + it('should return structured MFA payload when type is PASSKEY_MFA_REQUIRED', () => { + const authError = new AuthError( + 'mfa_required', + 'MFA is required to complete this request', + { + code: 'mfa_required', + status: 403, + json: { + error: 'mfa_required', + error_description: 'MFA is required to complete this request', + mfa_token: 'mfa_tok_abc123', + mfa_requirements: { + enroll: [{ type: 'otp' }], + challenge: [{ type: 'sms' }, { type: 'email' }], + }, + }, + } + ); + const error = new PasskeyError(authError); + + const payload = error.getMfaRequiredPayload(); + + expect(payload).not.toBeNull(); + expect(payload).toEqual({ + mfaToken: 'mfa_tok_abc123', + error: 'mfa_required', + errorDescription: 'MFA is required to complete this request', + mfaRequirements: { + enroll: [{ type: 'otp' }], + challenge: [{ type: 'sms' }, { type: 'email' }], + }, + }); + }); + + it('should return null when type is not PASSKEY_MFA_REQUIRED', () => { + const authError = new AuthError( + 'passkey_challenge_error', + 'Challenge failed', + { + code: 'passkey_challenge_error', + status: 400, + } + ); + const error = new PasskeyError(authError); + + const payload = error.getMfaRequiredPayload(); + + expect(payload).toBeNull(); + }); + + it('should handle missing mfa_requirements gracefully', () => { + const authError = new AuthError('mfa_required', 'MFA required', { + code: 'mfa_required', + json: { + error: 'mfa_required', + error_description: 'MFA required', + mfa_token: 'mfa_tok_xyz', + // mfa_requirements intentionally omitted + }, + }); + const error = new PasskeyError(authError); + + const payload = error.getMfaRequiredPayload(); + + expect(payload).not.toBeNull(); + expect(payload).toEqual({ + mfaToken: 'mfa_tok_xyz', + error: 'mfa_required', + errorDescription: 'MFA required', + mfaRequirements: undefined, + }); + }); + + it('should fallback to empty string for missing mfa_token', () => { + const authError = new AuthError('mfa_required', 'MFA required', { + code: 'mfa_required', + json: { + error: 'mfa_required', + error_description: 'MFA required', + // mfa_token intentionally omitted + }, + }); + const error = new PasskeyError(authError); + + const payload = error.getMfaRequiredPayload(); + + expect(payload).not.toBeNull(); + expect(payload?.mfaToken).toBe(''); + }); + + it('should use error.code as fallback for missing json.error', () => { + const authError = new AuthError('mfa_required', 'MFA required', { + code: 'mfa_required', + json: { + // error field missing + mfa_token: 'mfa_tok_123', + }, + }); + const error = new PasskeyError(authError); + + const payload = error.getMfaRequiredPayload(); + + expect(payload?.error).toBe('mfa_required'); + }); + + it('should use error.message as fallback for missing error_description', () => { + const authError = new AuthError('mfa_required', 'Fallback message', { + code: 'mfa_required', + json: { + error: 'mfa_required', + mfa_token: 'mfa_tok_123', + // error_description missing + }, + }); + const error = new PasskeyError(authError); + + const payload = error.getMfaRequiredPayload(); + + expect(payload?.errorDescription).toBe('Fallback message'); + }); + }); +}); + +describe('PasskeyErrorCodes', () => { + it('should export all expected error code constants', () => { + expect(PasskeyErrorCodes.NOT_AVAILABLE).toBe('PASSKEY_NOT_AVAILABLE'); + expect(PasskeyErrorCodes.CHALLENGE_FAILED).toBe('PASSKEY_CHALLENGE_FAILED'); + expect(PasskeyErrorCodes.EXCHANGE_FAILED).toBe('PASSKEY_EXCHANGE_FAILED'); + expect(PasskeyErrorCodes.INVALID_CREDENTIAL).toBe( + 'PASSKEY_INVALID_CREDENTIAL' + ); + expect(PasskeyErrorCodes.UNSUPPORTED_PLATFORM).toBe( + 'PASSKEY_UNSUPPORTED_PLATFORM' + ); + expect(PasskeyErrorCodes.INVALID_PARAMETER).toBe( + 'PASSKEY_INVALID_PARAMETER' + ); + expect(PasskeyErrorCodes.MFA_REQUIRED).toBe('PASSKEY_MFA_REQUIRED'); + expect(PasskeyErrorCodes.UNKNOWN_ERROR).toBe('PASSKEY_UNKNOWN_ERROR'); + }); }); diff --git a/src/platforms/native/adapters/NativeAuth0Client.ts b/src/platforms/native/adapters/NativeAuth0Client.ts index 085893c4..a826de02 100644 --- a/src/platforms/native/adapters/NativeAuth0Client.ts +++ b/src/platforms/native/adapters/NativeAuth0Client.ts @@ -312,6 +312,15 @@ export class NativeAuth0Client implements IAuth0Client { ): Promise { const { authSession, authResponse, realm, audience, scope, organization } = parameters; + if (typeof authResponse !== 'string') { + throw new PasskeyError( + new AuthError( + 'InvalidParameter', + 'authResponse must be a JSON string on native platforms.', + { code: 'InvalidParameter' } + ) + ); + } try { return await this.guardedBridge.getTokenByPasskey( authSession, diff --git a/src/platforms/native/adapters/__tests__/NativeAuth0Client.spec.ts b/src/platforms/native/adapters/__tests__/NativeAuth0Client.spec.ts index 84ee2efd..7a495b8b 100644 --- a/src/platforms/native/adapters/__tests__/NativeAuth0Client.spec.ts +++ b/src/platforms/native/adapters/__tests__/NativeAuth0Client.spec.ts @@ -612,7 +612,7 @@ describe('NativeAuth0Client', () => { const { AuthError } = require('../../../../core/models'); const { PasskeyError } = require('../../../../core/models'); - (mockBridgeInstance as any).getTokenByPasskey = jest + mockBridgeInstance.getTokenByPasskey = jest .fn() .mockRejectedValue( new AuthError('PASSKEY_EXCHANGE_FAILED', 'Exchange failed', { @@ -630,6 +630,34 @@ describe('NativeAuth0Client', () => { }) ).rejects.toBeInstanceOf(PasskeyError); }); + + it('should reject with PasskeyError and not call the bridge when authResponse is not a string', async () => { + const { + PasskeyError, + PasskeyErrorCodes, + } = require('../../../../core/models'); + const client = new NativeAuth0Client(options); + await new Promise(process.nextTick); + + const rawCredential = { + id: 'cred-id', + type: 'public-key', + response: {}, + } as unknown as PublicKeyCredential; + + await expect( + client.getTokenByPasskey({ + authSession: 'auth-session-123', + authResponse: rawCredential, + }) + ).rejects.toMatchObject({ + constructor: PasskeyError, + type: PasskeyErrorCodes.INVALID_PARAMETER, + }); + expect( + (mockBridgeInstance as any).getTokenByPasskey + ).not.toHaveBeenCalled(); + }); }); describe('native config re-sync (multi-tenant)', () => { diff --git a/src/platforms/web/adapters/WebAuth0Client.ts b/src/platforms/web/adapters/WebAuth0Client.ts index 07a66a1b..3d170090 100644 --- a/src/platforms/web/adapters/WebAuth0Client.ts +++ b/src/platforms/web/adapters/WebAuth0Client.ts @@ -293,35 +293,166 @@ export class WebAuth0Client implements IAuth0Client { } async passkeySignupChallenge( - _parameters: PasskeySignupChallengeParameters + parameters: PasskeySignupChallengeParameters ): Promise { - throw new PasskeyError( - new AuthError( - 'UnsupportedOperation', - 'Passkeys are not supported on the web platform' - ) - ); + try { + const { + email, + phoneNumber, + username, + name, + givenName, + familyName, + nickname, + picture, + userMetadata, + realm, + organization, + } = parameters; + + // auth0-spa-js's PasskeySignupChallengeOptions type requires at + // least one of email/phoneNumber/username, but which identifiers + // are actually accepted depends on the database connection's + // configuration (see Prerequisites in EXAMPLES.md) — the API + // rejects an unsupported/missing combination server-side, so no + // client-side check is duplicated here (matching native, which + // also forwards these fields as-is). + const challenge = await this.client.passkey.getSignupChallenge({ + email, + phoneNumber, + username, + name, + givenName, + familyName, + nickname, + picture, + userMetadata, + realm, + organization, + } as Parameters[0]); + + return { + authSession: challenge.authSession, + authParamsPublicKey: challenge.publicKey as unknown as Record< + string, + any + >, + }; + } catch (e: any) { + if (e instanceof PasskeyError) throw e; + // spa-js's PasskeyRegisterError sets `.code`; the OAuth2-style + // GenericError family (thrown by the underlying token/discovery + // calls) only sets `.error` — check both so no error is silently + // downgraded to PASSKEY_UNKNOWN_ERROR. + const code = e.code ?? e.error ?? 'passkey_signup_challenge_failed'; + const authError = new AuthError( + code, + e.message ?? + e.error_description ?? + 'Failed to request passkey signup challenge', + { code, json: e.cause ?? e } + ); + throw new PasskeyError(authError); + } } async passkeyLoginChallenge( - _parameters: PasskeyLoginChallengeParameters + parameters: PasskeyLoginChallengeParameters ): Promise { - throw new PasskeyError( - new AuthError( - 'UnsupportedOperation', - 'Passkeys are not supported on the web platform' - ) - ); + try { + const { realm, organization } = parameters; + const challenge = await this.client.passkey.getLoginChallenge({ + realm, + organization, + }); + + return { + authSession: challenge.authSession, + authParamsPublicKey: challenge.publicKey as unknown as Record< + string, + any + >, + }; + } catch (e: any) { + if (e instanceof PasskeyError) throw e; + const code = e.code ?? e.error ?? 'passkey_login_challenge_failed'; + const authError = new AuthError( + code, + e.message ?? + e.error_description ?? + 'Failed to request passkey login challenge', + { code, json: e.cause ?? e } + ); + throw new PasskeyError(authError); + } } async getTokenByPasskey( - _parameters: GetTokenByPasskeyParameters + parameters: GetTokenByPasskeyParameters ): Promise { - throw new PasskeyError( - new AuthError( - 'UnsupportedOperation', - 'Passkeys are not supported on the web platform' - ) - ); + try { + const { + authSession, + authResponse, + realm, + audience, + scope, + organization, + } = parameters; + + // Apply default scope if not provided, for consistency with native + // platforms (which default to "openid profile email" when omitted). + const finalScope = scope ?? 'openid profile email'; + + // `authResponse` must be the raw PublicKeyCredential from + // navigator.credentials.create()/.get(). getTokenWithPasskey() + // detects attestation vs assertion and serializes it internally. + if (typeof authResponse === 'string') { + throw new PasskeyError( + new AuthError( + 'InvalidParameter', + 'authResponse must be a PublicKeyCredential object on web, not a JSON string.', + { code: 'InvalidParameter' } + ) + ); + } + + const response = await this.client.passkey.getTokenWithPasskey({ + authSession, + credential: authResponse, + realm, + audience, + scope: finalScope, + organization, + }); + + const expiresAt = Math.floor(Date.now() / 1000) + response.expires_in; + + return { + accessToken: response.access_token, + idToken: response.id_token, + tokenType: (response.token_type as TokenType) ?? this.tokenType, + expiresAt, + scope: response.scope, + refreshToken: response.refresh_token, + }; + } catch (e: any) { + if (e instanceof PasskeyError) throw e; + // The token-exchange step (getTokenWithPasskey) + // throws auth0-spa-js's GenericError family (invalid_grant, + // mfa_required, missing_refresh_token, use_dpop_nonce, ...) or a + // bare Error from ID token verification — none of these set `.code`, + // only `.error` (OAuth2-style), so check both before falling back to + // a generic code. See PasskeyErrorCodes for the resulting mapping. + const code = e.code ?? e.error ?? 'passkey_exchange_failed'; + const authError = new AuthError( + code, + e.message ?? + e.error_description ?? + 'Failed to exchange passkey credential for tokens', + { code, json: e.cause ?? e } + ); + throw new PasskeyError(authError); + } } } diff --git a/src/platforms/web/adapters/__tests__/WebAuth0Client.spec.ts b/src/platforms/web/adapters/__tests__/WebAuth0Client.spec.ts index 675e73b3..f5bb0993 100644 --- a/src/platforms/web/adapters/__tests__/WebAuth0Client.spec.ts +++ b/src/platforms/web/adapters/__tests__/WebAuth0Client.spec.ts @@ -50,9 +50,49 @@ jest.mock('../../../../core/models', () => { } } + class MockPasskeyError extends Error { + type: string; + code: string; + json: any; + constructor( + originalError: MockAuthError | Error, + fallbackType = 'PASSKEY_UNKNOWN_ERROR' + ) { + super(originalError.message); + this.name = 'PasskeyError'; + const isAuthError = originalError instanceof MockAuthError; + this.code = isAuthError ? originalError.code : originalError.name; + this.json = isAuthError ? originalError.json : originalError; + + // Simplified ERROR_CODE_MAP matching the real PasskeyError + const codeMap: Record = { + InvalidParameter: 'PASSKEY_INVALID_PARAMETER', + passkey_challenge_error: 'PASSKEY_CHALLENGE_FAILED', + passkey_get_token_error: 'PASSKEY_EXCHANGE_FAILED', + passkey_invalid_credential: 'PASSKEY_INVALID_CREDENTIAL', + mfa_required: 'PASSKEY_MFA_REQUIRED', + // Add others as needed by tests + }; + this.type = codeMap[this.code] ?? fallbackType; + } + + getMfaRequiredPayload() { + if (this.type !== 'PASSKEY_MFA_REQUIRED') { + return null; + } + return { + mfaToken: this.json.mfa_token ?? '', + error: this.json.error ?? this.code, + errorDescription: this.json.error_description ?? this.message, + mfaRequirements: this.json.mfa_requirements, + }; + } + } + return { AuthError: MockAuthError, AuthenticationException: MockAuthenticationException, + PasskeyError: MockPasskeyError, // Add other exports from models if needed Credentials: jest.fn(), Auth0User: jest.fn(), @@ -110,6 +150,11 @@ describe('WebAuth0Client', () => { getTokenSilently: jest.fn(), getIdTokenClaims: jest.fn(), isAuthenticated: jest.fn(), + passkey: { + getSignupChallenge: jest.fn(), + getLoginChallenge: jest.fn(), + getTokenWithPasskey: jest.fn(), + }, } as any; mockHttpClient = { @@ -574,6 +619,434 @@ describe('WebAuth0Client', () => { }); }); + describe('passkeySignupChallenge method', () => { + const mockPublicKey = { + challenge: 'challenge-value', + rp: { id: 'test.auth0.com', name: 'Test App' }, + user: { id: 'user-id', name: 'user@example.com', displayName: 'User' }, + }; + + it('should request a signup challenge and map the response', async () => { + mockSpaClient.passkey.getSignupChallenge.mockResolvedValue({ + authSession: 'auth-session-123', + publicKey: mockPublicKey, + }); + + const result = await client.passkeySignupChallenge({ + email: 'user@example.com', + name: 'John Doe', + realm: 'Username-Password-Authentication', + }); + + expect(mockSpaClient.passkey.getSignupChallenge).toHaveBeenCalledWith( + expect.objectContaining({ + email: 'user@example.com', + name: 'John Doe', + realm: 'Username-Password-Authentication', + }) + ); + expect(result).toEqual({ + authSession: 'auth-session-123', + authParamsPublicKey: mockPublicKey, + }); + }); + + it('should throw PasskeyError when the challenge request fails', async () => { + mockSpaClient.passkey.getSignupChallenge.mockRejectedValue({ + code: 'passkey_register_error', + message: 'Failed to request signup challenge', + }); + + await expect( + client.passkeySignupChallenge({ email: 'user@example.com' }) + ).rejects.toMatchObject({ + name: 'PasskeyError', + code: 'passkey_register_error', + }); + }); + + it('should forward the request to the SPA client even without a client-side identifier check, letting the connection reject it server-side', async () => { + // Matches native, which also forwards these fields as-is with no + // client-side validation — which identifiers are accepted depends + // on the database connection's configuration, not a fixed SDK rule. + mockSpaClient.passkey.getSignupChallenge.mockRejectedValue({ + code: 'passkey_register_error', + message: 'At least one of email, phone_number or username is required', + }); + + await expect( + client.passkeySignupChallenge({ + name: 'John Doe', + realm: 'Username-Password-Authentication', + }) + ).rejects.toMatchObject({ + name: 'PasskeyError', + code: 'passkey_register_error', + }); + expect(mockSpaClient.passkey.getSignupChallenge).toHaveBeenCalledWith( + expect.objectContaining({ name: 'John Doe' }) + ); + }); + + it('should preserve the original message for a network failure with no .code or .error', async () => { + mockSpaClient.passkey.getSignupChallenge.mockRejectedValue( + new TypeError('Failed to fetch') + ); + + await expect( + client.passkeySignupChallenge({ email: 'user@example.com' }) + ).rejects.toMatchObject({ + name: 'PasskeyError', + message: 'Failed to fetch', + }); + }); + + it('should accept phoneNumber as the sole identifier', async () => { + mockSpaClient.passkey.getSignupChallenge.mockResolvedValue({ + authSession: 'auth-session-123', + publicKey: mockPublicKey, + }); + + await client.passkeySignupChallenge({ phoneNumber: '+15551234567' }); + + expect(mockSpaClient.passkey.getSignupChallenge).toHaveBeenCalledWith( + expect.objectContaining({ phoneNumber: '+15551234567' }) + ); + }); + + it('should accept username as the sole identifier', async () => { + mockSpaClient.passkey.getSignupChallenge.mockResolvedValue({ + authSession: 'auth-session-123', + publicKey: mockPublicKey, + }); + + await client.passkeySignupChallenge({ username: 'johndoe' }); + + expect(mockSpaClient.passkey.getSignupChallenge).toHaveBeenCalledWith( + expect.objectContaining({ username: 'johndoe' }) + ); + }); + }); + + describe('passkeyLoginChallenge method', () => { + const mockPublicKey = { + challenge: 'challenge-value', + rpId: 'test.auth0.com', + }; + + it('should request a login challenge and map the response', async () => { + mockSpaClient.passkey.getLoginChallenge.mockResolvedValue({ + authSession: 'auth-session-456', + publicKey: mockPublicKey, + }); + + const result = await client.passkeyLoginChallenge({ + realm: 'Username-Password-Authentication', + }); + + expect(mockSpaClient.passkey.getLoginChallenge).toHaveBeenCalledWith({ + realm: 'Username-Password-Authentication', + organization: undefined, + }); + expect(result).toEqual({ + authSession: 'auth-session-456', + authParamsPublicKey: mockPublicKey, + }); + }); + + it('should throw PasskeyError when the challenge request fails', async () => { + mockSpaClient.passkey.getLoginChallenge.mockRejectedValue({ + code: 'passkey_challenge_error', + message: 'Failed to request login challenge', + }); + + await expect(client.passkeyLoginChallenge({})).rejects.toMatchObject({ + name: 'PasskeyError', + code: 'passkey_challenge_error', + }); + }); + + it('should extract the OAuth2-style .error field when the SPA client rejects without a .code', async () => { + mockSpaClient.passkey.getLoginChallenge.mockRejectedValue({ + error: 'passkey_not_supported', + error_description: 'WebAuthn is not supported in this browser.', + message: 'WebAuthn is not supported in this browser.', + }); + + await expect(client.passkeyLoginChallenge({})).rejects.toMatchObject({ + name: 'PasskeyError', + code: 'passkey_not_supported', + }); + }); + }); + + describe('getTokenByPasskey method', () => { + it('should exchange the credential for tokens', async () => { + const rawCredential = { + id: 'credential-id', + rawId: new ArrayBuffer(8), + type: 'public-key', + response: { + clientDataJSON: new ArrayBuffer(8), + attestationObject: new ArrayBuffer(8), + }, + } as unknown as PublicKeyCredential; + + mockSpaClient.passkey.getTokenWithPasskey.mockResolvedValue({ + access_token: 'passkey-access-token', + id_token: 'passkey-id-token', + token_type: 'Bearer', + expires_in: 3600, + scope: 'openid profile email', + refresh_token: 'passkey-refresh-token', + }); + + const result = await client.getTokenByPasskey({ + authSession: 'auth-session-123', + authResponse: rawCredential, + realm: 'Username-Password-Authentication', + }); + + expect(mockSpaClient.passkey.getTokenWithPasskey).toHaveBeenCalledWith({ + authSession: 'auth-session-123', + credential: rawCredential, + realm: 'Username-Password-Authentication', + audience: undefined, + scope: 'openid profile email', + organization: undefined, + }); + expect(result.accessToken).toBe('passkey-access-token'); + expect(result.idToken).toBe('passkey-id-token'); + expect(result.refreshToken).toBe('passkey-refresh-token'); + expect(result.expiresAt).toBeGreaterThan(Math.floor(Date.now() / 1000)); + }); + + it('should use the provided scope instead of the default when given', async () => { + const rawCredential = { + id: 'credential-id', + rawId: new ArrayBuffer(8), + type: 'public-key', + response: { + clientDataJSON: new ArrayBuffer(8), + attestationObject: new ArrayBuffer(8), + }, + } as unknown as PublicKeyCredential; + + mockSpaClient.passkey.getTokenWithPasskey.mockResolvedValue({ + access_token: 'passkey-access-token', + id_token: 'passkey-id-token', + token_type: 'Bearer', + expires_in: 3600, + scope: 'openid profile email offline_access', + refresh_token: 'passkey-refresh-token', + }); + + await client.getTokenByPasskey({ + authSession: 'auth-session-123', + authResponse: rawCredential, + realm: 'Username-Password-Authentication', + scope: 'openid profile email offline_access', + }); + + expect(mockSpaClient.passkey.getTokenWithPasskey).toHaveBeenCalledWith( + expect.objectContaining({ + scope: 'openid profile email offline_access', + }) + ); + }); + + it('should throw PasskeyError when the exchange fails', async () => { + const rawCredential = { + id: 'credential-id', + rawId: new ArrayBuffer(8), + type: 'public-key', + response: { + clientDataJSON: new ArrayBuffer(8), + attestationObject: new ArrayBuffer(8), + }, + } as unknown as PublicKeyCredential; + + mockSpaClient.passkey.getTokenWithPasskey.mockRejectedValue({ + code: 'passkey_get_token_error', + message: 'Failed to exchange credential', + }); + + await expect( + client.getTokenByPasskey({ + authSession: 'auth-session-123', + authResponse: rawCredential, + }) + ).rejects.toMatchObject({ + name: 'PasskeyError', + code: 'passkey_get_token_error', + }); + }); + + it('should extract the OAuth2-style .error field from a GenericError-shaped rejection (no .code)', async () => { + const rawCredential = { + id: 'credential-id', + rawId: new ArrayBuffer(8), + type: 'public-key', + response: { + clientDataJSON: new ArrayBuffer(8), + attestationObject: new ArrayBuffer(8), + }, + } as unknown as PublicKeyCredential; + + // auth0-spa-js's getTokenWithPasskey funnels through _requestToken, + // which throws GenericError/MfaRequiredError/MissingRefreshTokenError/ + // UseDpopNonceError for the webauthn grant — none of these set `.code`, + // only the OAuth2-style `.error`/`.error_description` fields. + mockSpaClient.passkey.getTokenWithPasskey.mockRejectedValue({ + error: 'invalid_grant', + error_description: 'Invalid authorization grant', + message: 'Invalid authorization grant', + }); + + await expect( + client.getTokenByPasskey({ + authSession: 'auth-session-123', + authResponse: rawCredential, + }) + ).rejects.toMatchObject({ + name: 'PasskeyError', + code: 'invalid_grant', + message: 'Invalid authorization grant', + }); + }); + + it('should propagate mfa_token/mfa_requirements when the exchange requires MFA', async () => { + const rawCredential = { + id: 'credential-id', + rawId: new ArrayBuffer(8), + type: 'public-key', + response: { + clientDataJSON: new ArrayBuffer(8), + attestationObject: new ArrayBuffer(8), + }, + } as unknown as PublicKeyCredential; + + mockSpaClient.passkey.getTokenWithPasskey.mockRejectedValue({ + error: 'mfa_required', + error_description: 'MFA is required', + mfa_token: 'mfa_tok_123', + mfa_requirements: { + challenge: [{ type: 'sms' }, { type: 'otp' }], + enroll: [{ type: 'email' }] + }, + }); + + await expect( + client.getTokenByPasskey({ + authSession: 'auth-session-123', + authResponse: rawCredential, + }) + ).rejects.toMatchObject({ + name: 'PasskeyError', + code: 'mfa_required', + type: 'PASSKEY_MFA_REQUIRED', + json: expect.objectContaining({ + mfa_token: 'mfa_tok_123', + mfa_requirements: { + challenge: [{ type: 'sms' }, { type: 'otp' }], + enroll: [{ type: 'email' }] + } + }), + }); + + // Also verify getMfaRequiredPayload() returns structured data + try { + await client.getTokenByPasskey({ + authSession: 'auth-session-123', + authResponse: rawCredential, + }); + } catch (error: any) { + const payload = error.getMfaRequiredPayload(); + expect(payload).toEqual({ + mfaToken: 'mfa_tok_123', + error: 'mfa_required', + errorDescription: 'MFA is required', + mfaRequirements: { + challenge: [{ type: 'sms' }, { type: 'otp' }], + enroll: [{ type: 'email' }] + }, + }); + } + }); + + it('should preserve the original message when the rejection has neither .code nor .error', async () => { + const rawCredential = { + id: 'credential-id', + rawId: new ArrayBuffer(8), + type: 'public-key', + response: { + clientDataJSON: new ArrayBuffer(8), + attestationObject: new ArrayBuffer(8), + }, + } as unknown as PublicKeyCredential; + + // e.g. the bare Error thrown by auth0-spa-js's ID token verification. + mockSpaClient.passkey.getTokenWithPasskey.mockRejectedValue( + new Error( + 'Signature algorithm of "none" is not supported. Expected the ID token to be signed with "RS256".' + ) + ); + + await expect( + client.getTokenByPasskey({ + authSession: 'auth-session-123', + authResponse: rawCredential, + }) + ).rejects.toMatchObject({ + name: 'PasskeyError', + message: + 'Signature algorithm of "none" is not supported. Expected the ID token to be signed with "RS256".', + }); + }); + + it('should throw PasskeyError when getTokenWithPasskey fails for a raw credential', async () => { + const rawCredential = { + id: 'credential-id', + rawId: new ArrayBuffer(8), + type: 'public-key', + response: { clientDataJSON: new ArrayBuffer(8) }, + } as unknown as PublicKeyCredential; + + mockSpaClient.passkey.getTokenWithPasskey.mockRejectedValue({ + code: 'passkey_invalid_credential', + message: + 'The provided credential is not a valid attestation or assertion response.', + }); + + await expect( + client.getTokenByPasskey({ + authSession: 'auth-session-123', + authResponse: rawCredential, + }) + ).rejects.toMatchObject({ + name: 'PasskeyError', + code: 'passkey_invalid_credential', + }); + }); + + it('should throw PasskeyError with INVALID_PARAMETER when authResponse is a string', async () => { + await expect( + client.getTokenByPasskey({ + authSession: 'auth-session-123', + authResponse: '{"id":"credential-id","type":"public-key"}', + }) + ).rejects.toMatchObject({ + name: 'PasskeyError', + code: 'InvalidParameter', + type: 'PASSKEY_INVALID_PARAMETER', + message: + 'authResponse must be a PublicKeyCredential object on web, not a JSON string.', + }); + + expect(mockSpaClient.passkey.getTokenWithPasskey).not.toHaveBeenCalled(); + }); + }); + describe('ssoExchange', () => { it('should reject with UnsupportedOperation error on web', async () => { await expect( diff --git a/src/types/parameters.ts b/src/types/parameters.ts index 1faec2b9..c99e1d67 100644 --- a/src/types/parameters.ts +++ b/src/types/parameters.ts @@ -581,7 +581,7 @@ export interface PasskeyChallengeResponse { * Parameters for exchanging a passkey credential response for Auth0 tokens. * * After the platform credential manager returns the credential (either a new - * registration or an assertion), pass the auth_session and the credential JSON + * registration or an assertion), pass the auth_session and the credential * response to this method to obtain Auth0 tokens. * * @see https://auth0.com/docs/authenticate/database-connections/passkeys @@ -589,8 +589,16 @@ export interface PasskeyChallengeResponse { export interface GetTokenByPasskeyParameters { /** The auth session received from the challenge response. */ authSession: string; - /** The JSON string of the PublicKeyCredential response from the platform credential manager. */ - authResponse: string; + /** + * The credential response from the platform credential manager. + * + * On native (iOS/Android), this is the JSON string of the + * `PublicKeyCredential` response returned by the native credential + * manager. On web, pass the raw `PublicKeyCredential` object returned + * directly by `navigator.credentials.create()`/`.get()` — it does not + * need to be serialized first. + */ + authResponse: string | PublicKeyCredential; /** The database connection name. */ realm?: string; /** The target API identifier for the issued access token. */ diff --git a/yarn.lock b/yarn.lock index fc9cd0d8..34f17841 100644 --- a/yarn.lock +++ b/yarn.lock @@ -28,25 +28,25 @@ __metadata: languageName: node linkType: hard -"@auth0/auth0-auth-js@npm:1.10.0": - version: 1.10.0 - resolution: "@auth0/auth0-auth-js@npm:1.10.0" +"@auth0/auth0-auth-js@npm:^1.10.0": + version: 1.12.0 + resolution: "@auth0/auth0-auth-js@npm:1.12.0" dependencies: jose: "npm:^6.0.8" openid-client: "npm:^6.8.0" - checksum: 10c0/9c926d0c379cc2ba97aac1cbd2d7e983805d7bdc35cd5669d75476eb02264766c34ce05c426333870ec2b7cc9108b3805b643774d8fbbaf3b2f7e97ffa4ebd67 + checksum: 10c0/52cafd1a1b0bef8d44078ac36cbcf41c5e858ba32614e93ae2dd2020efd0c63dbe9a3d92af59579c1eacd60d918da622c33fe7dde92a723bb1831963a2ba6c11 languageName: node linkType: hard -"@auth0/auth0-spa-js@npm:^2.22.0": - version: 2.22.0 - resolution: "@auth0/auth0-spa-js@npm:2.22.0" +"@auth0/auth0-spa-js@npm:^2.24.0": + version: 2.24.0 + resolution: "@auth0/auth0-spa-js@npm:2.24.0" dependencies: - "@auth0/auth0-auth-js": "npm:1.10.0" - browser-tabs-lock: "npm:1.3.0" - dpop: "npm:2.1.1" - es-cookie: "npm:1.3.2" - checksum: 10c0/7aab9c19e91202c19cb7fea5f03c2f3e3e16b42508ca01a09f31e5521d066adcece0ee9579262652b7cbd2581b1688b5177087a1055df77be4478602917bd8dd + "@auth0/auth0-auth-js": "npm:^1.10.0" + browser-tabs-lock: "npm:^1.3.0" + dpop: "npm:^2.1.1" + es-cookie: "npm:~1.3.2" + checksum: 10c0/7bc190bf9c3c5e4c60c65ddec0c29b0326e44138b452c83365df1739ec8a6410f859fe1eb54b2ed70cea523374ab65ad0f69423e968774e7aa35faff9ed9a804 languageName: node linkType: hard @@ -7264,7 +7264,7 @@ __metadata: languageName: node linkType: hard -"browser-tabs-lock@npm:1.3.0": +"browser-tabs-lock@npm:^1.3.0": version: 1.3.0 resolution: "browser-tabs-lock@npm:1.3.0" dependencies: @@ -8768,7 +8768,7 @@ __metadata: languageName: node linkType: hard -"dpop@npm:2.1.1": +"dpop@npm:^2.1.1": version: 2.1.1 resolution: "dpop@npm:2.1.1" checksum: 10c0/e46dfd62325dd63372a17492c1867f79cdaf235645d32b87c3be8a09d4c7b03b8b44efec26688ba19e8279c77497a08deb302a9a4704b432795efd1163519611 @@ -9009,7 +9009,7 @@ __metadata: languageName: node linkType: hard -"es-cookie@npm:1.3.2": +"es-cookie@npm:~1.3.2": version: 1.3.2 resolution: "es-cookie@npm:1.3.2" checksum: 10c0/26eb6e06b25b5569d8763fcb23b5335a5098e354b0a9a7bc5122e8c8705003307187a165ddaeda5cff08fa4cc8e1675dbddd5709279fb27cfa8875514dc3eccb @@ -14968,7 +14968,7 @@ __metadata: version: 0.0.0-use.local resolution: "react-native-auth0@workspace:." dependencies: - "@auth0/auth0-spa-js": "npm:^2.22.0" + "@auth0/auth0-spa-js": "npm:^2.24.0" "@commitlint/config-conventional": "npm:^21.1.0" "@eslint/compat": "npm:^2.1.0" "@eslint/eslintrc": "npm:^3.3.5" From b1da3e253441ee80ef01d87cbead1ce696eb5e69 Mon Sep 17 00:00:00 2001 From: Subhankar Maiti <35273200+subhankarmaiti@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:18:42 +0530 Subject: [PATCH 2/3] feat: enforce IPSIE session_expiry with a SESSION_EXPIRED error (#1597) --- A0Auth0.podspec | 2 +- EXAMPLES-WEB.md | 5 +- EXAMPLES.md | 91 ++++++++++++++++--- README.md | 14 ++- .../java/com/auth0/react/A0Auth0Module.kt | 3 +- .../java/com/auth0/react/CredentialsParser.kt | 4 + example/ios/Podfile.lock | 10 +- ios/NativeBridge.swift | 8 +- src/core/models/Credentials.ts | 6 ++ src/core/models/CredentialsManagerError.ts | 11 +++ src/core/models/__tests__/Credentials.spec.ts | 3 + src/core/models/__tests__/ErrorCodes.spec.ts | 7 +- .../__tests__/NativeAuth0Client.spec.ts | 12 +-- .../NativeCredentialsManager.errors.spec.ts | 32 +++++++ .../NativeCredentialsManager.spec.ts | 15 +++ .../web/adapters/WebCredentialsManager.ts | 70 +++++++++++--- .../adapters/__tests__/WebAuth0Client.spec.ts | 8 +- .../WebCredentialsManager.errors.spec.ts | 24 +++++ .../__tests__/WebCredentialsManager.spec.ts | 59 ++++++++++++ src/types/common.ts | 18 ++++ 20 files changed, 350 insertions(+), 52 deletions(-) diff --git a/A0Auth0.podspec b/A0Auth0.podspec index 5c110043..88d86e12 100644 --- a/A0Auth0.podspec +++ b/A0Auth0.podspec @@ -16,7 +16,7 @@ Pod::Spec.new do |s| s.source_files = 'ios/**/*.{h,m,mm,swift}' s.requires_arc = true - s.dependency 'Auth0', '2.23.0' + s.dependency 'Auth0', '2.24.1' s.dependency 'SimpleKeychain', '1.3.0' install_modules_dependencies(s) diff --git a/EXAMPLES-WEB.md b/EXAMPLES-WEB.md index 6539072c..92308a3b 100644 --- a/EXAMPLES-WEB.md +++ b/EXAMPLES-WEB.md @@ -299,7 +299,8 @@ function PasskeyLoginButton() { // App calls navigator.credentials directly (not wrapped by SDK) const credential = (await navigator.credentials.get({ - publicKey: challenge.authParamsPublicKey as PublicKeyCredentialRequestOptions, + publicKey: + challenge.authParamsPublicKey as PublicKeyCredentialRequestOptions, })) as PublicKeyCredential | null; if (!credential) { @@ -317,7 +318,7 @@ function PasskeyLoginButton() { } catch (error) { if (error instanceof PasskeyError) { console.error('Passkey login failed:', error.type, error.message); - + // Handle MFA required scenario const mfaPayload = error.getMfaRequiredPayload(); if (mfaPayload) { diff --git a/EXAMPLES.md b/EXAMPLES.md index db44b0b8..243facb0 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -21,6 +21,7 @@ - [Using Retry with Auth0 Class](#using-retry-with-auth0-class) - [Platform Support](#platform-support) - [Error Handling](#error-handling) +- [IPSIE Session Expiry](#ipsie-session-expiry) - [Biometric Authentication](#biometric-authentication) - [Biometric Policy Types](#biometric-policy-types) - [Using with Auth0Provider (Hooks)](#using-with-auth0provider-hooks) @@ -640,6 +641,66 @@ function MyComponent() { 2. **Configure adequate overlap period**: Ensure your Auth0 tenant has at least 180 seconds token overlap configured 3. **Test on real devices**: Simulate network instability during testing to validate retry behavior +## IPSIE Session Expiry + +> **Platform Support:** iOS, Android, and Web. + +Auth0 supports the [IPSIE SL1](https://openid.github.io/ipsie-openid-sl1/draft-openid-ipsie-sl1-profile.html) `session_expiry` claim, which lets an upstream identity provider (e.g. Okta) set a hard ceiling on how long an Auth0-issued session may live. When an `okta` or `oidc` enterprise connection has the **"Use ID Token for Session Expiry"** toggle enabled (in the Dashboard, or `id_token_session_expiry_supported: true` via the Management API), and the app uses the Authorization Code flow, Auth0 includes a `session_expiry` Unix timestamp in the ID token returned to your app after login. + +This ceiling is layered **on top of** your tenant's existing idle and absolute session timeouts — it does not replace them. The session ends at whichever limit is reached first. + +> [!WARNING] +> `session_expiry` is interpreted as **seconds** since the Unix epoch (per RFC 7519 `NumericDate`). If the Post-Login Action that sets it emits **milliseconds** (e.g. `Date.now()` without `/ 1000`), the value reads as tens of thousands of years out; the platform SDKs reject implausibly large values (≥ `10_000_000_000`) as malformed and treat them as **no ceiling**, silently disabling enforcement. Always emit seconds. + +The underlying platform SDKs enforce this ceiling on every credential retrieval. Once the ceiling has passed, `getCredentials()` clears the stored credentials and rejects instead of attempting a token renewal — the user must re-authenticate. **No opt-in code is required**; enforcement is transparent once the connection option is active on your tenant. + +`react-native-auth0` surfaces this as a single, cross-platform error type: `CredentialsManagerError` with `type === 'SESSION_EXPIRED'`. Your existing "no credentials" re-login path already handles it, or you can match it explicitly: + +```jsx +import { useAuth0, CredentialsManagerError } from 'react-native-auth0'; + +function MyComponent() { + const { getCredentials, authorize } = useAuth0(); + + const fetchCredentials = async () => { + try { + const credentials = await getCredentials(); + return credentials; + } catch (error) { + if ( + error instanceof CredentialsManagerError && + error.type === 'SESSION_EXPIRED' + ) { + // Upstream IdP session has ended — send the user back to login. + await authorize({ scope: 'openid profile offline_access' }); + } else { + throw error; + } + } + }; + + // ... +} +``` + +If you need to read the ceiling directly — for example to warn the user before their session ends — it is exposed as `sessionExpiresAt` (an absolute Unix timestamp, in seconds) on the returned `Credentials`. It is `undefined` when the connection does not emit the claim: + +```jsx +const credentials = await getCredentials(); +if (credentials.sessionExpiresAt) { + const endsAt = new Date(credentials.sessionExpiresAt * 1000); + console.log(`Upstream IdP session ends at: ${endsAt.toISOString()}`); +} +``` + +> [!NOTE] +> Enforcement applies a small negative leeway (about 30 seconds) to account for clock skew, so the session is treated as expired slightly before this exact timestamp. Build any countdown UI with that margin in mind. + +This value is decoded from the current ID token's `session_expiry` claim, except on Android where the credentials manager reports the ceiling pinned at the initial login (the value it actually enforces) when one is stored. It is also readable directly from the raw `session_expiry` claim on the decoded ID token — see [Parse user profile from an ID token locally](#parse-user-profile-from-an-id-token-locally). + +> [!NOTE] +> On Android, the `session_expiry` ceiling is pinned at the initial login and is not raised by subsequent refresh-token grants. On iOS and Web, `sessionExpiresAt` is derived from the current ID token. Sessions from connections **without** the claim behave exactly as before. + ## Biometric Authentication > **Platform Support:** Native only (iOS/Android) @@ -1377,7 +1438,8 @@ function PasskeySignupScreenWeb() { let credential: PublicKeyCredential; try { credential = (await navigator.credentials.create({ - publicKey: challenge.authParamsPublicKey as PublicKeyCredentialCreationOptions, + publicKey: + challenge.authParamsPublicKey as PublicKeyCredentialCreationOptions, })) as PublicKeyCredential; } catch (e) { throw new PasskeyError(e as Error); @@ -1423,7 +1485,8 @@ function PasskeySigninScreenWeb() { let credential: PublicKeyCredential; try { credential = (await navigator.credentials.get({ - publicKey: challenge.authParamsPublicKey as PublicKeyCredentialRequestOptions, + publicKey: + challenge.authParamsPublicKey as PublicKeyCredentialRequestOptions, })) as PublicKeyCredential; } catch (e) { throw new PasskeyError(e as Error); @@ -1556,16 +1619,16 @@ The `passkeySignupChallenge` method accepts the following parameters to create a Passkey operations throw `PasskeyError` (extends `AuthError`) with a normalized `type` property. Use `PasskeyErrorCodes` for type-safe error handling: -| Error Code | Description | -| ------------------------------ | ----------------------------------------------------------------------------------------------------------------- | -| `PASSKEY_CHALLENGE_FAILED` | Auth0 challenge request failed | -| `PASSKEY_EXCHANGE_FAILED` | Token exchange with credential response failed | -| `PASSKEY_NOT_AVAILABLE` | Passkeys not available on this device/OS version, or WebAuthn is not supported in this browser | -| `PASSKEY_UNSUPPORTED_PLATFORM` | Passkeys not supported on this platform | -| `PASSKEY_INVALID_PARAMETER` | **Native only.** `authResponse` passed to `getTokenByPasskey` was not a JSON string | -| `PASSKEY_INVALID_CREDENTIAL` | **Web only.** The credential passed to `getTokenByPasskey` is neither a valid attestation (signup) nor assertion (login) response | +| Error Code | Description | +| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `PASSKEY_CHALLENGE_FAILED` | Auth0 challenge request failed | +| `PASSKEY_EXCHANGE_FAILED` | Token exchange with credential response failed | +| `PASSKEY_NOT_AVAILABLE` | Passkeys not available on this device/OS version, or WebAuthn is not supported in this browser | +| `PASSKEY_UNSUPPORTED_PLATFORM` | Passkeys not supported on this platform | +| `PASSKEY_INVALID_PARAMETER` | **Native only.** `authResponse` passed to `getTokenByPasskey` was not a JSON string | +| `PASSKEY_INVALID_CREDENTIAL` | **Web only.** The credential passed to `getTokenByPasskey` is neither a valid attestation (signup) nor assertion (login) response | | `PASSKEY_MFA_REQUIRED` | **Web only.** MFA is required to complete the exchange — use `error.getMfaRequiredPayload()` to extract `mfaToken` and `mfaRequirements`, then continue with `mfa.challenge()`/`mfa.verify()` | -| `PASSKEY_UNKNOWN_ERROR` | Unknown or uncategorized passkey error — check `error.message` for the underlying description | +| `PASSKEY_UNKNOWN_ERROR` | Unknown or uncategorized passkey error — check `error.message` for the underlying description | ```typescript import { PasskeyError, PasskeyErrorCodes } from 'react-native-auth0'; @@ -1580,7 +1643,7 @@ try { console.log('Error type:', error.type); // e.g. "PASSKEY_CHALLENGE_FAILED" console.log('Error message:', error.message); console.log('Error code:', error.code); // Raw error code - + // Handle MFA required if (error.type === PasskeyErrorCodes.MFA_REQUIRED) { const mfaPayload = error.getMfaRequiredPayload(); @@ -1598,8 +1661,8 @@ try { ### Platform Support -| Platform | Support | Requirements | -| ----------- | ------------ | ------------------------------------------------------------- | +| Platform | Support | Requirements | +| ----------- | ------------ | -------------------------------------------------------------- | | **iOS** | ✅ Supported | iOS 16.6+, Associated Domains with `webcredentials` | | **Android** | ✅ Supported | Android API 28+, Digital Asset Links configured | | **Web** | ✅ Supported | Modern browser with WebAuthn support; call from a user gesture | diff --git a/README.md b/README.md index 1f36d83c..4bdb58ac 100644 --- a/README.md +++ b/README.md @@ -730,6 +730,11 @@ try { 'DPoP credential state error. Clear credentials and re-authenticate.' ); break; + case CredentialsManagerErrorCodes.SESSION_EXPIRED: + console.log( + 'Upstream IdP session ceiling reached. Clear credentials and restart the login flow.' + ); + break; default: console.error('Credentials error:', error.message); } @@ -754,6 +759,9 @@ try { | `DPOP_KEY_MISSING` | `DPOP_KEY_MISSING` | `dpopKeyMissing` | | | `DPOP_NOT_CONFIGURED` | `DPOP_NOT_CONFIGURED` | `dpopNotConfigured` | | | `DPOP_KEY_MISMATCH` | `DPOP_KEY_MISMATCH` | `dpopKeyMismatch` | | +| `SESSION_EXPIRED` | `SESSION_EXPIRED` | `sessionExpired` | `session_expired` | + +> **`SESSION_EXPIRED`** is raised when the upstream IdP session ceiling (IPSIE `session_expiry` claim) has been reached. The local session is no longer valid and the user must re-authenticate. See [IPSIE Session Expiry](https://github.com/auth0/react-native-auth0/blob/master/EXAMPLES.md#ipsie-session-expiry) for details. ### MFA errors @@ -916,9 +924,9 @@ This library provides a unified API across Native (iOS/Android) and Web platform | `auth.passwordless...()` | ✅ | ❌ | **Not supported on Web.** Passwordless flows on the web should be configured via Universal Login and initiated with `webAuth.authorize()`. | | `auth.loginWith...()` (OTP/SMS etc) | ✅ | ❌ | **Not supported on Web.** These direct grant flows are not secure for public clients like browsers. | | **Passkeys** | | | --- | -| `passkeySignupChallenge()` | ✅ | ✅ | Gets a WebAuthn registration challenge from Auth0. Requires iOS 16.6+, Android API 28+, or a browser with WebAuthn support. | -| `passkeyLoginChallenge()` | ✅ | ✅ | Gets a WebAuthn assertion challenge from Auth0. Requires iOS 16.6+, Android API 28+, or a browser with WebAuthn support. | -| `getTokenByPasskey()` | ✅ | ✅ | Exchanges a passkey credential response for Auth0 tokens. On Web, uses `@auth0/auth0-spa-js`. | +| `passkeySignupChallenge()` | ✅ | ✅ | Gets a WebAuthn registration challenge from Auth0. Requires iOS 16.6+, Android API 28+, or a browser with WebAuthn support. | +| `passkeyLoginChallenge()` | ✅ | ✅ | Gets a WebAuthn assertion challenge from Auth0. Requires iOS 16.6+, Android API 28+, or a browser with WebAuthn support. | +| `getTokenByPasskey()` | ✅ | ✅ | Exchanges a passkey credential response for Auth0 tokens. On Web, uses `@auth0/auth0-spa-js`. | | **Token & User Management** | | | --- | | `auth.refreshToken()` | ✅ | ❌ | **Not supported on Web.** Token refresh is handled automatically by `getCredentials()` via `getTokenSilently()` on the web. | | `auth.userInfo()` | ✅ | ✅ | Fetches the user's profile from the `/userinfo` endpoint using an access token. | diff --git a/android/src/main/java/com/auth0/react/A0Auth0Module.kt b/android/src/main/java/com/auth0/react/A0Auth0Module.kt index e034a269..ff5080ad 100644 --- a/android/src/main/java/com/auth0/react/A0Auth0Module.kt +++ b/android/src/main/java/com/auth0/react/A0Auth0Module.kt @@ -105,7 +105,8 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0 CredentialsManagerException.NO_NETWORK to "NO_NETWORK", CredentialsManagerException.DPOP_KEY_MISSING to "DPOP_KEY_MISSING", CredentialsManagerException.DPOP_NOT_CONFIGURED to "DPOP_NOT_CONFIGURED", - CredentialsManagerException.DPOP_KEY_MISMATCH to "DPOP_KEY_MISMATCH" + CredentialsManagerException.DPOP_KEY_MISMATCH to "DPOP_KEY_MISMATCH", + CredentialsManagerException.SESSION_EXPIRED to "SESSION_EXPIRED" ) // DPoP enabled by default private var useDPoP: Boolean = true diff --git a/android/src/main/java/com/auth0/react/CredentialsParser.kt b/android/src/main/java/com/auth0/react/CredentialsParser.kt index ab2af622..1fa043a8 100644 --- a/android/src/main/java/com/auth0/react/CredentialsParser.kt +++ b/android/src/main/java/com/auth0/react/CredentialsParser.kt @@ -10,6 +10,7 @@ object CredentialsParser { private const val ACCESS_TOKEN_KEY = "accessToken" private const val ID_TOKEN_KEY = "idToken" private const val EXPIRES_AT_KEY = "expiresAt" + private const val SESSION_EXPIRES_AT_KEY = "sessionExpiresAt" private const val SCOPE = "scope" private const val REFRESH_TOKEN_KEY = "refreshToken" private const val TOKEN_TYPE_KEY = "tokenType" @@ -23,6 +24,9 @@ object CredentialsParser { map.putString(SCOPE, credentials.scope) map.putString(REFRESH_TOKEN_KEY, credentials.refreshToken) map.putString(TOKEN_TYPE_KEY, credentials.type) + credentials.sessionExpiresAt?.let { + map.putDouble(SESSION_EXPIRES_AT_KEY, it.toDouble()) + } return map } diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 1c282e27..05340273 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1,6 +1,6 @@ PODS: - - A0Auth0 (5.9.0): - - Auth0 (= 2.23.0) + - A0Auth0 (5.10.0): + - Auth0 (= 2.24.1) - hermes-engine - RCTRequired - RCTTypeSafety @@ -23,7 +23,7 @@ PODS: - ReactNativeDependencies - SimpleKeychain (= 1.3.0) - Yoga - - Auth0 (2.23.0): + - Auth0 (2.24.1): - JWTDecode (= 3.3.0) - SimpleKeychain (= 1.3.0) - FBLazyVector (0.86.0) @@ -2242,8 +2242,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: - A0Auth0: 260f4197bc27923b13525663cc625cbb01ac4e4a - Auth0: dd46c309079f995e91dfd7af38e8fefd1a43bf4e + A0Auth0: 88b0133241904699bfb3234731983d30dbf9cba0 + Auth0: 700271885e7d3bab08f372f7e119adb7b2ba662b FBLazyVector: b3e7ad108f0d882e30445c5527d774e3fd432f3d hermes-engine: 4ba8c4848d46c6a441e09d1c62f883ba69bc5b76 JWTDecode: 1ca6f765844457d0dd8690436860fecee788f631 diff --git a/ios/NativeBridge.swift b/ios/NativeBridge.swift index 0cdf67c4..91210c93 100644 --- a/ios/NativeBridge.swift +++ b/ios/NativeBridge.swift @@ -18,6 +18,7 @@ public class NativeBridge: NSObject { static let accessTokenKey = "accessToken"; static let idTokenKey = "idToken"; static let expiresAtKey = "expiresAt"; + static let sessionExpiresAtKey = "sessionExpiresAt"; static let scopeKey = "scope"; static let refreshTokenKey = "refreshToken"; static let typeKey = "type"; @@ -670,7 +671,7 @@ public class NativeBridge: NSObject { extension Credentials { func asDictionary() -> [String: Any] { - return [ + var dict: [String: Any] = [ NativeBridge.accessTokenKey: self.accessToken, NativeBridge.tokenTypeKey: self.tokenType, NativeBridge.idTokenKey: self.idToken, @@ -678,6 +679,10 @@ extension Credentials { NativeBridge.expiresAtKey: floor(self.expiresIn.timeIntervalSince1970), NativeBridge.scopeKey: self.scope as Any ] + if let sessionExpiresAt = self.sessionExpiresAt { + dict[NativeBridge.sessionExpiresAtKey] = floor(sessionExpiresAt.timeIntervalSince1970) + } + return dict } } @@ -760,6 +765,7 @@ extension CredentialsManagerError { case CredentialsManagerError.dpopKeyMissing: code = "DPOP_KEY_MISSING" case CredentialsManagerError.dpopKeyMismatch: code = "DPOP_KEY_MISMATCH" case CredentialsManagerError.dpopNotConfigured: code = "DPOP_NOT_CONFIGURED" + case CredentialsManagerError.sessionExpired: code = "SESSION_EXPIRED" default: code = "UNKNOWN" } return code diff --git a/src/core/models/Credentials.ts b/src/core/models/Credentials.ts index fb188155..4115f8ae 100644 --- a/src/core/models/Credentials.ts +++ b/src/core/models/Credentials.ts @@ -14,6 +14,11 @@ export class Credentials implements ICredentials { public expiresAt: number; public refreshToken?: string; public scope?: string; + /** + * The upstream IdP session ceiling as a UNIX timestamp (in seconds), asserted via the + * IPSIE `session_expiry` claim. `undefined` when the ID token lacks the claim. + */ + public sessionExpiresAt?: number; /** * Creates an instance of Credentials. @@ -27,6 +32,7 @@ export class Credentials implements ICredentials { this.expiresAt = params.expiresAt; this.refreshToken = params.refreshToken; this.scope = params.scope; + this.sessionExpiresAt = params.sessionExpiresAt; } /** diff --git a/src/core/models/CredentialsManagerError.ts b/src/core/models/CredentialsManagerError.ts index d2a1f272..4b8b2316 100644 --- a/src/core/models/CredentialsManagerError.ts +++ b/src/core/models/CredentialsManagerError.ts @@ -47,6 +47,11 @@ export const CredentialsManagerErrorCodes = { REVOKE_FAILED: 'REVOKE_FAILED', /** Requested minimum TTL exceeds token lifetime */ LARGE_MIN_TTL: 'LARGE_MIN_TTL', + /** + * The upstream IdP session ceiling (IPSIE `session_expiry`) has been reached. + * The local session is no longer valid and the user must re-authenticate. + */ + SESSION_EXPIRED: 'SESSION_EXPIRED', /** Generic credentials manager error */ CREDENTIAL_MANAGER_ERROR: 'CREDENTIAL_MANAGER_ERROR', /** Biometric authentication failed */ @@ -80,6 +85,8 @@ const ERROR_CODE_MAP: Record = { STORE_FAILED: CredentialsManagerErrorCodes.STORE_FAILED, REVOKE_FAILED: CredentialsManagerErrorCodes.REVOKE_FAILED, LARGE_MIN_TTL: CredentialsManagerErrorCodes.LARGE_MIN_TTL, + // IPSIE session_expiry ceiling reached (Android SESSION_EXPIRED, iOS sessionExpired) + SESSION_EXPIRED: CredentialsManagerErrorCodes.SESSION_EXPIRED, CREDENTIAL_MANAGER_ERROR: CredentialsManagerErrorCodes.CREDENTIAL_MANAGER_ERROR, BIOMETRICS_FAILED: CredentialsManagerErrorCodes.BIOMETRICS_FAILED, @@ -104,6 +111,7 @@ const ERROR_CODE_MAP: Record = { invalid_scope: CredentialsManagerErrorCodes.API_ERROR, server_error: CredentialsManagerErrorCodes.API_ERROR, temporarily_unavailable: CredentialsManagerErrorCodes.NO_NETWORK, + session_expired: CredentialsManagerErrorCodes.SESSION_EXPIRED, // --- iOS-specific mappings --- renewFailed: CredentialsManagerErrorCodes.RENEW_FAILED, @@ -112,6 +120,7 @@ const ERROR_CODE_MAP: Record = { noRefreshToken: CredentialsManagerErrorCodes.NO_REFRESH_TOKEN, storeFailed: CredentialsManagerErrorCodes.STORE_FAILED, largeMinTTL: CredentialsManagerErrorCodes.LARGE_MIN_TTL, + sessionExpired: CredentialsManagerErrorCodes.SESSION_EXPIRED, // --- Many-to-one mapping for granular Android Biometric errors --- INCOMPATIBLE_DEVICE: CredentialsManagerErrorCodes.INCOMPATIBLE_DEVICE, @@ -178,6 +187,7 @@ const ERROR_CODE_MAP: Record = { * - `STORE_FAILED`: Failed to store credentials * - `REVOKE_FAILED`: Failed to revoke refresh token * - `LARGE_MIN_TTL`: Requested minimum TTL exceeds token lifetime + * - `SESSION_EXPIRED`: Upstream IdP session ceiling (IPSIE `session_expiry`) reached - re-authentication required * * ### API Credentials (MRRT): * - `API_EXCHANGE_FAILED`: Failed to exchange refresh token for API-specific credentials @@ -307,6 +317,7 @@ export class CredentialsManagerError extends AuthError { * - `STORE_FAILED`: Failed to store credentials * - `REVOKE_FAILED`: Failed to revoke refresh token * - `LARGE_MIN_TTL`: Requested minimum TTL exceeds token lifetime + * - `SESSION_EXPIRED`: Upstream IdP session ceiling (IPSIE `session_expiry`) reached * - `BIOMETRICS_FAILED`: Biometric authentication failed * - `INCOMPATIBLE_DEVICE`: Device incompatible with secure storage * - `CRYPTO_EXCEPTION`: Cryptographic operation failed diff --git a/src/core/models/__tests__/Credentials.spec.ts b/src/core/models/__tests__/Credentials.spec.ts index 40e6130b..0ee5e005 100644 --- a/src/core/models/__tests__/Credentials.spec.ts +++ b/src/core/models/__tests__/Credentials.spec.ts @@ -24,6 +24,7 @@ describe('Credentials', () => { expiresAt: 1672578000, // Some time in the future refreshToken: 'refresh_token_789', scope: 'openid profile email', + sessionExpiresAt: 1893456000, }; const credentials = new Credentials(credsData); @@ -34,6 +35,7 @@ describe('Credentials', () => { expect(credentials.expiresAt).toBe(credsData.expiresAt); expect(credentials.refreshToken).toBe(credsData.refreshToken); expect(credentials.scope).toBe(credsData.scope); + expect(credentials.sessionExpiresAt).toBe(credsData.sessionExpiresAt); }); it('should handle missing optional properties', () => { @@ -48,6 +50,7 @@ describe('Credentials', () => { expect(credentials.refreshToken).toBeUndefined(); expect(credentials.scope).toBeUndefined(); + expect(credentials.sessionExpiresAt).toBeUndefined(); }); }); diff --git a/src/core/models/__tests__/ErrorCodes.spec.ts b/src/core/models/__tests__/ErrorCodes.spec.ts index 9853da0b..565c6a90 100644 --- a/src/core/models/__tests__/ErrorCodes.spec.ts +++ b/src/core/models/__tests__/ErrorCodes.spec.ts @@ -94,6 +94,9 @@ describe('Error Code Constants', () => { expect(CredentialsManagerErrorCodes.STORE_FAILED).toBe('STORE_FAILED'); expect(CredentialsManagerErrorCodes.REVOKE_FAILED).toBe('REVOKE_FAILED'); expect(CredentialsManagerErrorCodes.LARGE_MIN_TTL).toBe('LARGE_MIN_TTL'); + expect(CredentialsManagerErrorCodes.SESSION_EXPIRED).toBe( + 'SESSION_EXPIRED' + ); expect(CredentialsManagerErrorCodes.CREDENTIAL_MANAGER_ERROR).toBe( 'CREDENTIAL_MANAGER_ERROR' ); @@ -123,9 +126,9 @@ describe('Error Code Constants', () => { ); }); - it('should have exactly 18 error codes', () => { + it('should have exactly 19 error codes', () => { const keys = Object.keys(CredentialsManagerErrorCodes); - expect(keys).toHaveLength(18); + expect(keys).toHaveLength(19); }); it('should be immutable (as const)', () => { diff --git a/src/platforms/native/adapters/__tests__/NativeAuth0Client.spec.ts b/src/platforms/native/adapters/__tests__/NativeAuth0Client.spec.ts index 7a495b8b..914a65d9 100644 --- a/src/platforms/native/adapters/__tests__/NativeAuth0Client.spec.ts +++ b/src/platforms/native/adapters/__tests__/NativeAuth0Client.spec.ts @@ -612,13 +612,11 @@ describe('NativeAuth0Client', () => { const { AuthError } = require('../../../../core/models'); const { PasskeyError } = require('../../../../core/models'); - mockBridgeInstance.getTokenByPasskey = jest - .fn() - .mockRejectedValue( - new AuthError('PASSKEY_EXCHANGE_FAILED', 'Exchange failed', { - code: 'PASSKEY_EXCHANGE_FAILED', - }) - ); + mockBridgeInstance.getTokenByPasskey = jest.fn().mockRejectedValue( + new AuthError('PASSKEY_EXCHANGE_FAILED', 'Exchange failed', { + code: 'PASSKEY_EXCHANGE_FAILED', + }) + ); const client = new NativeAuth0Client(options); await new Promise(process.nextTick); diff --git a/src/platforms/native/adapters/__tests__/NativeCredentialsManager.errors.spec.ts b/src/platforms/native/adapters/__tests__/NativeCredentialsManager.errors.spec.ts index 699c23ff..25618e55 100644 --- a/src/platforms/native/adapters/__tests__/NativeCredentialsManager.errors.spec.ts +++ b/src/platforms/native/adapters/__tests__/NativeCredentialsManager.errors.spec.ts @@ -204,6 +204,38 @@ describe('Common Error Handling - NativeCredentialsManager', () => { }); }); + describe('IPSIE session_expiry ceiling', () => { + const sessionExpiredTestCases = [ + { + code: 'SESSION_EXPIRED', + message: 'The session has expired.', + }, + { + code: 'sessionExpired', + message: 'The session has expired.', + }, + ]; + + sessionExpiredTestCases.forEach(({ code, message }) => { + it(`should map ${code} to SESSION_EXPIRED`, async () => { + const nativeError = { code, message }; + mockBridge.getCredentials.mockRejectedValue(nativeError); + + await expect(manager.getCredentials()).rejects.toThrow( + CredentialsManagerError + ); + + try { + await manager.getCredentials(); + } catch (e) { + const err = e as CredentialsManagerError; + expect(err.type).toBe('SESSION_EXPIRED'); + expect(err.message).toBe(message); + } + }); + }); + }); + describe('Android Biometric Error Mappings', () => { const biometricErrorTestCases = [ 'BIOMETRIC_NO_ACTIVITY', diff --git a/src/platforms/native/adapters/__tests__/NativeCredentialsManager.spec.ts b/src/platforms/native/adapters/__tests__/NativeCredentialsManager.spec.ts index 81340845..f8983f1a 100644 --- a/src/platforms/native/adapters/__tests__/NativeCredentialsManager.spec.ts +++ b/src/platforms/native/adapters/__tests__/NativeCredentialsManager.spec.ts @@ -106,6 +106,21 @@ describe('NativeCredentialsManager', () => { expect(result).toEqual(expectedCredentials); }); + + it('should pass through sessionExpiresAt from the bridge', async () => { + const expectedCredentials = { + idToken: 'a', + accessToken: 'b', + tokenType: 'c', + expiresAt: 123, + sessionExpiresAt: 1893456000, + }; + mockBridge.getCredentials.mockResolvedValueOnce(expectedCredentials); + + const result = await manager.getCredentials(); + + expect(result.sessionExpiresAt).toBe(1893456000); + }); }); describe('hasValidCredentials', () => { diff --git a/src/platforms/web/adapters/WebCredentialsManager.ts b/src/platforms/web/adapters/WebCredentialsManager.ts index 770edcec..197773c1 100644 --- a/src/platforms/web/adapters/WebCredentialsManager.ts +++ b/src/platforms/web/adapters/WebCredentialsManager.ts @@ -11,6 +11,38 @@ import type { Auth0Client } from '@auth0/auth0-spa-js'; export class WebCredentialsManager implements ICredentialsManager { constructor(private client: Auth0Client) {} + // @auth0/auth0-spa-js (>= ^2.22.0) enforces the IPSIE `session_expiry` ceiling + // silently: once the upstream IdP session has expired, `getTokenSilently` + // resolves without a token instead of throwing. Surface this as SESSION_EXPIRED + // so callers get the same actionable signal as native (re-authentication + // required). If a future spa-js bump changes this behavior, revalidate the + // callers that rely on the undefined return below. + private sessionExpiredError(): CredentialsManagerError { + return new CredentialsManagerError( + new AuthError( + 'session_expired', + 'The session has expired and the user must re-authenticate.', + { code: 'session_expired' } + ) + ); + } + + // Normalize a caught error into a CredentialsManagerError. Errors we've already + // classified (e.g. the session_expiry ceiling) are passed through unchanged; + // anything else is wrapped in an AuthError with the given fallback code. + private toCredentialsManagerError( + e: any, + fallbackCode: string + ): CredentialsManagerError { + if (e instanceof CredentialsManagerError) { + return e; + } + const code = e.error ?? fallbackCode; + return new CredentialsManagerError( + new AuthError(code, e.error_description ?? e.message, { json: e, code }) + ); + } + async saveCredentials(_credentials: Credentials): Promise { console.warn( '`saveCredentials` is a no-op on the web. @auth0/auth0-spa-js handles credential storage automatically.' @@ -31,6 +63,12 @@ export class WebCredentialsManager implements ICredentialsManager { detailedResponse: true, }); + // See sessionExpiredError(): spa-js short-circuits the ceiling with an + // undefined resolution rather than a thrown error. + if (!tokenResponse) { + throw this.sessionExpiredError(); + } + const claims = await this.client.getIdTokenClaims(); if (!claims || !claims.exp) { throw new AuthError( @@ -39,20 +77,27 @@ export class WebCredentialsManager implements ICredentialsManager { ); } + // Decode the IPSIE `session_expiry` claim (absolute Unix seconds). Reject values + // outside (0, 10_000_000_000) to match native: the upper bound discards + // millisecond-valued timestamps that would otherwise disable the ceiling. + const rawSessionExpiry = claims.session_expiry; + const sessionExpiresAt = + typeof rawSessionExpiry === 'number' && + rawSessionExpiry > 0 && + rawSessionExpiry < 10_000_000_000 + ? Math.floor(rawSessionExpiry) + : undefined; + return new CredentialsModel({ idToken: tokenResponse.id_token, accessToken: tokenResponse.access_token, tokenType: tokenResponse.token_type ?? 'Bearer', expiresAt: claims.exp, scope: tokenResponse.scope, + sessionExpiresAt, }); } catch (e: any) { - const code = e.error ?? 'GetCredentialsFailed'; - const authError = new AuthError(code, e.error_description ?? e.message, { - json: e, - code, - }); - throw new CredentialsManagerError(authError); + throw this.toCredentialsManagerError(e, 'GetCredentialsFailed'); } } @@ -72,6 +117,12 @@ export class WebCredentialsManager implements ICredentialsManager { detailedResponse: true, }); + // See sessionExpiredError(): spa-js short-circuits the ceiling with an + // undefined resolution rather than a thrown error. + if (!tokenResponse) { + throw this.sessionExpiredError(); + } + // Calculate access token expiration from expires_in (seconds until expiration) // This is more accurate than using ID token claims for API credentials const nowInSeconds = Math.floor(Date.now() / 1000); @@ -84,12 +135,7 @@ export class WebCredentialsManager implements ICredentialsManager { scope: tokenResponse.scope, }); } catch (e: any) { - const code = e.error ?? 'GetApiCredentialsFailed'; - const authError = new AuthError(code, e.error_description ?? e.message, { - json: e, - code, - }); - throw new CredentialsManagerError(authError); + throw this.toCredentialsManagerError(e, 'GetApiCredentialsFailed'); } } diff --git a/src/platforms/web/adapters/__tests__/WebAuth0Client.spec.ts b/src/platforms/web/adapters/__tests__/WebAuth0Client.spec.ts index f5bb0993..ca5f981e 100644 --- a/src/platforms/web/adapters/__tests__/WebAuth0Client.spec.ts +++ b/src/platforms/web/adapters/__tests__/WebAuth0Client.spec.ts @@ -932,7 +932,7 @@ describe('WebAuth0Client', () => { mfa_token: 'mfa_tok_123', mfa_requirements: { challenge: [{ type: 'sms' }, { type: 'otp' }], - enroll: [{ type: 'email' }] + enroll: [{ type: 'email' }], }, }); @@ -949,8 +949,8 @@ describe('WebAuth0Client', () => { mfa_token: 'mfa_tok_123', mfa_requirements: { challenge: [{ type: 'sms' }, { type: 'otp' }], - enroll: [{ type: 'email' }] - } + enroll: [{ type: 'email' }], + }, }), }); @@ -968,7 +968,7 @@ describe('WebAuth0Client', () => { errorDescription: 'MFA is required', mfaRequirements: { challenge: [{ type: 'sms' }, { type: 'otp' }], - enroll: [{ type: 'email' }] + enroll: [{ type: 'email' }], }, }); } diff --git a/src/platforms/web/adapters/__tests__/WebCredentialsManager.errors.spec.ts b/src/platforms/web/adapters/__tests__/WebCredentialsManager.errors.spec.ts index 62b4b31d..7ab2fc5e 100644 --- a/src/platforms/web/adapters/__tests__/WebCredentialsManager.errors.spec.ts +++ b/src/platforms/web/adapters/__tests__/WebCredentialsManager.errors.spec.ts @@ -70,4 +70,28 @@ describe('WebCredentialsManager Error Handling', () => { }); }); }); + + describe('IPSIE session_expiry ceiling', () => { + it('should map a silent undefined token response to SESSION_EXPIRED', async () => { + // spa-js enforces the session_expiry ceiling silently: past the ceiling + // getTokenSilently resolves without a token rather than throwing. + (mockSpaClient.getTokenSilently as jest.Mock).mockResolvedValue( + undefined + ); + + await expect(manager.getCredentials()).rejects.toMatchObject({ + type: 'SESSION_EXPIRED', + }); + }); + + it('should map a silent undefined response in getApiCredentials to SESSION_EXPIRED', async () => { + (mockSpaClient.getTokenSilently as jest.Mock).mockResolvedValue( + undefined + ); + + await expect( + manager.getApiCredentials('https://api.example.com') + ).rejects.toMatchObject({ type: 'SESSION_EXPIRED' }); + }); + }); }); diff --git a/src/platforms/web/adapters/__tests__/WebCredentialsManager.spec.ts b/src/platforms/web/adapters/__tests__/WebCredentialsManager.spec.ts index a540c9d0..5057330d 100644 --- a/src/platforms/web/adapters/__tests__/WebCredentialsManager.spec.ts +++ b/src/platforms/web/adapters/__tests__/WebCredentialsManager.spec.ts @@ -103,6 +103,65 @@ describe('WebCredentialsManager', () => { }); }); + it('should decode the session_expiry claim into sessionExpiresAt', async () => { + const sessionExpiry = 1893456000; // 2030-01-01, within (0, 10_000_000_000) + mockSpaClient.getTokenSilently.mockResolvedValue(mockTokenResponse); + mockSpaClient.getIdTokenClaims.mockResolvedValue({ + ...mockIdTokenClaims, + session_expiry: sessionExpiry, + }); + + const result = await credentialsManager.getCredentials(); + + expect(result.sessionExpiresAt).toBe(sessionExpiry); + }); + + it('should leave sessionExpiresAt undefined when the claim is absent', async () => { + mockSpaClient.getTokenSilently.mockResolvedValue(mockTokenResponse); + mockSpaClient.getIdTokenClaims.mockResolvedValue(mockIdTokenClaims); + + const result = await credentialsManager.getCredentials(); + + expect(result.sessionExpiresAt).toBeUndefined(); + }); + + it('should floor a fractional session_expiry within range', async () => { + mockSpaClient.getTokenSilently.mockResolvedValue(mockTokenResponse); + mockSpaClient.getIdTokenClaims.mockResolvedValue({ + ...mockIdTokenClaims, + session_expiry: 1893456000.9, + }); + + const result = await credentialsManager.getCredentials(); + + expect(result.sessionExpiresAt).toBe(1893456000); + }); + + // The decode guard accepts only a finite number in (0, 10_000_000_000). + // Anything else — strings, zero, negatives, the exact ceiling, and + // millisecond values — must leave sessionExpiresAt undefined (no ceiling). + it.each([ + ['a millisecond value', 1893456000000], + ['the exact upper bound', 10_000_000_000], + ['zero', 0], + ['a negative value', -1], + ['a numeric string', '1893456000'], + ['a boolean', true], + ])( + 'should reject %s and leave sessionExpiresAt undefined', + async (_label, sessionExpiry) => { + mockSpaClient.getTokenSilently.mockResolvedValue(mockTokenResponse); + mockSpaClient.getIdTokenClaims.mockResolvedValue({ + ...mockIdTokenClaims, + session_expiry: sessionExpiry, + }); + + const result = await credentialsManager.getCredentials(); + + expect(result.sessionExpiresAt).toBeUndefined(); + } + ); + it('should get credentials with custom scope and parameters', async () => { mockSpaClient.getTokenSilently.mockResolvedValue(mockTokenResponse); mockSpaClient.getIdTokenClaims.mockResolvedValue(mockIdTokenClaims); diff --git a/src/types/common.ts b/src/types/common.ts index b2d43fe0..12253478 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -30,6 +30,24 @@ export type Credentials = { refreshToken?: string; /** A space-separated list of scopes granted for the access token. */ scope?: string; + /** + * The absolute upstream-IdP session ceiling, as a UNIX timestamp (in seconds), asserted via the + * IPSIE `session_expiry` claim. This is `undefined` when the connection does not emit the claim. + * + * @remarks + * This ceiling is independent of {@link Credentials.expiresAt} (the access-token expiry): it + * usually sits much further out and caps how long the local session may live regardless of + * access-token renewals. The underlying platform SDKs enforce it automatically — once it passes, + * credential retrieval fails with a `SESSION_EXPIRED` `CredentialsManagerError` and the user must + * re-authenticate. It is surfaced here for display purposes only. + * + * @remarks Available on iOS, Android, and web. The value is derived from the `session_expiry` + * claim of the current ID token. + * + * **Android only**: the credentials manager reports the ceiling pinned at the initial login + * (the value it actually enforces) when one is stored. + */ + sessionExpiresAt?: number; /** Allows for additional, non-standard properties returned from the server. */ [key: string]: any; }; From cba946fba1cd688441081149af427192392edc57 Mon Sep 17 00:00:00 2001 From: nandan_prabhu Date: Fri, 31 Jul 2026 12:25:19 +0530 Subject: [PATCH 3/3] Release v5.11.0 (#1617) --- .version | 2 +- CHANGELOG.md | 10 ++++++++++ package.json | 2 +- src/core/utils/telemetry.ts | 2 +- 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.version b/.version index 339cbf23..ff9c6e14 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -v5.10.0 +v5.11.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e053e8d..5052bfc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Change Log +## [v5.11.0](https://github.com/auth0/react-native-auth0/tree/v5.11.0) (2026-07-31) +[Full Changelog](https://github.com/auth0/react-native-auth0/compare/v5.10.0...v5.11.0) + +**Added** +- feat: enforce IPSIE session_expiry with a SESSION_EXPIRED error [\#1597](https://github.com/auth0/react-native-auth0/pull/1597) ([subhankarmaiti](https://github.com/subhankarmaiti)) +- feat: add passkeys support for web [\#1604](https://github.com/auth0/react-native-auth0/pull/1604) ([NandanPrabhu](https://github.com/NandanPrabhu)) +- feat: add My Account API support on the web platform [\#1608](https://github.com/auth0/react-native-auth0/pull/1608) ([subhankarmaiti](https://github.com/subhankarmaiti)) +- feat: add actor token support for Custom Token Exchange impersonation & delegation [\#1595](https://github.com/auth0/react-native-auth0/pull/1595) ([subhankarmaiti](https://github.com/subhankarmaiti)) +- feat: add INVALID_CALLBACK_URL web auth error code [\#1603](https://github.com/auth0/react-native-auth0/pull/1603) ([subhankarmaiti](https://github.com/subhankarmaiti)) + ## [v5.10.0](https://github.com/auth0/react-native-auth0/tree/v5.10.0) (2026-07-17) [Full Changelog](https://github.com/auth0/react-native-auth0/compare/v5.9.0...v5.10.0) diff --git a/package.json b/package.json index c403d448..b7d2326d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "react-native-auth0", "title": "React Native Auth0", - "version": "5.10.0", + "version": "5.11.0", "description": "React Native toolkit for Auth0 API", "main": "lib/commonjs/index.js", "module": "lib/module/index.js", diff --git a/src/core/utils/telemetry.ts b/src/core/utils/telemetry.ts index ce0aae3b..539f87d5 100644 --- a/src/core/utils/telemetry.ts +++ b/src/core/utils/telemetry.ts @@ -1,6 +1,6 @@ export const telemetry = { name: 'react-native-auth0', - version: '5.10.0', + version: '5.11.0', }; export type Telemetry = {