Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions resources/coverage-badge.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/controllers/authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
224 changes: 224 additions & 0 deletions src/controllers/stepUp.ts
Original file line number Diff line number Diff line change
@@ -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' });
}
};
50 changes: 50 additions & 0 deletions src/middleware/requireStepUp.ts
Original file line number Diff line number Diff line change
@@ -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<void | Response> {
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();
};
}
9 changes: 6 additions & 3 deletions src/middleware/verifyBearerAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
29 changes: 29 additions & 0 deletions src/migrations/20260515150000-add-step-up-session-fields.cjs
Original file line number Diff line number Diff line change
@@ -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";
`);
},
};
21 changes: 20 additions & 1 deletion src/models/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading