diff --git a/README.md b/README.md index 738504c..74e76ca 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ This repository intentionally focuses on **authentication only**. - Secure session and token handling - User registration and authentication APIs - WebAuthn / Passkeys support +- WebAuthn PRF-capable passkey primitives for browser-local key derivation - JWKS and token verification endpoints - Database models and migrations required for auth - Local development and self-hosting support @@ -58,6 +59,7 @@ This repository does **not** assume any specific cloud provider, billing system, - Passwordless-first design (no passwords to steal) - Modern session handling using secure, HTTP-only cookies - WebAuthn / passkeys support +- Optional WebAuthn PRF support for products that need browser-local key material - Token and JWKS support for service-to-service auth - Built for inspection, auditability, and self-hosting @@ -94,6 +96,12 @@ Copy the `.env.example` to an `.env` file and populate empty values. Never commit real secrets. Use `.env.example` for documentation. +### WebAuthn PRF + +SeamlessAuth can request PRF-capable passkeys and PRF assertions without ever receiving PRF output. +See [docs/webauthn-prf.md](./docs/webauthn-prf.md) for API usage, browser limitations, SDK +contract guidance, and Seamless Secrets consumption rules. + ### Install & run ``` diff --git a/resources/coverage-badge.svg b/resources/coverage-badge.svg index e82144e..cfaca7c 100644 --- a/resources/coverage-badge.svg +++ b/resources/coverage-badge.svg @@ -1,5 +1,5 @@ - - coverage: 82% + + coverage: 82.1% @@ -7,17 +7,17 @@ - + - - + + coverage coverage - 82% - 82% + 82.1% + 82.1% diff --git a/src/controllers/stepUp.ts b/src/controllers/stepUp.ts index a981f0f..7b3a5ba 100644 --- a/src/controllers/stepUp.ts +++ b/src/controllers/stepUp.ts @@ -14,6 +14,7 @@ import base64url from 'base64url'; import { Request, Response } from 'express'; import { getSystemConfig } from '../config/getSystemConfig.js'; +import { buildPrfAuthenticationExtensions, containsPrfOutput } from '../lib/webauthnPrf.js'; import { Credential } from '../models/credentials.js'; import { AuthEventService } from '../services/authEventService.js'; import { @@ -26,6 +27,23 @@ import getLogger from '../utils/logger.js'; const logger = getLogger('step-up'); +function filterAssertionCredentials( + credentials: Credential[], + options: { credentialId?: string; requiresPrf: boolean }, +) { + return credentials.filter((credential) => { + if (options.credentialId && credential.id !== options.credentialId) { + return false; + } + + if (options.requiresPrf && !credential.prfCapable) { + return false; + } + + return true; + }); +} + export const getStepUpStatus = async (req: Request, res: Response) => { const authReq = req as AuthenticatedRequest; const user = authReq.user; @@ -49,6 +67,7 @@ export const getStepUpStatus = async (req: Request, res: Response) => { export const startWebAuthnStepUp = async (req: Request, res: Response) => { const authReq = req as AuthenticatedRequest; const user = authReq.user; + const { credentialId, prf } = req.body ?? {}; if (!user?.id) { await AuthEventService.log({ @@ -63,25 +82,33 @@ export const startWebAuthnStepUp = async (req: Request, res: Response) => { try { const credentials = await Credential.findAll({ where: { userId: user.id } }); - if (!credentials || credentials.length === 0) { + const assertionCredentials = filterAssertionCredentials(credentials ?? [], { + credentialId, + requiresPrf: Boolean(prf), + }); + + if (!assertionCredentials || assertionCredentials.length === 0) { await AuthEventService.log({ userId: user.id, type: 'step_up_failed', req, - metadata: { reason: 'No WebAuthn credentials' }, + metadata: { + reason: prf ? 'No PRF-capable WebAuthn credentials' : 'No WebAuthn credentials', + }, }); return res.status(401).json({ error: 'step_up_unavailable' }); } const { rpid } = await getSystemConfig(); const options: PublicKeyCredentialRequestOptionsJSON = await generateAuthenticationOptions({ - allowCredentials: credentials.map((credential) => ({ + allowCredentials: assertionCredentials.map((credential) => ({ id: credential.id, transports: credential.transports, })), userVerification: 'required', timeout: 60000, rpID: rpid, + extensions: buildPrfAuthenticationExtensions(prf), }); await user.update({ @@ -125,6 +152,16 @@ export const finishWebAuthnStepUp = async (req: Request, res: Response) => { const { assertionResponse } = req.body; const assertionId = assertionResponse?.id; + if (containsPrfOutput(assertionResponse)) { + await AuthEventService.log({ + userId: user.id, + type: 'step_up_failed', + req, + metadata: { reason: 'PRF output was sent to the server' }, + }); + return res.status(400).json({ error: 'prf_output_not_allowed' }); + } + if (!user.challenge || typeof assertionId !== 'string') { await AuthEventService.log({ userId: user.id, diff --git a/src/controllers/user.ts b/src/controllers/user.ts index 5d82157..8ddc060 100644 --- a/src/controllers/user.ts +++ b/src/controllers/user.ts @@ -31,6 +31,7 @@ export const getUser = async (req: Request, res: Response) => { 'deviceType', 'backedup', 'counter', + 'prfCapable', 'friendlyName', 'lastUsedAt', 'platform', diff --git a/src/controllers/webauthn.ts b/src/controllers/webauthn.ts index 2e9c638..de327c3 100644 --- a/src/controllers/webauthn.ts +++ b/src/controllers/webauthn.ts @@ -16,6 +16,12 @@ import base64url from 'base64url'; import { Request, Response } from 'express'; import { getSystemConfig } from '../config/getSystemConfig.js'; +import { + buildPrfAuthenticationExtensions, + buildPrfRegistrationExtensions, + containsPrfOutput, + getRegistrationPrfCapable, +} from '../lib/webauthnPrf.js'; import { AuthEvent } from '../models/authEvents.js'; import { Credential } from '../models/credentials.js'; import { User } from '../models/users.js'; @@ -28,10 +34,47 @@ import getLogger from '../utils/logger.js'; const logger = getLogger('webauthn'); const AUTH_MODE: 'web' | 'server' = process.env.AUTH_MODE! as 'web' | 'server'; +function getRegistrationChallengeContext(user: User) { + const webauthnRegistration = user.challengeContext?.webauthnRegistration; + + if (typeof webauthnRegistration !== 'object' || webauthnRegistration === null) { + return { prfRequested: false, requirePrf: false }; + } + + const context = webauthnRegistration as Record; + + return { + prfRequested: context.prfRequested === true, + requirePrf: context.requirePrf === true, + }; +} + +function filterAssertionCredentials( + credentials: Credential[], + options: { credentialId?: string; requiresPrf: boolean }, +) { + return credentials.filter((credential) => { + if (options.credentialId && credential.id !== options.credentialId) { + return false; + } + + if (options.requiresPrf && !credential.prfCapable) { + return false; + } + + return true; + }); +} + const registerWebAuthn = async (req: Request, res: Response) => { try { const authReq = req as AuthenticatedRequest; const verifiedUser = authReq.user; + const { requestPrf = false, requirePrf = false } = req.query as { + requestPrf?: boolean; + requirePrf?: boolean; + }; + const prfRequested = requestPrf || requirePrf; logger.info(`Registering passwordless mechanism for ${authReq.user?.email}`); if (!verifiedUser) { @@ -78,10 +121,17 @@ const registerWebAuthn = async (req: Request, res: Response) => { residentKey: 'preferred', authenticatorAttachment: 'platform', }, + extensions: buildPrfRegistrationExtensions(prfRequested), }); await verifiedUser.update({ challenge: options.challenge, + challengeContext: { + webauthnRegistration: { + prfRequested, + requirePrf, + }, + }, }); logger.info(`Generated registration options for user ${verifiedUser.email}`); @@ -111,7 +161,7 @@ const verifyWebAuthnRegistration = async (req: Request, res: Response) => { logger.info(`Verifiying registration of passwordless mechanism for ${authReq.user?.email}`); try { - const { attestationResponse, metadata } = req.body; + const { attestationResponse, metadata = {} } = req.body; if (!verifiedUser) { logger.warn(`Missing verification token ${req.body}`); @@ -202,6 +252,19 @@ const verifyWebAuthnRegistration = async (req: Request, res: Response) => { } const { credential, credentialBackedUp, credentialDeviceType } = registrationInfo; + const challengeContext = getRegistrationChallengeContext(user); + const prfCapable = + getRegistrationPrfCapable(attestationResponse) || metadata.prfCapable === true; + + if (challengeContext.requirePrf && !prfCapable) { + await AuthEventService.log({ + userId: user.id, + type: 'webauthn_registration_failed', + req, + metadata: { reason: 'PRF required but credential did not report PRF support' }, + }); + return res.status(403).json({ error: 'prf_required' }); + } // @ts-expect-error Ignoring for testing. const publicKey = base64url.encode(credential.publicKey); @@ -218,11 +281,13 @@ const verifyWebAuthnRegistration = async (req: Request, res: Response) => { platform: metadata.platform || null, browser: metadata.browser || null, deviceInfo: metadata.deviceInfo || null, + prfCapable, lastUsedAt: new Date(), }); await user.update({ challenge: null, + challengeContext: null, lastLogin: new Date(), verified: true, }); @@ -272,6 +337,7 @@ const verifyWebAuthnRegistration = async (req: Request, res: Response) => { const generateWebAuthn = async (req: Request, res: Response) => { const authReq = req as AuthenticatedRequest; const verifiedUser = authReq.user; + const { credentialId, prf } = req.body ?? {}; logger.info(`Generating passwordless login for ${verifiedUser.email}`); const email = verifiedUser.email; @@ -306,13 +372,18 @@ const generateWebAuthn = async (req: Request, res: Response) => { creds = await Credential.findAll({ where: { userId: user.id } }); try { - if (!creds || creds.length === 0) { + const assertionCredentials = filterAssertionCredentials(creds ?? [], { + credentialId, + requiresPrf: Boolean(prf), + }); + + if (!assertionCredentials || assertionCredentials.length === 0) { await AuthEvent.create({ user_id: user.id, type: 'login_failed', ip_address: req.ip, user_agent: req.headers['user-agent'], - metadata: { reason: 'No credentials' }, + metadata: { reason: prf ? 'No PRF-capable credentials' : 'No credentials' }, }); logger.error('Valid user with no credentials'); return res.status(401).send('Credentials not found'); @@ -321,7 +392,7 @@ const generateWebAuthn = async (req: Request, res: Response) => { const { rpid } = await getSystemConfig(); const options: PublicKeyCredentialRequestOptionsJSON = await generateAuthenticationOptions({ - allowCredentials: creds.map((cred) => { + allowCredentials: assertionCredentials.map((cred) => { return { id: cred.id, transports: cred.transports, @@ -330,6 +401,7 @@ const generateWebAuthn = async (req: Request, res: Response) => { userVerification: 'required', timeout: 60000, rpID: rpid, + extensions: buildPrfAuthenticationExtensions(prf), }); await user.update({ @@ -369,6 +441,16 @@ const verifyWebAuthn = async (req: Request, res: Response) => { try { const { assertionResponse } = req.body; + if (containsPrfOutput(assertionResponse)) { + await AuthEventService.log({ + userId: verifiedUser.id, + type: 'webauthn_login_failed', + req, + metadata: { reason: 'PRF output was sent to the server' }, + }); + return res.status(400).json({ error: 'prf_output_not_allowed' }); + } + const email = verifiedUser.email; const phone = verifiedUser.phone; let user = verifiedUser; diff --git a/src/lib/webauthnPrf.ts b/src/lib/webauthnPrf.ts new file mode 100644 index 0000000..fc28365 --- /dev/null +++ b/src/lib/webauthnPrf.ts @@ -0,0 +1,145 @@ +/* + * 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 type { AuthenticationExtensionsClientInputs } from '@simplewebauthn/server'; + +export const PRF_MIN_SALT_BYTES = 32; + +type PrfRequest = { + salt: string; + secondSalt?: string; +}; + +type PrfCredentialResult = { + id: string; + getClientExtensionResults: () => { + prf?: { + results?: { + first?: ArrayBuffer | ArrayBufferView; + second?: ArrayBuffer | ArrayBufferView; + }; + }; + }; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function toBase64Url(bytes: Uint8Array) { + return Buffer.from(bytes).toString('base64url'); +} + +function toUint8Array(value: ArrayBuffer | ArrayBufferView) { + if (value instanceof ArrayBuffer) { + return new Uint8Array(value); + } + + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); +} + +export function assertValidPrfSalt(salt: string) { + if (!/^[A-Za-z0-9_-]+$/.test(salt)) { + throw new Error('PRF salt must be base64url encoded'); + } + + const decoded = Buffer.from(salt, 'base64url'); + + if (decoded.length < PRF_MIN_SALT_BYTES) { + throw new Error(`PRF salt must decode to at least ${PRF_MIN_SALT_BYTES} bytes`); + } +} + +export function buildPrfRegistrationExtensions( + requestPrf: boolean, +): AuthenticationExtensionsClientInputs | undefined { + if (!requestPrf) { + return undefined; + } + + return { + prf: {}, + } as unknown as AuthenticationExtensionsClientInputs; +} + +export function buildPrfAuthenticationExtensions( + request?: PrfRequest, +): AuthenticationExtensionsClientInputs | undefined { + if (!request) { + return undefined; + } + + assertValidPrfSalt(request.salt); + + if (request.secondSalt) { + assertValidPrfSalt(request.secondSalt); + } + + return { + prf: { + eval: { + first: request.salt, + ...(request.secondSalt ? { second: request.secondSalt } : {}), + }, + }, + } as unknown as AuthenticationExtensionsClientInputs; +} + +export function getRegistrationPrfCapable(attestationResponse: unknown) { + if (!isRecord(attestationResponse)) { + return false; + } + + const extensionResults = attestationResponse.clientExtensionResults; + + if (!isRecord(extensionResults)) { + return false; + } + + const prf = extensionResults.prf; + + if (!isRecord(prf)) { + return false; + } + + return prf.enabled === true; +} + +export function containsPrfOutput(credentialResponse: unknown) { + if (!isRecord(credentialResponse)) { + return false; + } + + const extensionResults = credentialResponse.clientExtensionResults; + + if (!isRecord(extensionResults)) { + return false; + } + + const prf = extensionResults.prf; + + if (!isRecord(prf)) { + return false; + } + + return isRecord(prf.results); +} + +export function extractPasskeyPrfResult(credential: PrfCredentialResult) { + const first = credential.getClientExtensionResults().prf?.results?.first; + + if (!first) { + return null; + } + + const output = toUint8Array(first); + + return { + credentialId: credential.id, + output, + outputBase64url: toBase64Url(output), + }; +} diff --git a/src/migrations/20260516190000-add-webauthn-prf-metadata.cjs b/src/migrations/20260516190000-add-webauthn-prf-metadata.cjs new file mode 100644 index 0000000..fc6bff8 --- /dev/null +++ b/src/migrations/20260516190000-add-webauthn-prf-metadata.cjs @@ -0,0 +1,33 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + */ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query(` + ALTER TABLE public.credentials + ADD COLUMN IF NOT EXISTS "prfCapable" boolean DEFAULT false NOT NULL; + + ALTER TABLE public.users + ADD COLUMN IF NOT EXISTS challenge_context jsonb; + + CREATE INDEX IF NOT EXISTS idx_credentials_prf_capable + ON public.credentials USING btree ("prfCapable"); + `); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query(` + DROP INDEX IF EXISTS idx_credentials_prf_capable; + + ALTER TABLE public.users + DROP COLUMN IF EXISTS challenge_context; + + ALTER TABLE public.credentials + DROP COLUMN IF EXISTS "prfCapable"; + `); + }, +}; diff --git a/src/models/credentials.ts b/src/models/credentials.ts index 7ced346..b09d9de 100644 --- a/src/models/credentials.ts +++ b/src/models/credentials.ts @@ -17,6 +17,7 @@ export class Credential extends Model { declare transports?: AuthenticatorTransportFuture[]; declare deviceType: CredentialDeviceType; declare backedup: boolean; + declare prfCapable: boolean; declare friendlyName: string | null; declare lastUsedAt: Date | null; @@ -65,6 +66,11 @@ export default (sequelize: Sequelize) => { allowNull: false, defaultValue: false, }, + prfCapable: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, deviceType: { type: DataTypes.STRING, allowNull: true, diff --git a/src/models/users.ts b/src/models/users.ts index c3f2d6d..6dc6508 100644 --- a/src/models/users.ts +++ b/src/models/users.ts @@ -23,6 +23,7 @@ export interface UserAttributes { phoneVerified?: boolean; verified?: boolean; challenge?: string | null; + challengeContext?: Record | null; lastLogin?: Date; createdAt?: Date; updatedAt?: Date; @@ -43,6 +44,7 @@ export class User extends Model implements UserAttributes { declare phoneVerified: boolean; declare verified: boolean; declare challenge: string | null; + declare challengeContext: Record | null; declare roles?: string[]; declare lastLogin?: Date; declare readonly createdAt: Date; @@ -134,6 +136,10 @@ const initializeUserModel = (sequelize: Sequelize) => { type: DataTypes.STRING, allowNull: true, }, + challengeContext: { + type: DataTypes.JSON, + allowNull: true, + }, lastLogin: { type: DataTypes.DATE, allowNull: true, diff --git a/src/routes/stepUp.routes.ts b/src/routes/stepUp.routes.ts index 9ed4200..7798ab6 100644 --- a/src/routes/stepUp.routes.ts +++ b/src/routes/stepUp.routes.ts @@ -12,7 +12,10 @@ import { import { createRouter } from '../lib/createRouter.js'; import { ErrorSchema, InternalErrorSchema } from '../schemas/generic.responses.js'; import { StepUpStatusSchema, StepUpSuccessSchema } from '../schemas/stepUp.responses.js'; -import { WebAuthnLoginFinishSchema } from '../schemas/webauthn.requests.js'; +import { + WebAuthnAssertionStartSchema, + WebAuthnLoginFinishSchema, +} from '../schemas/webauthn.requests.js'; import { WebAuthnChallengeSchema } from '../schemas/webauthn.responses.js'; const stepUpRouter = createRouter('/step-up'); @@ -42,6 +45,8 @@ stepUpRouter.post( tags: ['Step-Up'], schemas: { + body: WebAuthnAssertionStartSchema, + response: { 200: WebAuthnChallengeSchema, 401: ErrorSchema, @@ -64,6 +69,7 @@ stepUpRouter.post( response: { 200: StepUpSuccessSchema, + 400: ErrorSchema, 401: ErrorSchema, 500: InternalErrorSchema, }, diff --git a/src/routes/webauthn.routes.ts b/src/routes/webauthn.routes.ts index 37f7464..bc6bf7e 100644 --- a/src/routes/webauthn.routes.ts +++ b/src/routes/webauthn.routes.ts @@ -13,8 +13,10 @@ import { import { createRouter } from '../lib/createRouter.js'; import { ErrorSchema, InternalErrorSchema } from '../schemas/generic.responses.js'; import { + WebAuthnAssertionStartSchema, WebAuthnLoginFinishSchema, WebAuthnRegisterFinishSchema, + WebAuthnRegisterStartQuerySchema, } from '../schemas/webauthn.requests.js'; import { WebAuthnChallengeSchema, @@ -31,6 +33,8 @@ webauthnRouter.get( tags: ['WebAuthn'], schemas: { + query: WebAuthnRegisterStartQuerySchema, + response: { 200: WebAuthnChallengeSchema, 403: ErrorSchema, @@ -69,6 +73,8 @@ webauthnRouter.post( tags: ['WebAuthn'], schemas: { + body: WebAuthnAssertionStartSchema, + response: { 200: WebAuthnChallengeSchema, 401: ErrorSchema, @@ -92,6 +98,7 @@ webauthnRouter.post( response: { 200: WebAuthnTokenSuccessSchema, + 400: ErrorSchema, 401: ErrorSchema, 403: ErrorSchema, 500: InternalErrorSchema, diff --git a/src/schemas/me.response.ts b/src/schemas/me.response.ts index 8397276..893db0d 100644 --- a/src/schemas/me.response.ts +++ b/src/schemas/me.response.ts @@ -7,6 +7,10 @@ import { CredentialApiSchema, UserSchema } from '@seamless-auth/types'; import { z } from 'zod'; +const CredentialWithPrfSchema = CredentialApiSchema.extend({ + prfCapable: z.boolean().optional(), +}); + export const MeResponseSchema = z.object({ user: UserSchema.pick({ id: true, @@ -15,5 +19,5 @@ export const MeResponseSchema = z.object({ roles: true, lastLogin: true, }), - credentials: z.array(CredentialApiSchema), + credentials: z.array(CredentialWithPrfSchema), }); diff --git a/src/schemas/webauthn.requests.ts b/src/schemas/webauthn.requests.ts index 4939a7e..8abf788 100644 --- a/src/schemas/webauthn.requests.ts +++ b/src/schemas/webauthn.requests.ts @@ -6,6 +6,52 @@ import { z } from 'zod'; +import { assertValidPrfSalt } from '../lib/webauthnPrf.js'; + +const BooleanQuerySchema = z.preprocess((value) => { + if (value === 'true') return true; + if (value === 'false') return false; + return value; +}, z.boolean().optional()); + +export const WebAuthnPrfRequestSchema = z.object({ + salt: z.string().superRefine((value, ctx) => { + try { + assertValidPrfSalt(value); + } catch (error) { + ctx.addIssue({ + code: 'custom', + message: error instanceof Error ? error.message : 'Invalid PRF salt', + }); + } + }), + secondSalt: z + .string() + .superRefine((value, ctx) => { + try { + assertValidPrfSalt(value); + } catch (error) { + ctx.addIssue({ + code: 'custom', + message: error instanceof Error ? error.message : 'Invalid PRF salt', + }); + } + }) + .optional(), +}); + +export const WebAuthnRegisterStartQuerySchema = z.object({ + requestPrf: BooleanQuerySchema, + requirePrf: BooleanQuerySchema, +}); + +export const WebAuthnAssertionStartSchema = z + .object({ + credentialId: z.string().optional(), + prf: WebAuthnPrfRequestSchema.optional(), + }) + .default({}); + export const WebAuthnRegisterFinishSchema = z.object({ attestationResponse: z.record(z.string(), z.unknown()), @@ -15,6 +61,7 @@ export const WebAuthnRegisterFinishSchema = z.object({ platform: z.string().optional(), browser: z.string().optional(), deviceInfo: z.string().optional(), + prfCapable: z.boolean().optional(), }) .optional(), }); diff --git a/tests/factories/credentialFactory.ts b/tests/factories/credentialFactory.ts index 0025951..f1637a1 100644 --- a/tests/factories/credentialFactory.ts +++ b/tests/factories/credentialFactory.ts @@ -8,6 +8,8 @@ export function buildCredential(overrides: any = {}) { transports: [], deviceType: 'platform', backedup: false, + backedUp: false, + prfCapable: false, counter: 0, lastUsedAt: new Date(), platform: 'web', diff --git a/tests/factories/userFactory.ts b/tests/factories/userFactory.ts index f2bc379..f0de78e 100644 --- a/tests/factories/userFactory.ts +++ b/tests/factories/userFactory.ts @@ -9,6 +9,7 @@ export function buildUser(overrides: Partial = {}) { phone: '+14155552671', roles: ['user'], challenge: 'challenge', + challengeContext: null, createdAt: Date.now(), emailVerified: true, phoneVerified: true, diff --git a/tests/integration/webauthn/webauthn.spec.ts b/tests/integration/webauthn/webauthn.spec.ts index 7254d2e..db64364 100644 --- a/tests/integration/webauthn/webauthn.spec.ts +++ b/tests/integration/webauthn/webauthn.spec.ts @@ -14,6 +14,10 @@ import { generateRefreshToken, hashRefreshToken, signAccessToken } from '../../. let app: Application; +function prfSalt(byte = 1) { + return Buffer.alloc(32, byte).toString('base64url'); +} + vi.mock('../../../src/middleware/attachAuthMiddleware.js', async (importOriginal) => { const actual = await importOriginal(); @@ -55,6 +59,29 @@ describe('GET /webauthn/register/start', () => { expect(res.status).toBe(200); expect(res.body.challenge).toBeDefined(); }); + + it('adds PRF creation extension when requested', async () => { + (Credential.findAll as any).mockResolvedValue([]); + (getSystemConfig as any).mockResolvedValue({ + app_name: 'SeamlessAuth', + rpid: 'localhost', + }); + + const { generateRegistrationOptions } = await import('@simplewebauthn/server'); + + (generateRegistrationOptions as any).mockResolvedValue({ + challenge: 'challenge', + }); + + const res = await request(app).get('/webauthn/register/start').query({ requirePrf: 'true' }); + + expect(res.status).toBe(200); + expect(generateRegistrationOptions).toHaveBeenCalledWith( + expect.objectContaining({ + extensions: { prf: {} }, + }), + ); + }); }); describe('POST /webauthn/register/finish', () => { @@ -85,12 +112,66 @@ describe('POST /webauthn/register/finish', () => { (Credential.create as any).mockResolvedValue({}); (Session.create as any).mockResolvedValue({ id: 'session-1' }); - const res = await request(app).post('/webauthn/register/finish').send({ - attestationResponse: {}, - metadata: {}, - }); + const res = await request(app) + .post('/webauthn/register/finish') + .send({ + attestationResponse: { + clientExtensionResults: { + prf: { enabled: true }, + }, + }, + metadata: {}, + }); expect(res.status).toBe(200); + expect(Credential.create).toHaveBeenCalledWith( + expect.objectContaining({ + prfCapable: true, + }), + ); + }); + + it('rejects PRF-required registration when credential is not PRF-capable', async () => { + const user = buildUser({ + challengeContext: { + webauthnRegistration: { + prfRequested: true, + requirePrf: true, + }, + }, + }); + + (User.findOne as any).mockResolvedValue(user); + const { verifyRegistrationResponse } = await import('@simplewebauthn/server'); + + (verifyRegistrationResponse as any).mockResolvedValue({ + verified: true, + registrationInfo: { + credential: { + id: 'cred-1', + publicKey: Buffer.from('key'), + counter: 0, + transports: [], + }, + credentialBackedUp: false, + credentialDeviceType: 'platform', + }, + }); + + const res = await request(app) + .post('/webauthn/register/finish') + .send({ + attestationResponse: { + clientExtensionResults: { + prf: { enabled: false }, + }, + }, + metadata: {}, + }); + + expect(res.status).toBe(403); + expect(res.body).toEqual({ error: 'prf_required' }); + expect(Credential.create).not.toHaveBeenCalled(); }); }); @@ -116,6 +197,42 @@ describe('POST /webauthn/login/start', () => { expect(res.status).toBe(200); expect(res.body.challenge).toBeDefined(); + expect(generateAuthenticationOptions).toHaveBeenCalledWith( + expect.objectContaining({ + extensions: undefined, + }), + ); + }); + + it('adds PRF assertion extension and filters to PRF-capable credentials', async () => { + (Credential.findAll as any).mockResolvedValue([ + buildCredential({ id: 'regular-cred', prfCapable: false }), + buildCredential({ id: 'prf-cred', prfCapable: true }), + ]); + + const { generateAuthenticationOptions } = await import('@simplewebauthn/server'); + + (generateAuthenticationOptions as any).mockResolvedValue({ + challenge: 'challenge', + }); + + const res = await request(app) + .post('/webauthn/login/start') + .send({ prf: { salt: prfSalt() } }); + + expect(res.status).toBe(200); + expect(generateAuthenticationOptions).toHaveBeenCalledWith( + expect.objectContaining({ + allowCredentials: [{ id: 'prf-cred', transports: [] }], + extensions: { + prf: { + eval: { + first: prfSalt(), + }, + }, + }, + }), + ); }); }); @@ -158,4 +275,24 @@ describe('POST /webauthn/login/finish', () => { expect(res.status).toBe(200); }); + + it('rejects assertion responses that include PRF output', async () => { + const res = await request(app) + .post('/webauthn/login/finish') + .send({ + assertionResponse: { + id: 'cred-1', + clientExtensionResults: { + prf: { + results: { + first: 'must-not-reach-server', + }, + }, + }, + }, + }); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'prf_output_not_allowed' }); + }); }); diff --git a/tests/setup/mocks.ts b/tests/setup/mocks.ts index c765446..f5dee9b 100644 --- a/tests/setup/mocks.ts +++ b/tests/setup/mocks.ts @@ -94,6 +94,8 @@ vi.mock('../../src/middleware/attachAuthMiddleware.js', async (importOriginal) = email: 'test@example.com', phone: '+14155552671', roles: ['user'], + challenge: 'challenge', + challengeContext: null, // required for verification flows emailVerificationToken: '123456', diff --git a/tests/unit/controllers/stepUp.spec.ts b/tests/unit/controllers/stepUp.spec.ts index 546979b..0de16a6 100644 --- a/tests/unit/controllers/stepUp.spec.ts +++ b/tests/unit/controllers/stepUp.spec.ts @@ -8,6 +8,10 @@ import { buildCredential } from '../../factories/credentialFactory.js'; import { buildSession } from '../../factories/sessionFactory.js'; import { buildUser } from '../../factories/userFactory.js'; +function prfSalt(byte = 1) { + return Buffer.alloc(32, byte).toString('base64url'); +} + function buildReq(overrides: Record = {}) { return { body: {}, @@ -56,6 +60,40 @@ describe('step-up controller', () => { expect(res.json).toHaveBeenCalledWith({ challenge: 'challenge' }); }); + it('starts a PRF-capable WebAuthn step-up challenge with caller salt', async () => { + const user = buildUser(); + const credential = buildCredential({ id: 'prf-cred', userId: user.id, prfCapable: true }); + (Credential.findAll as any).mockResolvedValue([ + buildCredential({ id: 'regular-cred', userId: user.id, prfCapable: false }), + credential, + ]); + (getSystemConfig as any).mockResolvedValue({ rpid: 'localhost' }); + + const { generateAuthenticationOptions } = await import('@simplewebauthn/server'); + (generateAuthenticationOptions as any).mockResolvedValue({ challenge: 'challenge' }); + + const req = buildReq({ + user, + body: { prf: { salt: prfSalt() } }, + }); + const res = buildRes(); + + await startWebAuthnStepUp(req, res); + + expect(generateAuthenticationOptions).toHaveBeenCalledWith( + expect.objectContaining({ + allowCredentials: [{ id: credential.id, transports: credential.transports }], + extensions: { + prf: { + eval: { + first: prfSalt(), + }, + }, + }, + }), + ); + }); + it('finishes WebAuthn step-up and records freshness on the current session', async () => { const user = buildUser({ challenge: 'challenge' }); const credential = buildCredential({ id: 'cred-1', userId: user.id }); @@ -129,4 +167,30 @@ describe('step-up controller', () => { expect(res.status).toHaveBeenCalledWith(401); expect(res.json).toHaveBeenCalledWith({ error: 'step_up_failed' }); }); + + it('rejects WebAuthn step-up responses that include PRF output', async () => { + const user = buildUser({ challenge: 'challenge' }); + const req = buildReq({ + user, + body: { + assertionResponse: { + id: 'cred-1', + clientExtensionResults: { + prf: { + results: { + first: 'must-not-reach-server', + }, + }, + }, + }, + }, + }); + const res = buildRes(); + + await finishWebAuthnStepUp(req, res); + + expect(Credential.findOne).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: 'prf_output_not_allowed' }); + }); }); diff --git a/tests/unit/lib/webauthnPrf.spec.ts b/tests/unit/lib/webauthnPrf.spec.ts new file mode 100644 index 0000000..533f24f --- /dev/null +++ b/tests/unit/lib/webauthnPrf.spec.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; + +import { + assertValidPrfSalt, + buildPrfAuthenticationExtensions, + buildPrfRegistrationExtensions, + containsPrfOutput, + extractPasskeyPrfResult, + getRegistrationPrfCapable, +} from '../../../src/lib/webauthnPrf.js'; + +function salt(byte = 1) { + return Buffer.alloc(32, byte).toString('base64url'); +} + +describe('webauthnPrf', () => { + it('builds no extension for normal registration', () => { + expect(buildPrfRegistrationExtensions(false)).toBeUndefined(); + }); + + it('builds PRF registration extension when requested', () => { + expect(buildPrfRegistrationExtensions(true)).toEqual({ prf: {} }); + }); + + it('builds PRF assertion extension with base64url salts', () => { + expect(buildPrfAuthenticationExtensions({ salt: salt(), secondSalt: salt(2) })).toEqual({ + prf: { + eval: { + first: salt(), + second: salt(2), + }, + }, + }); + }); + + it('rejects salts that are not base64url or are too short', () => { + expect(() => assertValidPrfSalt('not valid!')).toThrow('base64url'); + expect(() => assertValidPrfSalt(Buffer.alloc(16).toString('base64url'))).toThrow( + 'at least 32 bytes', + ); + }); + + it('detects PRF capability and forbidden PRF output in client extension results', () => { + expect( + getRegistrationPrfCapable({ + clientExtensionResults: { prf: { enabled: true } }, + }), + ).toBe(true); + + expect( + containsPrfOutput({ + clientExtensionResults: { prf: { results: { first: 'secret-output' } } }, + }), + ).toBe(true); + }); + + it('extracts browser PRF output without needing to send it to the API', () => { + const output = Uint8Array.from([1, 2, 3, 4]); + const result = extractPasskeyPrfResult({ + id: 'cred-1', + getClientExtensionResults: () => ({ + prf: { + results: { + first: output.buffer, + }, + }, + }), + }); + + expect(result?.credentialId).toBe('cred-1'); + expect(Array.from(result?.output ?? [])).toEqual([1, 2, 3, 4]); + expect(result?.outputBase64url).toBe('AQIDBA'); + }); +});