From 67792dfa42634e6a6a3fac92de68535c8a9a5cb3 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Fri, 15 May 2026 11:02:42 -0400 Subject: [PATCH 1/2] feat: first pass at step up authentication --- resources/coverage-badge.svg | 8 +- src/controllers/stepUp.ts | 224 ++++++++++++++++++ src/middleware/requireStepUp.ts | 50 ++++ ...60515150000-add-step-up-session-fields.cjs | 29 +++ src/models/sessions.ts | 21 +- src/routes/stepUp.routes.ts | 75 ++++++ src/schemas/authEvent.types.ts | 4 + src/schemas/stepUp.responses.ts | 24 ++ src/services/stepUpService.ts | 119 ++++++++++ tests/integration/stepUp/stepUp.spec.ts | 39 +++ tests/unit/controllers/stepUp.spec.ts | 132 +++++++++++ tests/unit/middleware/requireStepUp.spec.ts | 64 +++++ tests/unit/services/stepUpService.spec.ts | 84 +++++++ 13 files changed, 868 insertions(+), 5 deletions(-) create mode 100644 src/controllers/stepUp.ts create mode 100644 src/middleware/requireStepUp.ts create mode 100644 src/migrations/20260515150000-add-step-up-session-fields.cjs create mode 100644 src/routes/stepUp.routes.ts create mode 100644 src/schemas/stepUp.responses.ts create mode 100644 src/services/stepUpService.ts create mode 100644 tests/integration/stepUp/stepUp.spec.ts create mode 100644 tests/unit/controllers/stepUp.spec.ts create mode 100644 tests/unit/middleware/requireStepUp.spec.ts create mode 100644 tests/unit/services/stepUpService.spec.ts diff --git a/resources/coverage-badge.svg b/resources/coverage-badge.svg index 4396890..c178fd7 100644 --- a/resources/coverage-badge.svg +++ b/resources/coverage-badge.svg @@ -1,5 +1,5 @@ - - coverage: 82.9% + + coverage: 82.7% @@ -17,7 +17,7 @@ coverage coverage - 82.9% - 82.9% + 82.7% + 82.7% diff --git a/src/controllers/stepUp.ts b/src/controllers/stepUp.ts new file mode 100644 index 0000000..a981f0f --- /dev/null +++ b/src/controllers/stepUp.ts @@ -0,0 +1,224 @@ +/* + * 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 { + AuthenticatorTransportFuture, + generateAuthenticationOptions, + PublicKeyCredentialRequestOptionsJSON, + verifyAuthenticationResponse, +} from '@simplewebauthn/server'; +import base64url from 'base64url'; +import { Request, Response } from 'express'; + +import { getSystemConfig } from '../config/getSystemConfig.js'; +import { Credential } from '../models/credentials.js'; +import { AuthEventService } from '../services/authEventService.js'; +import { + getSessionStepUpStatus, + recordStepUpVerification, + serializeStepUpStatus, +} from '../services/stepUpService.js'; +import { AuthenticatedRequest } from '../types/types.js'; +import getLogger from '../utils/logger.js'; + +const logger = getLogger('step-up'); + +export const getStepUpStatus = async (req: Request, res: Response) => { + const authReq = req as AuthenticatedRequest; + const user = authReq.user; + + if (!user?.id || !authReq.sessionId) { + return res.status(401).json({ error: 'unauthorized' }); + } + + const status = await getSessionStepUpStatus({ + sessionId: authReq.sessionId, + userId: user.id, + }); + + if (!status.sessionFound) { + return res.status(401).json({ error: 'unauthorized' }); + } + + return res.json(serializeStepUpStatus(status)); +}; + +export const startWebAuthnStepUp = async (req: Request, res: Response) => { + const authReq = req as AuthenticatedRequest; + const user = authReq.user; + + if (!user?.id) { + await AuthEventService.log({ + userId: null, + type: 'step_up_suspicious', + req, + metadata: { reason: 'No authenticated user' }, + }); + return res.status(401).json({ error: 'unauthorized' }); + } + + try { + const credentials = await Credential.findAll({ where: { userId: user.id } }); + + if (!credentials || credentials.length === 0) { + await AuthEventService.log({ + userId: user.id, + type: 'step_up_failed', + req, + metadata: { reason: '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) => ({ + id: credential.id, + transports: credential.transports, + })), + userVerification: 'required', + timeout: 60000, + rpID: rpid, + }); + + await user.update({ + challenge: options.challenge, + }); + + await AuthEventService.log({ + userId: user.id, + type: 'step_up_challenge', + req, + metadata: { method: 'webauthn' }, + }); + + return res.json(options); + } catch (error) { + logger.error(`Failed to start WebAuthn step-up: ${error}`); + await AuthEventService.log({ + userId: user.id, + type: 'step_up_failed', + req, + metadata: { reason: 'Failed to generate WebAuthn options' }, + }); + return res.status(500).json({ error: 'Internal server error' }); + } +}; + +export const finishWebAuthnStepUp = async (req: Request, res: Response) => { + const authReq = req as AuthenticatedRequest; + const user = authReq.user; + + if (!user?.id || !authReq.sessionId) { + await AuthEventService.log({ + userId: null, + type: 'step_up_suspicious', + req, + metadata: { reason: 'No authenticated user or session' }, + }); + return res.status(401).json({ error: 'unauthorized' }); + } + + const { assertionResponse } = req.body; + const assertionId = assertionResponse?.id; + + if (!user.challenge || typeof assertionId !== 'string') { + await AuthEventService.log({ + userId: user.id, + type: 'step_up_failed', + req, + metadata: { reason: 'Missing challenge or assertion id' }, + }); + return res.status(401).json({ error: 'step_up_failed' }); + } + + const credential = await Credential.findOne({ + where: { userId: user.id, id: assertionId }, + }); + + if (!credential) { + await AuthEventService.log({ + userId: user.id, + type: 'step_up_failed', + req, + metadata: { reason: 'Credential not found' }, + }); + return res.status(401).json({ error: 'step_up_failed' }); + } + + const expectedChallenge = user.challenge; + await user.update({ challenge: null }); + + try { + const { origins, rpid } = await getSystemConfig(); + const verification = await verifyAuthenticationResponse({ + response: assertionResponse, + expectedChallenge, + expectedOrigin: origins, + expectedRPID: rpid, + credential: { + id: credential.id, + // @ts-expect-error SimpleWebAuthn expects a Uint8Array-compatible public key here. + publicKey: base64url.toBuffer(credential.publicKey), + counter: credential.counter, + transports: credential.transports as AuthenticatorTransportFuture[], + }, + }); + + if (!verification.verified) { + await AuthEventService.log({ + userId: user.id, + type: 'step_up_failed', + req, + metadata: { reason: 'Verification failed' }, + }); + return res.status(401).json({ error: 'step_up_failed' }); + } + + await credential.update({ + lastUsedAt: new Date(), + counter: verification.authenticationInfo.newCounter, + }); + + const status = await recordStepUpVerification({ + sessionId: authReq.sessionId, + userId: user.id, + method: 'webauthn', + }); + + if (!status) { + await AuthEventService.log({ + userId: user.id, + type: 'step_up_failed', + req, + metadata: { reason: 'Session not found' }, + }); + return res.status(401).json({ error: 'unauthorized' }); + } + + await AuthEventService.log({ + userId: user.id, + type: 'step_up_success', + req, + metadata: { method: 'webauthn' }, + }); + + return res.json({ + message: 'Success', + ...serializeStepUpStatus(status), + method: 'webauthn', + }); + } catch (error) { + logger.error(`Failed to finish WebAuthn step-up: ${error}`); + await AuthEventService.log({ + userId: user.id, + type: 'step_up_failed', + req, + metadata: { reason: 'Verification error' }, + }); + return res.status(401).json({ error: 'step_up_failed' }); + } +}; diff --git a/src/middleware/requireStepUp.ts b/src/middleware/requireStepUp.ts new file mode 100644 index 0000000..6d95c47 --- /dev/null +++ b/src/middleware/requireStepUp.ts @@ -0,0 +1,50 @@ +/* + * 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 { NextFunction, Request, Response } from 'express'; + +import { + DEFAULT_STEP_UP_MAX_AGE_SECONDS, + getSessionStepUpStatus, + serializeStepUpStatus, +} from '../services/stepUpService.js'; +import { AuthenticatedRequest } from '../types/types.js'; + +export function requireStepUp( + options: { maxAgeSeconds?: number } = {}, +): (req: Request, res: Response, next: NextFunction) => Promise { + const maxAgeSeconds = options.maxAgeSeconds ?? DEFAULT_STEP_UP_MAX_AGE_SECONDS; + + return async (req, res, next) => { + const authReq = req as AuthenticatedRequest; + const user = authReq.user; + const sessionId = authReq.sessionId; + + if (!user?.id || !sessionId) { + return res.status(401).json({ error: 'unauthorized' }); + } + + const status = await getSessionStepUpStatus({ + sessionId, + userId: user.id, + maxAgeSeconds, + }); + + if (!status.sessionFound) { + return res.status(401).json({ error: 'unauthorized' }); + } + + if (!status.fresh) { + return res.status(403).json({ + error: 'step_up_required', + message: 'Recent step-up authentication is required', + ...serializeStepUpStatus(status), + }); + } + + return next(); + }; +} diff --git a/src/migrations/20260515150000-add-step-up-session-fields.cjs b/src/migrations/20260515150000-add-step-up-session-fields.cjs new file mode 100644 index 0000000..67db0f1 --- /dev/null +++ b/src/migrations/20260515150000-add-step-up-session-fields.cjs @@ -0,0 +1,29 @@ +/* + * 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.sessions + ADD COLUMN IF NOT EXISTS "stepUpVerifiedAt" timestamp with time zone, + ADD COLUMN IF NOT EXISTS "stepUpMethod" character varying(255); + + CREATE INDEX IF NOT EXISTS idx_sessions_step_up_verified_at + ON public.sessions USING btree ("stepUpVerifiedAt"); + `); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query(` + DROP INDEX IF EXISTS idx_sessions_step_up_verified_at; + + ALTER TABLE public.sessions + DROP COLUMN IF EXISTS "stepUpMethod", + DROP COLUMN IF EXISTS "stepUpVerifiedAt"; + `); + }, +}; diff --git a/src/models/sessions.ts b/src/models/sessions.ts index afc4192..379444e 100644 --- a/src/models/sessions.ts +++ b/src/models/sessions.ts @@ -19,6 +19,8 @@ export interface SessionAttributes { lastUsedAt: Date; expiresAt: Date; idleExpiresAt: Date; + stepUpVerifiedAt?: Date | null; + stepUpMethod?: string | null; replacedBySessionId?: string | null; revokedAt?: Date | null; revokedReason?: string | null; @@ -28,7 +30,14 @@ export interface SessionAttributes { type SessionCreationAttributes = Optional< SessionAttributes, - 'id' | 'replacedBySessionId' | 'revokedAt' | 'revokedReason' | 'deviceName' | 'lastUsedAt' + | 'id' + | 'replacedBySessionId' + | 'revokedAt' + | 'revokedReason' + | 'deviceName' + | 'lastUsedAt' + | 'stepUpVerifiedAt' + | 'stepUpMethod' >; export class Session @@ -47,6 +56,8 @@ export class Session declare lastUsedAt: Date; declare expiresAt: Date; declare idleExpiresAt: Date; + declare stepUpVerifiedAt: Date | null; + declare stepUpMethod: string | null; declare replacedBySessionId: string | null; declare revokedAt: Date | null; declare revokedReason: string | null; @@ -101,6 +112,14 @@ const initializeSessionModel = (sequelize: Sequelize) => { type: DataTypes.DATE, allowNull: false, }, + stepUpVerifiedAt: { + type: DataTypes.DATE, + allowNull: true, + }, + stepUpMethod: { + type: DataTypes.STRING, + allowNull: true, + }, replacedBySessionId: { type: DataTypes.UUID, allowNull: true, diff --git a/src/routes/stepUp.routes.ts b/src/routes/stepUp.routes.ts new file mode 100644 index 0000000..9ed4200 --- /dev/null +++ b/src/routes/stepUp.routes.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 { + finishWebAuthnStepUp, + getStepUpStatus, + startWebAuthnStepUp, +} from '../controllers/stepUp.js'; +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 { WebAuthnChallengeSchema } from '../schemas/webauthn.responses.js'; + +const stepUpRouter = createRouter('/step-up'); + +stepUpRouter.get( + '/status', + { + auth: 'access', + summary: 'Get current session step-up status', + tags: ['Step-Up'], + + schemas: { + response: { + 200: StepUpStatusSchema, + 401: ErrorSchema, + }, + }, + }, + getStepUpStatus, +); + +stepUpRouter.post( + '/webauthn/start', + { + auth: 'access', + summary: 'Start WebAuthn step-up authentication', + tags: ['Step-Up'], + + schemas: { + response: { + 200: WebAuthnChallengeSchema, + 401: ErrorSchema, + 500: InternalErrorSchema, + }, + }, + }, + startWebAuthnStepUp, +); + +stepUpRouter.post( + '/webauthn/finish', + { + auth: 'access', + summary: 'Finish WebAuthn step-up authentication', + tags: ['Step-Up'], + + schemas: { + body: WebAuthnLoginFinishSchema, + + response: { + 200: StepUpSuccessSchema, + 401: ErrorSchema, + 500: InternalErrorSchema, + }, + }, + }, + finishWebAuthnStepUp, +); + +export default stepUpRouter.router; diff --git a/src/schemas/authEvent.types.ts b/src/schemas/authEvent.types.ts index 7b0080d..291ff6d 100644 --- a/src/schemas/authEvent.types.ts +++ b/src/schemas/authEvent.types.ts @@ -54,6 +54,10 @@ export const AUTH_EVENT_TYPES = [ 'service_token_rotated', 'service_token_success', 'service_token_suspicious', + 'step_up_challenge', + 'step_up_failed', + 'step_up_success', + 'step_up_suspicious', 'system_config_error', 'system_config_read', 'system_config_updated', diff --git a/src/schemas/stepUp.responses.ts b/src/schemas/stepUp.responses.ts new file mode 100644 index 0000000..039480d --- /dev/null +++ b/src/schemas/stepUp.responses.ts @@ -0,0 +1,24 @@ +/* + * 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 { z } from 'zod'; + +export const StepUpStatusSchema = z.object({ + fresh: z.boolean(), + method: z.literal('webauthn').nullable(), + verifiedAt: z.string().nullable(), + expiresAt: z.string().nullable(), + maxAgeSeconds: z.number(), +}); + +export const StepUpSuccessSchema = z.object({ + message: z.string(), + fresh: z.boolean(), + method: z.literal('webauthn'), + verifiedAt: z.string(), + expiresAt: z.string(), + maxAgeSeconds: z.number(), +}); diff --git a/src/services/stepUpService.ts b/src/services/stepUpService.ts new file mode 100644 index 0000000..2602e7b --- /dev/null +++ b/src/services/stepUpService.ts @@ -0,0 +1,119 @@ +/* + * 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 { Session } from '../models/sessions.js'; + +export const DEFAULT_STEP_UP_MAX_AGE_SECONDS = 5 * 60; + +export type StepUpMethod = 'webauthn'; + +export interface StepUpStatus { + sessionFound: boolean; + fresh: boolean; + method: StepUpMethod | null; + verifiedAt: Date | null; + expiresAt: Date | null; + maxAgeSeconds: number; +} + +interface StepUpSessionFields { + stepUpVerifiedAt?: Date | null; + stepUpMethod?: StepUpMethod | string | null; +} + +function expiresAt(verifiedAt: Date, maxAgeSeconds: number) { + return new Date(verifiedAt.getTime() + maxAgeSeconds * 1000); +} + +export function getStepUpStatusFromSession( + session: StepUpSessionFields | null, + maxAgeSeconds = DEFAULT_STEP_UP_MAX_AGE_SECONDS, + now = new Date(), +): StepUpStatus { + if (!session) { + return { + sessionFound: false, + fresh: false, + method: null, + verifiedAt: null, + expiresAt: null, + maxAgeSeconds, + }; + } + + const verifiedAt = session.stepUpVerifiedAt ?? null; + const method = session.stepUpMethod === 'webauthn' ? session.stepUpMethod : null; + const stepUpExpiresAt = verifiedAt ? expiresAt(verifiedAt, maxAgeSeconds) : null; + + return { + sessionFound: true, + fresh: Boolean(verifiedAt && stepUpExpiresAt && stepUpExpiresAt.getTime() > now.getTime()), + method, + verifiedAt, + expiresAt: stepUpExpiresAt, + maxAgeSeconds, + }; +} + +async function findCurrentSession(sessionId: string, userId: string) { + return Session.findOne({ + where: { + id: sessionId, + userId, + revokedAt: null, + }, + }); +} + +export async function getSessionStepUpStatus({ + sessionId, + userId, + maxAgeSeconds = DEFAULT_STEP_UP_MAX_AGE_SECONDS, +}: { + sessionId: string; + userId: string; + maxAgeSeconds?: number; +}) { + const session = await findCurrentSession(sessionId, userId); + + return getStepUpStatusFromSession(session, maxAgeSeconds); +} + +export async function recordStepUpVerification({ + sessionId, + userId, + method, + verifiedAt = new Date(), + maxAgeSeconds = DEFAULT_STEP_UP_MAX_AGE_SECONDS, +}: { + sessionId: string; + userId: string; + method: StepUpMethod; + verifiedAt?: Date; + maxAgeSeconds?: number; +}) { + const session = await findCurrentSession(sessionId, userId); + + if (!session) { + return null; + } + + session.stepUpVerifiedAt = verifiedAt; + session.stepUpMethod = method; + await session.save(); + + return getStepUpStatusFromSession(session, maxAgeSeconds, verifiedAt); +} + +export function serializeStepUpStatus(status: StepUpStatus) { + return { + fresh: status.fresh, + method: status.method, + verifiedAt: status.verifiedAt?.toISOString() ?? null, + expiresAt: status.expiresAt?.toISOString() ?? null, + maxAgeSeconds: status.maxAgeSeconds, + }; +} diff --git a/tests/integration/stepUp/stepUp.spec.ts b/tests/integration/stepUp/stepUp.spec.ts new file mode 100644 index 0000000..4938af6 --- /dev/null +++ b/tests/integration/stepUp/stepUp.spec.ts @@ -0,0 +1,39 @@ +import { Application } from 'express'; +import request from 'supertest'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createApp } from '../../../src/app.js'; +import { Session } from '../../../src/models/sessions.js'; +import { buildSession } from '../../factories/sessionFactory.js'; + +let app: Application; + +beforeAll(async () => { + app = await createApp(); +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('GET /step-up/status', () => { + it('returns freshness for the current session', async () => { + (Session.findOne as any).mockResolvedValue( + buildSession({ + stepUpVerifiedAt: new Date(), + stepUpMethod: 'webauthn', + }), + ); + + const res = await request(app).get('/step-up/status'); + + expect(res.status).toBe(200); + expect(res.body).toEqual( + expect.objectContaining({ + fresh: true, + method: 'webauthn', + maxAgeSeconds: 300, + }), + ); + }); +}); diff --git a/tests/unit/controllers/stepUp.spec.ts b/tests/unit/controllers/stepUp.spec.ts new file mode 100644 index 0000000..546979b --- /dev/null +++ b/tests/unit/controllers/stepUp.spec.ts @@ -0,0 +1,132 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getSystemConfig } from '../../../src/config/getSystemConfig.js'; +import { finishWebAuthnStepUp, startWebAuthnStepUp } from '../../../src/controllers/stepUp.js'; +import { Credential } from '../../../src/models/credentials.js'; +import { Session } from '../../../src/models/sessions.js'; +import { buildCredential } from '../../factories/credentialFactory.js'; +import { buildSession } from '../../factories/sessionFactory.js'; +import { buildUser } from '../../factories/userFactory.js'; + +function buildReq(overrides: Record = {}) { + return { + body: {}, + headers: {}, + ip: '127.0.0.1', + user: buildUser(), + sessionId: 'session-1', + ...overrides, + } as any; +} + +function buildRes() { + const res: any = {}; + res.status = vi.fn().mockReturnValue(res); + res.json = vi.fn().mockReturnValue(res); + return res; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('step-up controller', () => { + it('starts a WebAuthn step-up challenge for authenticated users with credentials', async () => { + const user = buildUser(); + const credential = buildCredential({ userId: user.id }); + (Credential.findAll as any).mockResolvedValue([credential]); + (getSystemConfig as any).mockResolvedValue({ rpid: 'localhost' }); + + const { generateAuthenticationOptions } = await import('@simplewebauthn/server'); + (generateAuthenticationOptions as any).mockResolvedValue({ challenge: 'challenge' }); + + const req = buildReq({ user }); + const res = buildRes(); + + await startWebAuthnStepUp(req, res); + + expect(generateAuthenticationOptions).toHaveBeenCalledWith( + expect.objectContaining({ + allowCredentials: [{ id: credential.id, transports: credential.transports }], + userVerification: 'required', + rpID: 'localhost', + }), + ); + expect(user.update).toHaveBeenCalledWith({ challenge: 'challenge' }); + expect(res.json).toHaveBeenCalledWith({ challenge: 'challenge' }); + }); + + 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 }); + const session = buildSession({ stepUpVerifiedAt: null, stepUpMethod: null }); + + (Credential.findOne as any).mockResolvedValue(credential); + (Session.findOne as any).mockResolvedValue(session); + (getSystemConfig as any).mockResolvedValue({ + origins: ['http://localhost:5137'], + rpid: 'localhost', + }); + + const { verifyAuthenticationResponse } = await import('@simplewebauthn/server'); + (verifyAuthenticationResponse as any).mockResolvedValue({ + verified: true, + authenticationInfo: { newCounter: 2 }, + }); + + const req = buildReq({ + user, + body: { assertionResponse: { id: 'cred-1' } }, + }); + const res = buildRes(); + + await finishWebAuthnStepUp(req, res); + + expect(user.update).toHaveBeenCalledWith({ challenge: null }); + expect(credential.update).toHaveBeenCalledWith( + expect.objectContaining({ + counter: 2, + }), + ); + expect(session.stepUpVerifiedAt).toBeInstanceOf(Date); + expect(session.stepUpMethod).toBe('webauthn'); + expect(session.save).toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Success', + fresh: true, + method: 'webauthn', + }), + ); + }); + + it('does not update session freshness when verification fails', async () => { + const user = buildUser({ challenge: 'challenge' }); + const credential = buildCredential({ id: 'cred-1', userId: user.id }); + + (Credential.findOne as any).mockResolvedValue(credential); + (getSystemConfig as any).mockResolvedValue({ + origins: ['http://localhost:5137'], + rpid: 'localhost', + }); + + const { verifyAuthenticationResponse } = await import('@simplewebauthn/server'); + (verifyAuthenticationResponse as any).mockResolvedValue({ + verified: false, + authenticationInfo: { newCounter: 2 }, + }); + + const req = buildReq({ + user, + body: { assertionResponse: { id: 'cred-1' } }, + }); + const res = buildRes(); + + await finishWebAuthnStepUp(req, res); + + expect(Session.findOne).not.toHaveBeenCalled(); + expect(credential.update).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'step_up_failed' }); + }); +}); diff --git a/tests/unit/middleware/requireStepUp.spec.ts b/tests/unit/middleware/requireStepUp.spec.ts new file mode 100644 index 0000000..ed59886 --- /dev/null +++ b/tests/unit/middleware/requireStepUp.spec.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { requireStepUp } from '../../../src/middleware/requireStepUp.js'; +import { Session } from '../../../src/models/sessions.js'; +import { buildSession } from '../../factories/sessionFactory.js'; + +function buildReq(overrides: Record = {}) { + return { + user: { id: 'user-1' }, + sessionId: 'session-1', + ...overrides, + } as any; +} + +function buildRes() { + const res: any = {}; + res.status = vi.fn().mockReturnValue(res); + res.json = vi.fn().mockReturnValue(res); + return res; +} + +describe('requireStepUp', () => { + it('calls next when the current session has fresh step-up verification', async () => { + (Session.findOne as any).mockResolvedValue( + buildSession({ + stepUpVerifiedAt: new Date(), + stepUpMethod: 'webauthn', + }), + ); + + const req = buildReq(); + const res = buildRes(); + const next = vi.fn(); + + await requireStepUp()(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('returns step_up_required when step-up verification is stale', async () => { + (Session.findOne as any).mockResolvedValue( + buildSession({ + stepUpVerifiedAt: new Date(Date.now() - 10 * 60 * 1000), + stepUpMethod: 'webauthn', + }), + ); + + const req = buildReq(); + const res = buildRes(); + const next = vi.fn(); + + await requireStepUp()(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + error: 'step_up_required', + fresh: false, + }), + ); + }); +}); diff --git a/tests/unit/services/stepUpService.spec.ts b/tests/unit/services/stepUpService.spec.ts new file mode 100644 index 0000000..fa8f4bc --- /dev/null +++ b/tests/unit/services/stepUpService.spec.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { Session } from '../../../src/models/sessions.js'; +import { + DEFAULT_STEP_UP_MAX_AGE_SECONDS, + getSessionStepUpStatus, + getStepUpStatusFromSession, + recordStepUpVerification, +} from '../../../src/services/stepUpService.js'; +import { buildSession } from '../../factories/sessionFactory.js'; + +describe('stepUpService', () => { + it('marks sessions without step-up as not fresh', () => { + const status = getStepUpStatusFromSession( + buildSession({ stepUpVerifiedAt: null, stepUpMethod: null }), + ); + + expect(status.sessionFound).toBe(true); + expect(status.fresh).toBe(false); + expect(status.verifiedAt).toBeNull(); + expect(status.expiresAt).toBeNull(); + }); + + it('marks recent step-up as fresh', () => { + const verifiedAt = new Date('2026-05-15T12:00:00.000Z'); + const status = getStepUpStatusFromSession( + buildSession({ stepUpVerifiedAt: verifiedAt, stepUpMethod: 'webauthn' }), + DEFAULT_STEP_UP_MAX_AGE_SECONDS, + new Date('2026-05-15T12:02:00.000Z'), + ); + + expect(status.fresh).toBe(true); + expect(status.method).toBe('webauthn'); + expect(status.expiresAt?.toISOString()).toBe('2026-05-15T12:05:00.000Z'); + }); + + it('marks expired step-up as stale', () => { + const verifiedAt = new Date('2026-05-15T12:00:00.000Z'); + const status = getStepUpStatusFromSession( + buildSession({ stepUpVerifiedAt: verifiedAt, stepUpMethod: 'webauthn' }), + DEFAULT_STEP_UP_MAX_AGE_SECONDS, + new Date('2026-05-15T12:06:00.000Z'), + ); + + expect(status.fresh).toBe(false); + }); + + it('records step-up verification on the current session', async () => { + const session = buildSession({ stepUpVerifiedAt: null, stepUpMethod: null }); + (Session.findOne as any).mockResolvedValue(session); + + const verifiedAt = new Date('2026-05-15T12:00:00.000Z'); + const status = await recordStepUpVerification({ + sessionId: 'session-1', + userId: 'user-1', + method: 'webauthn', + verifiedAt, + }); + + expect(Session.findOne).toHaveBeenCalledWith({ + where: { + id: 'session-1', + userId: 'user-1', + revokedAt: null, + }, + }); + expect(session.stepUpVerifiedAt).toBe(verifiedAt); + expect(session.stepUpMethod).toBe('webauthn'); + expect(session.save).toHaveBeenCalled(); + expect(status?.fresh).toBe(true); + }); + + it('returns a missing status when the current session is not found', async () => { + (Session.findOne as any).mockResolvedValue(null); + + const status = await getSessionStepUpStatus({ + sessionId: 'missing-session', + userId: 'user-1', + }); + + expect(status.sessionFound).toBe(false); + expect(status.fresh).toBe(false); + }); +}); From 04ba462798011edc224c8f797a5d7c00150ed7fb Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 16 May 2026 11:32:38 -0400 Subject: [PATCH 2/2] feat: add session to refresh service and process for step up auth --- src/controllers/authentication.ts | 1 + src/middleware/verifyBearerAuth.ts | 9 ++++++--- src/services/sessionService.ts | 14 ++++++++++++-- tests/unit/controllers/authentication.spec.ts | 1 + tests/unit/middleware/verifyBearerAuth.spec.ts | 6 +++++- tests/unit/services/sessionService.spec.ts | 9 +++++++-- 6 files changed, 32 insertions(+), 8 deletions(-) diff --git a/src/controllers/authentication.ts b/src/controllers/authentication.ts index f4e7c69..49f5464 100644 --- a/src/controllers/authentication.ts +++ b/src/controllers/authentication.ts @@ -363,6 +363,7 @@ export const refreshSession = async (req: Request, res: Response) => { token, refreshToken: newRefreshToken, sub: user.id, + sessionId: newSession.id, roles: user.roles, email: user.email, phone: user.phone, diff --git a/src/middleware/verifyBearerAuth.ts b/src/middleware/verifyBearerAuth.ts index 6b7c2a0..fae0c92 100644 --- a/src/middleware/verifyBearerAuth.ts +++ b/src/middleware/verifyBearerAuth.ts @@ -21,12 +21,15 @@ export async function verifyBearerAuth(req: Request, res: Response, next: NextFu const token = auth.slice(7); try { - const user = await validateBearerToken(token); - if (!user) { + const result = await validateBearerToken(token); + if (!result) { logger.error('No user found for service bearer token'); return res.status(401).json({ error: 'unauthorized' }); } - (req as AuthenticatedRequest).user = user; + (req as AuthenticatedRequest).user = result.user; + if (result.sessionId !== undefined) { + (req as AuthenticatedRequest).sessionId = result.sessionId; + } next(); } catch (err) { console.error('verifyBearerAuth failed:', err); diff --git a/src/services/sessionService.ts b/src/services/sessionService.ts index 017579c..1ed6e99 100644 --- a/src/services/sessionService.ts +++ b/src/services/sessionService.ts @@ -205,6 +205,11 @@ export async function getUserFromSession(session: Session) { return user ?? null; } +export interface ValidatedBearerToken { + user: User; + sessionId?: string; +} + export async function validateBearerToken(token: string) { const serviceSecret = await getInternalSecret(); let payload; @@ -223,9 +228,14 @@ export async function validateBearerToken(token: string) { return null; } + if (typeof payload === 'string' || typeof payload.sub !== 'string') { + return null; + } + + const sessionId = typeof payload.sid === 'string' ? payload.sid : undefined; const user = await User.findOne({ - where: { id: payload.sub as string, revoked: false }, + where: { id: payload.sub, revoked: false }, }); - return user ?? null; + return user ? { user, sessionId } : null; } diff --git a/tests/unit/controllers/authentication.spec.ts b/tests/unit/controllers/authentication.spec.ts index d00acd3..f2c45d2 100644 --- a/tests/unit/controllers/authentication.spec.ts +++ b/tests/unit/controllers/authentication.spec.ts @@ -174,6 +174,7 @@ describe('refreshSession', () => { refreshToken: 'new-raw-refresh-token', sub: user.id, roles: user.roles, + sessionId: 'session-2', email: user.email, phone: user.phone, ttl: 900, diff --git a/tests/unit/middleware/verifyBearerAuth.spec.ts b/tests/unit/middleware/verifyBearerAuth.spec.ts index ea21aa2..edeaf7c 100644 --- a/tests/unit/middleware/verifyBearerAuth.spec.ts +++ b/tests/unit/middleware/verifyBearerAuth.spec.ts @@ -65,11 +65,15 @@ describe('verifyBearerAuth', () => { const mockUser = { id: 'user-1' }; - (validateBearerToken as any).mockResolvedValue(mockUser); + (validateBearerToken as any).mockResolvedValue({ + user: mockUser, + sessionId: 'session-1', + }); await verifyBearerAuth(req, res, next); expect(req.user).toEqual(mockUser); + expect(req.sessionId).toBe('session-1'); expect(next).toHaveBeenCalled(); }); diff --git a/tests/unit/services/sessionService.spec.ts b/tests/unit/services/sessionService.spec.ts index 21a48d2..6c2e940 100644 --- a/tests/unit/services/sessionService.spec.ts +++ b/tests/unit/services/sessionService.spec.ts @@ -325,15 +325,20 @@ describe('sessionService', () => { (jwt.default.verify as any).mockReturnValue({ sub: 'user', + sid: 'session-1', }); - (User.findOne as any).mockResolvedValue({ id: 'user' }); + const user = { id: 'user' }; + (User.findOne as any).mockResolvedValue(user); const { validateBearerToken } = await import('../../../src/services/sessionService'); const result = await validateBearerToken('token'); - expect(result).toBeTruthy(); + expect(result).toEqual({ + user, + sessionId: 'session-1', + }); }); it('returns null if jwt fails', async () => {