From cfa17c2e114a9aecb71159064c8a08fd228d6562 Mon Sep 17 00:00:00 2001 From: Nandan Prabhu Date: Wed, 22 Jul 2026 23:06:12 +0530 Subject: [PATCH] Passkeys API support on web --- CLAUDE.md | 2 +- EXAMPLES-WEB.md | 43 +++++ EXAMPLES.md | 110 ++++++++++-- README.md | 6 +- example/src/App.web.tsx | 141 +++++++++++++++ package.json | 2 +- src/Auth0.ts | 6 - src/core/models/PasskeyError.ts | 7 +- src/platforms/web/adapters/WebAuth0Client.ts | 133 +++++++++++--- .../adapters/__tests__/WebAuth0Client.spec.ts | 162 ++++++++++++++++++ yarn.lock | 32 ++-- 11 files changed, 584 insertions(+), 60 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6d8794987..d0e40c3f6 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 c4a709d0a..9657d6799 100644 --- a/EXAMPLES-WEB.md +++ b/EXAMPLES-WEB.md @@ -277,8 +277,51 @@ 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 → credential manager → 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. See [Signup with Passkey (Web)](./EXAMPLES.md#signup-with-passkey-web) in `EXAMPLES.md` for a full example, including how to serialize the `PublicKeyCredential` returned by the browser into the JSON format `getTokenByPasskey` expects. + +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', + }); + + const credential = (await navigator.credentials.get({ + publicKey: challenge.authParamsPublicKey as PublicKeyCredentialRequestOptions, + })) as PublicKeyCredential; + + // See EXAMPLES.md for the serializeCredential() helper. + const credentials = await getTokenByPasskey({ + authSession: challenge.authSession, + authResponse: serializeCredential(credential), + realm: 'Username-Password-Authentication', + }); + + console.log('Signed in with passkey:', credentials.accessToken); + } catch (error) { + if (error instanceof PasskeyError) { + console.error('Passkey login failed:', error.type, error.message); + } + } + }; + + 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. Token refresh is handled automatically by `credentialsManager.getCredentials()` on the web. The `auth.refreshToken()` method is not available. + +Passkeys are supported on web, but the My Account API's passkey enrollment (`myAccount.enrollPasskey()`) is native-only. diff --git a/EXAMPLES.md b/EXAMPLES.md index feb0aefca..a6cb2b124 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -56,7 +56,8 @@ - [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) + - [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) @@ -1191,15 +1192,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. On native platforms, 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`. On web, the SDK drives the full flow — challenge, browser WebAuthn ceremony (`navigator.credentials`), and token exchange — internally via `@auth0/auth0-spa-js`, so no extra library is needed. 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. **Credential Manager** — Present the OS (native) or browser (web) credential manager UI to create or assert a passkey. On native, use your own native module or a library; on web, the SDK calls `navigator.credentials` for you as part of `getTokenByPasskey`. 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). @@ -1211,6 +1212,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. @@ -1306,9 +1308,95 @@ function PasskeySigninScreen() { } ``` + + +### Signup with Passkey (Web) + +On web, step 2 (the credential manager) uses 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. Serialize the resulting `PublicKeyCredential` to the JSON shape described in [Auth Response Format](#passkeys-auth-response-format) before passing it to `getTokenByPasskey`. + +```tsx +import { useAuth0, PasskeyError } from 'react-native-auth0'; + +// Converts a PublicKeyCredential from navigator.credentials into the +// JSON shape getTokenByPasskey expects (see "Auth Response Format" below). +function serializeCredential(credential: PublicKeyCredential) { + const toBase64Url = (buffer: ArrayBuffer) => + btoa(String.fromCharCode(...new Uint8Array(buffer))) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + + const response = credential.response; + const isAttestation = 'attestationObject' in response; + + return JSON.stringify({ + id: credential.id, + rawId: toBase64Url(credential.rawId), + type: credential.type, + authenticatorAttachment: credential.authenticatorAttachment ?? undefined, + response: isAttestation + ? { + clientDataJSON: toBase64Url(response.clientDataJSON), + attestationObject: toBase64Url( + (response as AuthenticatorAttestationResponse).attestationObject + ), + } + : { + clientDataJSON: toBase64Url(response.clientDataJSON), + authenticatorData: toBase64Url( + (response as AuthenticatorAssertionResponse).authenticatorData + ), + signature: toBase64Url( + (response as AuthenticatorAssertionResponse).signature + ), + userHandle: (response as AuthenticatorAssertionResponse).userHandle + ? toBase64Url( + (response as AuthenticatorAssertionResponse).userHandle! + ) + : undefined, + }, + }); +} + +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', + }); + + const credential = (await navigator.credentials.create({ + publicKey: challenge.authParamsPublicKey as PublicKeyCredentialCreationOptions, + })) as PublicKeyCredential; + + const credentials = await getTokenByPasskey({ + authSession: challenge.authSession, + authResponse: serializeCredential(credential), + realm: 'Username-Password-Authentication', + }); + + console.log('Signed up with passkey:', credentials.accessToken); + } catch (error) { + if (error instanceof PasskeyError) { + console.error('Passkey signup 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. +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 (native module/library on iOS/Android, `navigator.credentials` on web). **For registration (signup):** @@ -1441,13 +1529,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 diff --git a/README.md b/README.md index b62b06701..0289242e5 100644 --- a/README.md +++ b/README.md @@ -915,9 +915,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 469adeba2..f627ac57c 100644 --- a/example/src/App.web.tsx +++ b/example/src/App.web.tsx @@ -17,6 +17,7 @@ import Auth0, { MfaError, MfaErrorCodes, MfaFactorType, + PasskeyError, } from 'react-native-auth0'; import type { MfaAuthenticator, @@ -40,6 +41,47 @@ type MfaStep = type EnrollType = MfaFactorType; +// Converts a PublicKeyCredential from navigator.credentials into the JSON +// shape getTokenByPasskey expects (see EXAMPLES.md "Auth Response Format"). +const toBase64Url = (buffer: ArrayBuffer): string => + btoa(String.fromCharCode(...new Uint8Array(buffer))) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + +const serializePasskeyCredential = (credential: PublicKeyCredential): string => { + const response = credential.response; + const isAttestation = 'attestationObject' in response; + + return JSON.stringify({ + id: credential.id, + rawId: toBase64Url(credential.rawId), + type: credential.type, + authenticatorAttachment: credential.authenticatorAttachment ?? undefined, + response: isAttestation + ? { + clientDataJSON: toBase64Url(response.clientDataJSON), + attestationObject: toBase64Url( + (response as AuthenticatorAttestationResponse).attestationObject + ), + } + : { + clientDataJSON: toBase64Url(response.clientDataJSON), + authenticatorData: toBase64Url( + (response as AuthenticatorAssertionResponse).authenticatorData + ), + signature: toBase64Url( + (response as AuthenticatorAssertionResponse).signature + ), + userHandle: (response as AuthenticatorAssertionResponse).userHandle + ? toBase64Url( + (response as AuthenticatorAssertionResponse).userHandle! + ) + : undefined, + }, + }); +}; + // ======================================================================== // --- 1. HOOKS-BASED IMPLEMENTATION (Recommended) --- // ======================================================================== @@ -57,12 +99,17 @@ const HooksAuthContent = (): React.JSX.Element => { loginWithPasswordRealm, mfa, users, + 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); // MFA wizard state const [mfaToken, setMfaToken] = useState(''); @@ -129,6 +176,77 @@ 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', + }); + + const credential = (await navigator.credentials.create({ + publicKey: + challenge.authParamsPublicKey as PublicKeyCredentialCreationOptions, + })) as PublicKeyCredential; + + const credentials = await getTokenByPasskey({ + authSession: challenge.authSession, + authResponse: serializePasskeyCredential(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', + }); + + const credential = (await navigator.credentials.get({ + publicKey: + challenge.authParamsPublicKey as PublicKeyCredentialRequestOptions, + })) as PublicKeyCredential; + + const credentials = await getTokenByPasskey({ + authSession: challenge.authSession, + authResponse: serializePasskeyCredential(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); @@ -626,6 +744,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 238316173..e8a4a1abf 100644 --- a/package.json +++ b/package.json @@ -135,7 +135,7 @@ "typescript-eslint": "^8.62.0" }, "dependencies": { - "@auth0/auth0-spa-js": "2.19.3", + "@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 960140d99..2e5a9d20e 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 e11737ddd..bc0b1663a 100644 --- a/src/core/models/PasskeyError.ts +++ b/src/core/models/PasskeyError.ts @@ -53,8 +53,13 @@ const ERROR_CODE_MAP: Record = { PASSKEY_CHALLENGE_FAILED: PasskeyErrorCodes.CHALLENGE_FAILED, PASSKEY_EXCHANGE_FAILED: PasskeyErrorCodes.EXCHANGE_FAILED, - // --- Web platform --- + // --- Web platform (auth0-spa-js) --- 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.EXCHANGE_FAILED, }; /** diff --git a/src/platforms/web/adapters/WebAuth0Client.ts b/src/platforms/web/adapters/WebAuth0Client.ts index 10efa9796..a468b6956 100644 --- a/src/platforms/web/adapters/WebAuth0Client.ts +++ b/src/platforms/web/adapters/WebAuth0Client.ts @@ -2,6 +2,7 @@ import { Auth0Client, type Auth0ClientOptions, type LogoutOptions, + type PasskeyCredentialResponse, } from '@auth0/auth0-spa-js'; import type { IAuth0Client, @@ -275,35 +276,125 @@ 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; + const challenge = await this.client.passkey.getSignupChallenge({ + email, + phoneNumber, + username, + name, + givenName, + familyName, + nickname, + picture, + userMetadata, + realm, + organization, + } as any); + + return { + authSession: challenge.authSession, + authParamsPublicKey: challenge.publicKey as unknown as Record< + string, + any + >, + }; + } catch (e: any) { + const authError = new AuthError( + e.code ?? 'passkey_signup_challenge_failed', + e.message ?? 'Failed to request passkey signup challenge', + { code: e.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) { + const authError = new AuthError( + e.code ?? 'passkey_login_challenge_failed', + e.message ?? 'Failed to request passkey login challenge', + { code: e.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; + // `authResponse` is already a serialized (base64url) credential JSON + // matching the native platforms' format, not a raw WebAuthn + // PublicKeyCredential — so we bypass `passkey.getTokenWithPasskey()` + // (which expects the raw browser credential) and go straight to the + // token exchange with the pre-serialized shape it would have produced. + const credential: PasskeyCredentialResponse = JSON.parse(authResponse); + + const response = await this.client._requestTokenForPasskey({ + authSession, + credential, + realm, + audience, + scope, + 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) { + const authError = new AuthError( + e.code ?? 'passkey_exchange_failed', + e.message ?? 'Failed to exchange passkey credential for tokens', + { code: e.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 e1783c261..49900ad8a 100644 --- a/src/platforms/web/adapters/__tests__/WebAuth0Client.spec.ts +++ b/src/platforms/web/adapters/__tests__/WebAuth0Client.spec.ts @@ -49,9 +49,21 @@ jest.mock('../../../../core/models', () => { } } + class MockPasskeyError extends Error { + type: string; + code: string; + constructor(originalError: MockAuthError) { + super(originalError.message); + this.name = 'PasskeyError'; + this.code = originalError.code; + this.type = originalError.code; + } + } + return { AuthError: MockAuthError, AuthenticationException: MockAuthenticationException, + PasskeyError: MockPasskeyError, // Add other exports from models if needed Credentials: jest.fn(), Auth0User: jest.fn(), @@ -109,6 +121,11 @@ describe('WebAuth0Client', () => { getTokenSilently: jest.fn(), getIdTokenClaims: jest.fn(), isAuthenticated: jest.fn(), + passkey: { + getSignupChallenge: jest.fn(), + getLoginChallenge: jest.fn(), + }, + _requestTokenForPasskey: jest.fn(), } as any; mockHttpClient = { @@ -486,6 +503,151 @@ 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', + }); + }); + }); + + 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', + }); + }); + }); + + describe('getTokenByPasskey method', () => { + const mockCredential = { + id: 'credential-id', + rawId: 'credential-id', + type: 'public-key', + response: { + clientDataJSON: 'client-data', + attestationObject: 'attestation-object', + }, + }; + + it('should exchange the credential for tokens', async () => { + mockSpaClient._requestTokenForPasskey.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: JSON.stringify(mockCredential), + realm: 'Username-Password-Authentication', + }); + + expect(mockSpaClient._requestTokenForPasskey).toHaveBeenCalledWith({ + authSession: 'auth-session-123', + credential: mockCredential, + realm: 'Username-Password-Authentication', + audience: undefined, + scope: undefined, + 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 throw PasskeyError when the exchange fails', async () => { + mockSpaClient._requestTokenForPasskey.mockRejectedValue({ + code: 'passkey_get_token_error', + message: 'Failed to exchange credential', + }); + + await expect( + client.getTokenByPasskey({ + authSession: 'auth-session-123', + authResponse: JSON.stringify(mockCredential), + }) + ).rejects.toMatchObject({ + name: 'PasskeyError', + code: 'passkey_get_token_error', + }); + }); + }); + describe('ssoExchange', () => { it('should reject with UnsupportedOperation error on web', async () => { await expect( diff --git a/yarn.lock b/yarn.lock index a3b1f8fd9..36e830439 100644 --- a/yarn.lock +++ b/yarn.lock @@ -28,25 +28,25 @@ __metadata: languageName: node linkType: hard -"@auth0/auth0-auth-js@npm:1.6.0": - version: 1.6.0 - resolution: "@auth0/auth0-auth-js@npm:1.6.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/0f5672ea77045d1172dac8a2571f1a7111045677191fa6105c0458a720fab3db385384b302535a6694c16dfe82ef0e9a3510d2113d5c35ca0f5ecd1bb1d96c05 + checksum: 10c0/52cafd1a1b0bef8d44078ac36cbcf41c5e858ba32614e93ae2dd2020efd0c63dbe9a3d92af59579c1eacd60d918da622c33fe7dde92a723bb1831963a2ba6c11 languageName: node linkType: hard -"@auth0/auth0-spa-js@npm:2.19.3": - version: 2.19.3 - resolution: "@auth0/auth0-spa-js@npm:2.19.3" +"@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.6.0" - browser-tabs-lock: "npm:1.3.0" - dpop: "npm:2.1.1" - es-cookie: "npm:1.3.2" - checksum: 10c0/6449b0d47b311989baf0650f67dc3629397952af04eb5dc8303feabe10b51a695acfb7a4814052c94b89c228acccd299be7753bce41ed20986131da54a516114 + "@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.19.3" + "@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"