From 565963b960331389ca0a3843dddabda560b0331a Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 16 May 2026 18:54:55 -0400 Subject: [PATCH] feat: support webauthn prf flows --- README.md | 56 ++++++- package-lock.json | 4 +- package.json | 2 +- src/AuthProvider.tsx | 25 +++ src/client/createSeamlessAuthClient.ts | 182 +++++++++++++++++++-- src/client/webauthnPrf.ts | 189 ++++++++++++++++++++++ src/index.ts | 18 +++ src/types.ts | 2 + tests/createSeamlessAuthClient.test.ts | 213 +++++++++++++++++++++++++ tests/webauthnPrf.test.ts | 75 +++++++++ 10 files changed, 746 insertions(+), 20 deletions(-) create mode 100644 src/client/webauthnPrf.ts create mode 100644 tests/webauthnPrf.test.ts diff --git a/README.md b/README.md index bf9bd17..a6290aa 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,7 @@ You are still responsible for your app’s route protection and redirects. refreshSession(): Promise; refreshStepUpStatus(): Promise; verifyStepUpWithPasskey(): Promise; + verifyStepUpWithPasskeyPrf(input: PasskeyPrfInput): Promise; logout(): Promise; deleteUser(): Promise; login(identifier: string, passkeyAvailable: boolean): Promise; @@ -185,6 +186,55 @@ function DeleteAccountButton() { The current step-up backend supports WebAuthn/passkeys. `refreshStepUpStatus()` calls `/step-up/status`, and `verifyStepUpWithPasskey()` performs the `/step-up/webauthn/start` and `/step-up/webauthn/finish` challenge flow. +### WebAuthn PRF + +WebAuthn PRF lets a compatible passkey and browser derive local key material during a WebAuthn assertion. Seamless Auth verifies the passkey assertion on the server, while the React SDK returns the PRF output only to the browser caller. PRF output is stripped before `/webAuthn/login/finish` and `/step-up/webauthn/finish`, and should never be logged, stored, or sent to your API. + +Browser and authenticator support is not universal. Call `isPasskeyPrfSupported()` before offering PRF-required flows, and keep a fallback for passkeys that authenticate successfully without returning PRF output. + +```ts +import { createSeamlessAuthClient } from '@seamless-auth/react'; + +const authClient = createSeamlessAuthClient({ + apiHost: 'https://your.api', + mode: 'server', +}); + +const prfSupported = await authClient.isPasskeyPrfSupported(); + +if (prfSupported) { + await authClient.registerPasskey({ + metadata: { + friendlyName: 'My laptop', + platform: 'macOS', + browser: 'Chrome', + deviceInfo: navigator.userAgent, + }, + requirePrf: true, + }); +} +``` + +For local key unwrap flows such as Seamless Secrets, use PRF during step-up and consume the returned bytes in browser memory: + +```ts +const result = await authClient.verifyStepUpWithPasskeyPrf({ + salt: vaultSaltBase64url, + credentialId, +}); + +if (!result.success || !result.prf) { + throw new Error('PRF step-up failed'); +} + +const vaultUnlockMaterial: { credentialId: string; output: Uint8Array } = { + credentialId: result.credentialId!, + output: result.prf.output, +}; +``` + +The salt may be an `ArrayBuffer`, `ArrayBufferView`, or base64url string. Authentication proves identity and user presence; the PRF output is local key material for your application to use without sending it to Seamless Auth. + ## Headless Client For custom auth UIs, use the exported client directly: @@ -221,9 +271,11 @@ The headless client exposes helpers for: Client methods return raw `Response` objects except for the passkey convenience helpers: -- `loginWithPasskey(): Promise` -- `registerPasskey(metadata): Promise` +- `loginWithPasskey(options?: PasskeyLoginOptions): Promise` +- `registerPasskey(metadata | { metadata, requestPrf?, requirePrf? }): Promise` +- `isPasskeyPrfSupported(): Promise` - `verifyStepUpWithPasskey(): Promise` +- `verifyStepUpWithPasskeyPrf(input): Promise` ## React Hooks For Custom UI diff --git a/package-lock.json b/package-lock.json index 527b8c3..f047b0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@seamless-auth/react", - "version": "0.1.1", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@seamless-auth/react", - "version": "0.1.1", + "version": "0.2.0", "license": "AGPL-3.0-only", "dependencies": { "@simplewebauthn/browser": "^13.1.0", diff --git a/package.json b/package.json index 6c8b09e..65e05d1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@seamless-auth/react", - "version": "0.1.1", + "version": "0.2.0", "description": "A drop-in authentication solution for modern React applications.", "type": "module", "exports": { diff --git a/src/AuthProvider.tsx b/src/AuthProvider.tsx index 992bb85..f70fd56 100644 --- a/src/AuthProvider.tsx +++ b/src/AuthProvider.tsx @@ -6,9 +6,11 @@ import { createSeamlessAuthClient, + StepUpWithPasskeyPrfResult, StepUpStatus, StepUpVerificationResult, } from '@/client/createSeamlessAuthClient'; +import { PasskeyPrfInput } from '@/client/webauthnPrf'; import { Credential, User } from '@/types'; import React, { createContext, @@ -42,6 +44,9 @@ export interface AuthContextType { handlePasskeyLogin: () => Promise; refreshStepUpStatus: () => Promise; verifyStepUpWithPasskey: () => Promise; + verifyStepUpWithPasskeyPrf: ( + input: PasskeyPrfInput + ) => Promise; loading: boolean; } @@ -222,6 +227,25 @@ export const AuthProvider: React.FC = ({ return result; }, [authClient]); + const verifyStepUpWithPasskeyPrf = useCallback( + async (input: PasskeyPrfInput) => { + const result = await authClient.verifyStepUpWithPasskeyPrf(input); + + if (result.success) { + setStepUpStatus({ + fresh: result.fresh, + method: result.method, + verifiedAt: result.verifiedAt, + expiresAt: result.expiresAt, + maxAgeSeconds: result.maxAgeSeconds, + }); + } + + return result; + }, + [authClient] + ); + useEffect(() => { void validateToken(); }, [validateToken]); @@ -254,6 +278,7 @@ export const AuthProvider: React.FC = ({ handlePasskeyLogin, refreshStepUpStatus, verifyStepUpWithPasskey, + verifyStepUpWithPasskeyPrf, }} > {children} diff --git a/src/client/createSeamlessAuthClient.ts b/src/client/createSeamlessAuthClient.ts index bc555f0..5b896c3 100644 --- a/src/client/createSeamlessAuthClient.ts +++ b/src/client/createSeamlessAuthClient.ts @@ -7,12 +7,23 @@ import { startAuthentication, startRegistration, + type AuthenticationResponseJSON, type RegistrationResponseJSON, WebAuthnError, } from '@simplewebauthn/browser'; import { AuthMode, createFetchWithAuth } from '../fetchWithAuth'; import { Credential, User } from '../types'; +import { + createPrfRequestBody, + extractPasskeyPrfResult, + getRegistrationPrfCapable, + isPasskeyPrfSupported, + PasskeyPrfInput, + PasskeyPrfResult, + preparePrfRequestOptions, + stripPrfResultsFromAssertion, +} from './webauthnPrf'; export interface SeamlessAuthClientOptions { apiHost: string; @@ -51,6 +62,14 @@ export interface PasskeyLoginResult { export interface PasskeyRegistrationResult { success: boolean; message: string; + credentialId?: string; + prfCapable?: boolean; +} + +export interface RegisterPasskeyOptions { + metadata: PasskeyMetadata; + requestPrf?: boolean; + requirePrf?: boolean; } export type StepUpMethod = 'webauthn'; @@ -68,10 +87,23 @@ export interface StepUpVerificationResult extends StepUpStatus { message: string; } +export interface PasskeyLoginOptions { + prf?: PasskeyPrfInput; +} + +export interface PasskeyLoginWithPrfResult extends PasskeyLoginResult { + prf?: PasskeyPrfResult; +} + +export interface StepUpWithPasskeyPrfResult extends StepUpVerificationResult { + credentialId: string | null; + prf: PasskeyPrfResult | null; +} + export interface SeamlessAuthClient { getCurrentUser: () => Promise; login: (input: LoginInput) => Promise; - loginWithPasskey: () => Promise; + loginWithPasskey: (options?: PasskeyLoginOptions) => Promise; logout: () => Promise; deleteUser: () => Promise; register: (input: RegisterInput) => Promise; @@ -82,9 +114,15 @@ export interface SeamlessAuthClient { requestMagicLink: () => Promise; checkMagicLink: () => Promise; verifyMagicLink: (token: string) => Promise; - registerPasskey: (metadata: PasskeyMetadata) => Promise; + registerPasskey: ( + input: PasskeyMetadata | RegisterPasskeyOptions + ) => Promise; + isPasskeyPrfSupported: () => Promise; getStepUpStatus: () => Promise; verifyStepUpWithPasskey: () => Promise; + verifyStepUpWithPasskeyPrf: ( + input: PasskeyPrfInput + ) => Promise; updateCredential: (input: { id: string; friendlyName: string | null; @@ -102,6 +140,47 @@ const staleStepUpResult = (message: string): StepUpVerificationResult => ({ message, }); +const staleStepUpPrfResult = (message: string): StepUpWithPasskeyPrfResult => ({ + ...staleStepUpResult(message), + credentialId: null, + prf: null, +}); + +function normalizeRegisterPasskeyInput( + input: PasskeyMetadata | RegisterPasskeyOptions +): RegisterPasskeyOptions { + if ('metadata' in input) { + return input; + } + + return { metadata: input }; +} + +function buildRegisterStartPath(input: RegisterPasskeyOptions) { + const query = new URLSearchParams(); + + if (input.requirePrf) { + query.set('requirePrf', 'true'); + } else if (input.requestPrf) { + query.set('requestPrf', 'true'); + } + + const queryString = query.toString(); + + return `/webAuthn/register/start${queryString ? `?${queryString}` : ''}`; +} + +function buildAssertionStartInit(input?: PasskeyPrfInput): RequestInit { + if (!input) { + return { method: 'POST' }; + } + + return { + method: 'POST', + body: JSON.stringify(createPrfRequestBody(input)), + }; +} + export const createSeamlessAuthClient = ( opts: SeamlessAuthClientOptions ): SeamlessAuthClient => { @@ -122,9 +201,9 @@ export const createSeamlessAuthClient = ( body: JSON.stringify(input), }), - loginWithPasskey: async () => { + loginWithPasskey: async optionsInput => { const response = await fetchWithAuth(`/webAuthn/login/start`, { - method: 'POST', + ...buildAssertionStartInit(optionsInput?.prf), }); if (!response.ok) { @@ -137,11 +216,15 @@ export const createSeamlessAuthClient = ( try { const options = await response.json(); - const credential = await startAuthentication({ optionsJSON: options }); + const credential = (await startAuthentication({ + optionsJSON: preparePrfRequestOptions(options), + })) as AuthenticationResponseJSON; + const prf = extractPasskeyPrfResult(credential); + const assertionResponse = stripPrfResultsFromAssertion(credential); const verificationResponse = await fetchWithAuth(`/webAuthn/login/finish`, { method: 'POST', - body: JSON.stringify({ assertionResponse: credential }), + body: JSON.stringify({ assertionResponse }), }); if (!verificationResponse.ok) { @@ -161,6 +244,7 @@ export const createSeamlessAuthClient = ( message: verificationResult.mfaLogin ? 'Passkey login requires MFA.' : 'Passkey login succeeded.', + ...(prf ? { prf } : {}), }; } @@ -169,8 +253,8 @@ export const createSeamlessAuthClient = ( mfaRequired: false, message: verificationResult.message ?? 'Passkey login failed.', }; - } catch (error) { - console.error('Passkey login error:', error); + } catch { + console.error('Passkey login error.'); return { success: false, mfaRequired: false, @@ -253,8 +337,9 @@ export const createSeamlessAuthClient = ( }, }), - registerPasskey: async metadata => { - const challengeRes = await fetchWithAuth(`/webAuthn/register/start`, { + registerPasskey: async input => { + const registerInput = normalizeRegisterPasskeyInput(input); + const challengeRes = await fetchWithAuth(buildRegisterStartPath(registerInput), { method: 'GET', headers: { 'Content-Type': 'application/json' }, credentials: 'include', @@ -293,7 +378,10 @@ export const createSeamlessAuthClient = ( headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ attestationResponse, - metadata, + metadata: { + ...registerInput.metadata, + prfCapable: getRegistrationPrfCapable(attestationResponse), + }, }), credentials: 'include', }); @@ -308,9 +396,13 @@ export const createSeamlessAuthClient = ( return { success: true, message: 'Passkey registered successfully.', + credentialId: attestationResponse.id, + prfCapable: getRegistrationPrfCapable(attestationResponse), }; }, + isPasskeyPrfSupported, + getStepUpStatus: () => fetchWithAuth(`/step-up/status`, { method: 'GET', @@ -327,11 +419,14 @@ export const createSeamlessAuthClient = ( try { const options = await response.json(); - const credential = await startAuthentication({ optionsJSON: options }); + const credential = (await startAuthentication({ + optionsJSON: preparePrfRequestOptions(options), + })) as AuthenticationResponseJSON; + const assertionResponse = stripPrfResultsFromAssertion(credential); const verificationResponse = await fetchWithAuth(`/step-up/webauthn/finish`, { method: 'POST', - body: JSON.stringify({ assertionResponse: credential }), + body: JSON.stringify({ assertionResponse }), }); if (!verificationResponse.ok) { @@ -357,12 +452,69 @@ export const createSeamlessAuthClient = ( ? 'Step-up authentication succeeded.' : (verificationResult.message ?? 'Step-up authentication failed.'), }; - } catch (error) { - console.error('Step-up authentication error:', error); + } catch { + console.error('Step-up authentication error.'); return staleStepUpResult('Step-up authentication failed.'); } }, + verifyStepUpWithPasskeyPrf: async input => { + const response = await fetchWithAuth(`/step-up/webauthn/start`, { + ...buildAssertionStartInit(input), + }); + + if (!response.ok) { + return staleStepUpPrfResult('Failed to start step-up authentication.'); + } + + try { + const options = await response.json(); + const credential = (await startAuthentication({ + optionsJSON: preparePrfRequestOptions(options), + })) as AuthenticationResponseJSON; + const prf = extractPasskeyPrfResult(credential); + const assertionResponse = stripPrfResultsFromAssertion(credential); + + if (!prf) { + return staleStepUpPrfResult('Passkey did not return PRF output.'); + } + + const verificationResponse = await fetchWithAuth(`/step-up/webauthn/finish`, { + method: 'POST', + body: JSON.stringify({ assertionResponse }), + }); + + if (!verificationResponse.ok) { + return staleStepUpPrfResult('Failed to verify step-up authentication.'); + } + + const verificationResult = await verificationResponse.json(); + const method = + verificationResult.method === 'webauthn' ? verificationResult.method : null; + const success = + verificationResult.message === 'Success' && + verificationResult.fresh === true && + method === 'webauthn'; + + return { + success, + fresh: Boolean(verificationResult.fresh), + method, + verifiedAt: verificationResult.verifiedAt ?? null, + expiresAt: verificationResult.expiresAt ?? null, + maxAgeSeconds: verificationResult.maxAgeSeconds ?? 0, + message: success + ? 'Step-up authentication succeeded.' + : (verificationResult.message ?? 'Step-up authentication failed.'), + credentialId: prf.credentialId, + prf, + }; + } catch { + console.error('Step-up authentication error.'); + return staleStepUpPrfResult('Step-up authentication failed.'); + } + }, + updateCredential: input => fetchWithAuth(`users/credentials`, { method: 'POST', diff --git a/src/client/webauthnPrf.ts b/src/client/webauthnPrf.ts new file mode 100644 index 0000000..eb7791e --- /dev/null +++ b/src/client/webauthnPrf.ts @@ -0,0 +1,189 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { + base64URLStringToBuffer, + browserSupportsWebAuthn, + bufferToBase64URLString, + type AuthenticationResponseJSON, + type PublicKeyCredentialRequestOptionsJSON, +} from '@simplewebauthn/browser'; + +export type PasskeyPrfSalt = ArrayBuffer | ArrayBufferView | string; + +export interface PasskeyPrfInput { + salt: PasskeyPrfSalt; + secondSalt?: PasskeyPrfSalt; + credentialId?: string; +} + +export interface PasskeyPrfResult { + credentialId: string; + output: Uint8Array; + outputBase64url: string; +} + +type PrfExtensionEval = { + first?: PasskeyPrfSalt; + second?: PasskeyPrfSalt; +}; + +type PrfOptionsJSON = PublicKeyCredentialRequestOptionsJSON & { + extensions?: PublicKeyCredentialRequestOptionsJSON['extensions'] & { + prf?: { + eval?: PrfExtensionEval; + }; + }; +}; + +type PrfClientExtensionResults = { + prf?: { + enabled?: boolean; + results?: { + first?: ArrayBuffer | ArrayBufferView; + second?: ArrayBuffer | ArrayBufferView; + }; + }; +}; + +function toArrayBuffer(value: ArrayBuffer | ArrayBufferView): ArrayBuffer { + if (value instanceof ArrayBuffer) { + return value; + } + + const source = new Uint8Array(value.buffer, value.byteOffset, value.byteLength); + const copy = new Uint8Array(source.byteLength); + copy.set(source); + + return copy.buffer; +} + +function toUint8Array(value: ArrayBuffer | ArrayBufferView): Uint8Array { + if (value instanceof ArrayBuffer) { + return new Uint8Array(value); + } + + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); +} + +export function encodePrfSalt(salt: PasskeyPrfSalt): string { + if (typeof salt === 'string') { + return salt; + } + + return bufferToBase64URLString(toArrayBuffer(salt)); +} + +function saltToBufferSource(salt: PasskeyPrfSalt): ArrayBuffer | ArrayBufferView { + if (typeof salt === 'string') { + return base64URLStringToBuffer(salt); + } + + return salt; +} + +export function createPrfRequestBody(input: PasskeyPrfInput) { + return { + ...(input.credentialId ? { credentialId: input.credentialId } : {}), + prf: { + salt: encodePrfSalt(input.salt), + ...(input.secondSalt ? { secondSalt: encodePrfSalt(input.secondSalt) } : {}), + }, + }; +} + +export async function isPasskeyPrfSupported(): Promise { + if (!browserSupportsWebAuthn()) { + return false; + } + + if (typeof window !== 'undefined' && window.isSecureContext === false) { + return false; + } + + return ( + typeof globalThis.PublicKeyCredential !== 'undefined' && + typeof navigator !== 'undefined' && + typeof navigator.credentials?.get === 'function' + ); +} + +export function preparePrfRequestOptions( + optionsJSON: PublicKeyCredentialRequestOptionsJSON, +): PublicKeyCredentialRequestOptionsJSON { + const options = optionsJSON as PrfOptionsJSON; + const prfEval = options.extensions?.prf?.eval; + + if (!prfEval) { + return optionsJSON; + } + + return { + ...options, + extensions: { + ...options.extensions, + prf: { + ...options.extensions?.prf, + eval: { + ...prfEval, + ...(prfEval.first ? { first: saltToBufferSource(prfEval.first) } : {}), + ...(prfEval.second ? { second: saltToBufferSource(prfEval.second) } : {}), + }, + }, + }, + } as PublicKeyCredentialRequestOptionsJSON; +} + +export function extractPasskeyPrfResult( + credential: AuthenticationResponseJSON, +): PasskeyPrfResult | null { + const extensionResults = + credential.clientExtensionResults as unknown as PrfClientExtensionResults; + const first = extensionResults?.prf?.results?.first; + + if (!first) { + return null; + } + + const output = toUint8Array(first); + + return { + credentialId: credential.id, + output, + outputBase64url: bufferToBase64URLString(toArrayBuffer(output)), + }; +} + +export function stripPrfResultsFromAssertion( + credential: AuthenticationResponseJSON, +): AuthenticationResponseJSON { + const extensionResults = + credential.clientExtensionResults as unknown as PrfClientExtensionResults; + + if (!extensionResults?.prf?.results) { + return credential; + } + + return { + ...credential, + clientExtensionResults: { + ...credential.clientExtensionResults, + prf: { + ...extensionResults.prf, + results: undefined, + }, + }, + } as AuthenticationResponseJSON; +} + +export function getRegistrationPrfCapable(attestationResponse: { + clientExtensionResults?: unknown; +}) { + const extensionResults = + attestationResponse.clientExtensionResults as PrfClientExtensionResults | undefined; + + return extensionResults?.prf?.enabled === true; +} diff --git a/src/index.ts b/src/index.ts index 406a55c..e6806ab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,15 +11,25 @@ import { CurrentUserResult, LoginInput, PasskeyLoginResult, + PasskeyLoginWithPrfResult, PasskeyMetadata, PasskeyRegistrationResult, RegisterInput, + RegisterPasskeyOptions, SeamlessAuthClient, SeamlessAuthClientOptions, StepUpMethod, StepUpStatus, + StepUpWithPasskeyPrfResult, StepUpVerificationResult, } from '@/client/createSeamlessAuthClient'; +import { + encodePrfSalt, + extractPasskeyPrfResult, + isPasskeyPrfSupported, + PasskeyPrfInput, + PasskeyPrfResult, +} from '@/client/webauthnPrf'; import { AuthMode } from '@/fetchWithAuth'; import { useAuthClient } from '@/hooks/useAuthClient'; import { usePasskeySupport } from '@/hooks/usePasskeySupport'; @@ -29,6 +39,9 @@ export { AuthProvider, AuthRoutes, createSeamlessAuthClient, + encodePrfSalt, + extractPasskeyPrfResult, + isPasskeyPrfSupported, useAuth, useAuthClient, usePasskeySupport, @@ -39,14 +52,19 @@ export type { Credential, CurrentUserResult, LoginInput, + PasskeyLoginWithPrfResult, PasskeyLoginResult, PasskeyMetadata, + PasskeyPrfInput, + PasskeyPrfResult, PasskeyRegistrationResult, RegisterInput, + RegisterPasskeyOptions, SeamlessAuthClient, SeamlessAuthClientOptions, StepUpMethod, StepUpStatus, + StepUpWithPasskeyPrfResult, StepUpVerificationResult, User, }; diff --git a/src/types.ts b/src/types.ts index a16ce33..3ea511e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -22,6 +22,8 @@ export interface Credential { transports?: AuthenticatorTransportFuture[]; deviceType: CredentialDeviceType; backedup: boolean; + backedUp?: boolean; + prfCapable?: boolean; friendlyName: string | null; lastUsedAt: Date | null; platform: string | null; diff --git a/tests/createSeamlessAuthClient.test.ts b/tests/createSeamlessAuthClient.test.ts index 192061b..975605a 100644 --- a/tests/createSeamlessAuthClient.test.ts +++ b/tests/createSeamlessAuthClient.test.ts @@ -12,6 +12,20 @@ jest.mock('../src/fetchWithAuth'); jest.mock('@simplewebauthn/browser', () => ({ startAuthentication: jest.fn(), startRegistration: jest.fn(), + browserSupportsWebAuthn: jest.fn(() => true), + base64URLStringToBuffer: jest.fn((value: string) => { + const base64 = value.replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '='); + + return Uint8Array.from(Buffer.from(padded, 'base64')).buffer; + }), + bufferToBase64URLString: jest.fn((value: ArrayBuffer) => + Buffer.from(value) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, '') + ), WebAuthnError: class WebAuthnError extends Error { name = 'WebAuthnError'; }, @@ -24,6 +38,10 @@ const mockFetchWithAuth = jest.fn(); describe('createSeamlessAuthClient', () => { beforeEach(() => { jest.clearAllMocks(); + mockFetchWithAuth.mockReset(); + (startAuthentication as jest.Mock).mockReset(); + (startRegistration as jest.Mock).mockReset(); + (createFetchWithAuth as jest.Mock).mockReturnValue(mockFetchWithAuth); }); it('forwards login requests through the shared auth fetch helper', async () => { @@ -72,6 +90,63 @@ describe('createSeamlessAuthClient', () => { }); }); + it('returns passkey login PRF output without sending it to verification', async () => { + mockFetchWithAuth + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + challenge: 'challenge', + extensions: { + prf: { + eval: { + first: 'AQIDBA', + }, + }, + }, + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ message: 'Success', mfaLogin: false }), + }); + (startAuthentication as jest.Mock).mockResolvedValueOnce({ + id: 'cred-prf', + rawId: 'cred-prf', + response: { + authenticatorData: 'auth-data', + clientDataJSON: 'client-data', + signature: 'sig', + }, + type: 'public-key', + clientExtensionResults: { + prf: { + results: { + first: Uint8Array.from([1, 2, 3, 4]).buffer, + }, + }, + }, + }); + + const client = createSeamlessAuthClient({ + apiHost: 'https://api.example.com', + mode: 'web', + }); + + const result = await client.loginWithPasskey({ + prf: { salt: Uint8Array.from([1, 2, 3, 4]) }, + }); + + expect(result.success).toBe(true); + expect(result.prf?.credentialId).toBe('cred-prf'); + expect(result.prf?.outputBase64url).toBe('AQIDBA'); + + const finishBody = (mockFetchWithAuth.mock.calls[1][1] as RequestInit).body as string; + expect(finishBody).not.toContain('AQIDBA'); + expect( + JSON.parse(finishBody).assertionResponse.clientExtensionResults.prf.results + ).toBeUndefined(); + }); + it('returns a successful passkey registration result when registration completes', async () => { mockFetchWithAuth .mockResolvedValueOnce({ @@ -98,7 +173,71 @@ describe('createSeamlessAuthClient', () => { ).resolves.toEqual({ success: true, message: 'Passkey registered successfully.', + credentialId: 'cred', + prfCapable: false, + }); + }); + + it('requests PRF-capable registration and reports capability', async () => { + mockFetchWithAuth + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ challenge: 'challenge', extensions: { prf: {} } }), + }) + .mockResolvedValueOnce({ + ok: true, + }); + (startRegistration as jest.Mock).mockResolvedValueOnce({ + id: 'cred-prf', + clientExtensionResults: { prf: { enabled: true } }, + }); + + const client = createSeamlessAuthClient({ + apiHost: 'https://api.example.com', + mode: 'web', + }); + + await expect( + client.registerPasskey({ + metadata: { + friendlyName: 'My Laptop', + platform: 'mac', + browser: 'chrome', + deviceInfo: 'mac chrome', + }, + requirePrf: true, + }) + ).resolves.toEqual({ + success: true, + message: 'Passkey registered successfully.', + credentialId: 'cred-prf', + prfCapable: true, }); + + expect(mockFetchWithAuth).toHaveBeenNthCalledWith( + 1, + '/webAuthn/register/start?requirePrf=true', + expect.objectContaining({ method: 'GET' }) + ); + expect(mockFetchWithAuth).toHaveBeenNthCalledWith( + 2, + '/webAuthn/register/finish', + expect.objectContaining({ + body: JSON.stringify({ + attestationResponse: { + id: 'cred-prf', + clientExtensionResults: { prf: { enabled: true } }, + }, + metadata: { + friendlyName: 'My Laptop', + platform: 'mac', + browser: 'chrome', + deviceInfo: 'mac chrome', + prfCapable: true, + }, + }), + }) + ); }); it('forwards step-up status requests through the shared auth fetch helper', async () => { @@ -178,4 +317,78 @@ describe('createSeamlessAuthClient', () => { message: 'Failed to start step-up authentication.', }); }); + + it('performs PRF step-up without sending PRF output to the API', async () => { + const salt = Uint8Array.from(Array.from({ length: 32 }, (_, index) => index + 1)); + + mockFetchWithAuth + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + challenge: 'challenge', + extensions: { + prf: { + eval: { + first: 'AQIDBA', + }, + }, + }, + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + message: 'Success', + fresh: true, + method: 'webauthn', + verifiedAt: '2026-05-15T12:00:00.000Z', + expiresAt: '2026-05-15T12:05:00.000Z', + maxAgeSeconds: 300, + }), + }); + (startAuthentication as jest.Mock).mockResolvedValueOnce({ + id: 'cred-prf', + rawId: 'cred-prf', + response: { + authenticatorData: 'auth-data', + clientDataJSON: 'client-data', + signature: 'sig', + }, + type: 'public-key', + clientExtensionResults: { + prf: { + results: { + first: Uint8Array.from([1, 2, 3, 4]).buffer, + }, + }, + }, + }); + + const client = createSeamlessAuthClient({ + apiHost: 'https://api.example.com', + mode: 'web', + }); + + const result = await client.verifyStepUpWithPasskeyPrf({ salt }); + + expect(result.success).toBe(true); + expect(result.credentialId).toBe('cred-prf'); + expect(result.prf?.outputBase64url).toBe('AQIDBA'); + expect(Array.from(result.prf?.output ?? [])).toEqual([1, 2, 3, 4]); + + expect(mockFetchWithAuth).toHaveBeenNthCalledWith(1, '/step-up/webauthn/start', { + method: 'POST', + body: JSON.stringify({ + prf: { + salt: 'AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA', + }, + }), + }); + + const finishBody = (mockFetchWithAuth.mock.calls[1][1] as RequestInit).body as string; + expect(finishBody).not.toContain('AQIDBA'); + expect( + JSON.parse(finishBody).assertionResponse.clientExtensionResults.prf.results + ).toBeUndefined(); + }); }); diff --git a/tests/webauthnPrf.test.ts b/tests/webauthnPrf.test.ts new file mode 100644 index 0000000..66d32e6 --- /dev/null +++ b/tests/webauthnPrf.test.ts @@ -0,0 +1,75 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { + createPrfRequestBody, + encodePrfSalt, + extractPasskeyPrfResult, + preparePrfRequestOptions, + stripPrfResultsFromAssertion, +} from '../src/client/webauthnPrf'; + +describe('webauthnPrf helpers', () => { + it('encodes PRF salts and request bodies as base64url', () => { + const salt = Uint8Array.from([1, 2, 3, 4]); + + expect(encodePrfSalt(salt)).toBe('AQIDBA'); + expect(createPrfRequestBody({ salt, credentialId: 'cred-1' })).toEqual({ + credentialId: 'cred-1', + prf: { + salt: 'AQIDBA', + }, + }); + }); + + it('converts PRF option salts from JSON strings to BufferSource values', () => { + const options = preparePrfRequestOptions({ + challenge: 'challenge', + extensions: { + prf: { + eval: { + first: 'AQIDBA', + }, + }, + } as any, + }); + + expect((options.extensions as any).prf.eval.first).toBeInstanceOf(ArrayBuffer); + expect(Array.from(new Uint8Array((options.extensions as any).prf.eval.first))).toEqual([ + 1, 2, 3, 4, + ]); + }); + + it('extracts PRF output and strips it from assertion payloads', () => { + const assertion: any = { + id: 'cred-1', + rawId: 'cred-1', + response: { + clientDataJSON: 'client-data', + authenticatorData: 'auth-data', + signature: 'sig', + }, + type: 'public-key', + clientExtensionResults: { + prf: { + results: { + first: Uint8Array.from([5, 6, 7, 8]).buffer, + }, + }, + }, + }; + + expect(extractPasskeyPrfResult(assertion)).toEqual({ + credentialId: 'cred-1', + output: Uint8Array.from([5, 6, 7, 8]), + outputBase64url: 'BQYHCA', + }); + expect(JSON.stringify(stripPrfResultsFromAssertion(assertion))).not.toContain('BQYHCA'); + expect( + (stripPrfResultsFromAssertion(assertion).clientExtensionResults as any).prf.results + ).toBeUndefined(); + }); +});