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
28 changes: 28 additions & 0 deletions .changeset/brave-hoops-turn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'seamless-auth-api': minor
---

Give WebAuthn challenges their own store, with an expiry and one-time use.

Challenges lived in a single `users.challenge` column shared by registration,
login and step-up. Three consequences, all fixed here:

- **Flows clobbered each other.** Starting a login invalidated a registration
already in flight for the same user, and a second tab invalidated the first.
Challenges are now keyed by user and flow, so registration, login and step-up
can be outstanding at once.
- **Nothing expired.** The column had no lifetime, so a challenge stayed valid
until some later flow happened to overwrite it. The `timeout` in the credential
options is only a hint to the browser and was never enforced. Challenges now
expire server side after five minutes, comfortably longer than that hint so no
legitimate ceremony is cut short.
- **A challenge could outlive its ceremony.** It is now spent when verification
reads it, before anything else can fail, so an attempt that fails leaves
nothing redeemable behind.

A magic link completing also spends any half-finished WebAuthn ceremony for that
user, preserving what the old defensive clear did.

`users.challenge` and `users.challengeContext` are no longer read or written.
They are left in place so this release can be rolled back, and should be dropped
in a follow-up once it has run in production.
7 changes: 5 additions & 2 deletions src/controllers/magicLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { AuthEventService } from '../services/authEventService.js';
import { getLoginPolicy, isLoginMethodEnabled } from '../services/loginPolicyService.js';
import { sendMagicLinkEmail } from '../services/messagingService.js';
import { issueSessionAndRespond } from '../services/sessionIssuance.js';
import { invalidateChallengesForUser } from '../services/webauthnChallengeService.js';
import { AuthenticatedRequest } from '../types/types.js';
import getLogger from '../utils/logger.js';
import { hashDeviceFingerprint, hashSha256 } from '../utils/utils.js';
Expand Down Expand Up @@ -253,11 +254,14 @@ export async function pollMagicLinkConfirmation(req: Request, res: Response) {
req,
});

user.challenge = '';
user.verified = true;

await user.save();

// A different route completed the sign-in, so any half-finished WebAuthn
// ceremony for this user should not still be redeemable.
await invalidateChallengesForUser(user.id);

await AuthEventService.log({
userId: user.id,
type: 'registration_success',
Expand All @@ -278,7 +282,6 @@ export async function pollMagicLinkConfirmation(req: Request, res: Response) {

await user.update({
lastLogin: new Date(),
challengeContext: null,
});

return;
Expand Down
14 changes: 10 additions & 4 deletions src/controllers/stepUp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
recordStepUpVerification,
serializeStepUpStatus,
} from '../services/stepUpService.js';
import { consumeChallenge, issueChallenge } from '../services/webauthnChallengeService.js';
import { AuthenticatedRequest } from '../types/types.js';
import getLogger from '../utils/logger.js';

Expand Down Expand Up @@ -111,7 +112,9 @@ export const startWebAuthnStepUp = async (req: Request, res: Response) => {
extensions: buildPrfAuthenticationExtensions(prf),
});

await user.update({
await issueChallenge({
userId: user.id,
purpose: 'step_up',
challenge: options.challenge,
});

Expand Down Expand Up @@ -162,7 +165,11 @@ export const finishWebAuthnStepUp = async (req: Request, res: Response) => {
return res.status(400).json({ error: 'prf_output_not_allowed' });
}

if (!user.challenge || typeof assertionId !== 'string') {
// Consumed before the credential lookup, so every exit below leaves the
// challenge spent rather than live.
const issued = await consumeChallenge({ userId: user.id, purpose: 'step_up' });

if (!issued || typeof assertionId !== 'string') {
await AuthEventService.log({
userId: user.id,
type: 'step_up_failed',
Expand All @@ -186,8 +193,7 @@ export const finishWebAuthnStepUp = async (req: Request, res: Response) => {
return res.status(401).json({ error: 'step_up_failed' });
}

const expectedChallenge = user.challenge;
await user.update({ challenge: null });
const expectedChallenge = issued.challenge;

try {
const { origins, rpid } = await getSystemConfig();
Expand Down
45 changes: 24 additions & 21 deletions src/controllers/webauthn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,19 +28,16 @@ import type { WebAuthnAuthenticatorAttachment } from '../schemas/webauthn.reques
import { AuthEventService } from '../services/authEventService.js';
import { rejectIfUserLocked } from '../services/lockoutPolicyService.js';
import { issueSessionAndRespond } from '../services/sessionIssuance.js';
import { consumeChallenge, issueChallenge } from '../services/webauthnChallengeService.js';
import { AuthenticatedRequest } from '../types/types.js';
import getLogger from '../utils/logger.js';

const logger = getLogger('webauthn');
function getRegistrationChallengeContext(user: User) {
const webauthnRegistration = user.challengeContext?.webauthnRegistration;

if (typeof webauthnRegistration !== 'object' || webauthnRegistration === null) {
function getRegistrationChallengeContext(context: Record<string, unknown> | null | undefined) {
if (!context) {
return { prfRequested: false, requirePrf: false };
}

const context = webauthnRegistration as Record<string, unknown>;

return {
prfRequested: context.prfRequested === true,
requirePrf: context.requirePrf === true,
Expand Down Expand Up @@ -152,15 +149,11 @@ const registerWebAuthn = async (req: Request, res: Response) => {
extensions: buildPrfRegistrationExtensions(prfRequested),
});

await verifiedUser.update({
await issueChallenge({
userId: verifiedUser.id,
purpose: 'registration',
challenge: options.challenge,
challengeContext: {
...(verifiedUser.challengeContext ?? {}),
webauthnRegistration: {
prfRequested,
requirePrf,
},
},
context: { prfRequested, requirePrf },
});

logger.info('Generated registration options for user');
Expand Down Expand Up @@ -231,7 +224,11 @@ const verifyWebAuthnRegistration = async (req: Request, res: Response) => {
return res.status(403).json({ error: 'Not allowed' });
}

const expectedChallenge = user.challenge;
// Consumed before verification, so the challenge is spent however this
// attempt turns out and a failure cannot leave one live to replay against.
const issued = await consumeChallenge({ userId: user.id, purpose: 'registration' });
const expectedChallenge = issued?.challenge;

if (!expectedChallenge) {
logger.error('Unexpected user challegnge supplied.');
await AuthEventService.log({
Expand Down Expand Up @@ -278,7 +275,7 @@ const verifyWebAuthnRegistration = async (req: Request, res: Response) => {
}

const { credential, credentialBackedUp, credentialDeviceType } = registrationInfo;
const challengeContext = getRegistrationChallengeContext(user);
const challengeContext = getRegistrationChallengeContext(issued?.context);
const prfCapable =
getRegistrationPrfCapable(attestationResponse) || metadata.prfCapable === true;

Expand Down Expand Up @@ -312,8 +309,6 @@ const verifyWebAuthnRegistration = async (req: Request, res: Response) => {
});

await user.update({
challenge: null,
challengeContext: null,
lastLogin: new Date(),
verified: true,
});
Expand Down Expand Up @@ -403,7 +398,9 @@ const generateWebAuthn = async (req: Request, res: Response) => {
extensions: buildPrfAuthenticationExtensions(prf),
});

await user.update({
await issueChallenge({
userId: user.id,
purpose: 'authentication',
challenge: options.challenge,
});

Expand Down Expand Up @@ -466,7 +463,13 @@ const verifyWebAuthn = async (req: Request, res: Response) => {
return res.status(403).json({ error: 'Not allowed' });
}

if (!user || !user.challenge) {
// Consumed before anything else can fail, so this path cannot leave a live
// challenge behind for an assertion to be replayed against.
const issued = user
? await consumeChallenge({ userId: user.id, purpose: 'authentication' })
: null;

if (!user || !issued) {
logger.error('User or user challenge missing');
await AuthEventService.log({
userId: user.id,
Expand Down Expand Up @@ -495,7 +498,7 @@ const verifyWebAuthn = async (req: Request, res: Response) => {
return res.status(401).json({ error: 'Authentication failed.' });
}

const expectedChallenge = user.challenge;
const expectedChallenge = issued.challenge;
let verification;

try {
Expand Down
74 changes: 74 additions & 0 deletions src/migrations/20260829040000-create-webauthn-challenges.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
'use strict';

/**
* Moves WebAuthn challenges off the single `users.challenge` column.
*
* That column was shared by registration, login and step-up, so two flows for
* one user clobbered each other, and it had no expiry: a challenge stayed valid
* until some later flow happened to overwrite it.
*
* @type {import('sequelize-cli').Migration}
*/
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('webauthn_challenges', {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.literal('gen_random_uuid()'),
primaryKey: true,
},
user_id: {
type: Sequelize.UUID,
allowNull: false,
references: { model: 'users', key: 'id' },
onDelete: 'CASCADE',
},
purpose: {
type: Sequelize.STRING,
allowNull: false,
},
challenge: {
type: Sequelize.STRING,
allowNull: false,
},
// Flow state that used to live in users.challengeContext, for example
// whether the registration asked for PRF.
context: {
type: Sequelize.JSONB,
allowNull: true,
},
expires_at: {
type: Sequelize.DATE,
allowNull: false,
},
consumed_at: {
type: Sequelize.DATE,
allowNull: true,
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.fn('NOW'),
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.fn('NOW'),
},
});

// Every lookup is "the live challenge for this user and this flow".
await queryInterface.addIndex('webauthn_challenges', ['user_id', 'purpose'], {
name: 'webauthn_challenges_user_purpose_idx',
});

// Supports reaping expired rows without scanning the table.
await queryInterface.addIndex('webauthn_challenges', ['expires_at'], {
name: 'webauthn_challenges_expires_at_idx',
});
},

async down(queryInterface) {
await queryInterface.dropTable('webauthn_challenges');
},
};
97 changes: 97 additions & 0 deletions src/models/webauthnChallenges.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/*
* 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 { DataTypes, Model, Optional, Sequelize } from 'sequelize';

export type WebAuthnChallengePurpose = 'registration' | 'authentication' | 'step_up';

export interface WebAuthnChallengeAttributes {
id: string;
userId: string;
purpose: WebAuthnChallengePurpose;
challenge: string;
context?: Record<string, unknown> | null;
expiresAt: Date;
consumedAt?: Date | null;
createdAt?: Date;
updatedAt?: Date;
}

type WebAuthnChallengeCreationAttributes = Optional<
WebAuthnChallengeAttributes,
'id' | 'context' | 'consumedAt' | 'createdAt' | 'updatedAt'
>;

export class WebAuthnChallenge
extends Model<WebAuthnChallengeAttributes, WebAuthnChallengeCreationAttributes>
implements WebAuthnChallengeAttributes
{
declare id: string;
declare userId: string;
declare purpose: WebAuthnChallengePurpose;
declare challenge: string;
declare context: Record<string, unknown> | null;
declare expiresAt: Date;
declare consumedAt: Date | null;
declare readonly createdAt: Date;
declare readonly updatedAt: Date;

// eslint-disable-next-line @typescript-eslint/no-explicit-any
static associate(models: any) {
WebAuthnChallenge.belongsTo(models.User, {
foreignKey: 'userId',
onDelete: 'CASCADE',
as: 'user',
});
}
}

const initializeWebAuthnChallengeModel = (sequelize: Sequelize) => {
WebAuthnChallenge.init(
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
userId: {
type: DataTypes.UUID,
allowNull: false,
},
purpose: {
type: DataTypes.STRING,
allowNull: false,
},
challenge: {
type: DataTypes.STRING,
allowNull: false,
},
context: {
type: DataTypes.JSONB,
allowNull: true,
},
expiresAt: {
type: DataTypes.DATE,
allowNull: false,
},
consumedAt: {
type: DataTypes.DATE,
allowNull: true,
},
},
{
sequelize,
modelName: 'WebAuthnChallenge',
tableName: 'webauthn_challenges',
underscored: true,
timestamps: true,
},
);

return WebAuthnChallenge;
};

export default initializeWebAuthnChallengeModel;
Loading
Loading