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: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

```
Expand Down
14 changes: 7 additions & 7 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.
43 changes: 40 additions & 3 deletions src/controllers/stepUp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand All @@ -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({
Expand All @@ -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({
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/controllers/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export const getUser = async (req: Request, res: Response) => {
'deviceType',
'backedup',
'counter',
'prfCapable',
'friendlyName',
'lastUsedAt',
'platform',
Expand Down
90 changes: 86 additions & 4 deletions src/controllers/webauthn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<string, unknown>;

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) {
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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);
Expand All @@ -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,
});
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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');
Expand All @@ -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,
Expand All @@ -330,6 +401,7 @@ const generateWebAuthn = async (req: Request, res: Response) => {
userVerification: 'required',
timeout: 60000,
rpID: rpid,
extensions: buildPrfAuthenticationExtensions(prf),
});

await user.update({
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading