augmenttoolkit / server /src /services /passkeyService.ts
Leon4gr45's picture
Upload folder using huggingface_hub (part 5)
57a889c verified
Raw
History Blame Contribute Delete
15.7 kB
import bcrypt from 'bcryptjs';
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
type AuthenticatorTransportFuture,
} from '@simplewebauthn/server';
import { db } from '../db/database';
import { resolveWebauthnConfig } from './webauthnConfig';
import { generateToken, stripUserForClient, avatarUrl } from './authService';
import type { User } from '../types';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
// Short single-use challenge lifetime — a ceremony is a few seconds of user
// interaction. Kept tight so a stray row can't be replayed and the table can't
// accumulate. Mirrors the spirit of the OIDC state TTL.
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
// Pinned COSE algorithms: EdDSA (-8), ES256 (-7), RS256 (-257). We never want a
// future library default to silently widen what we accept.
const SUPPORTED_ALGORITHM_IDS = [-8, -7, -257];
const NOT_CONFIGURED = { error: 'Passkey login is not configured for this server.', status: 400 } as const;
// One generic message for every authentication failure so the endpoint can't be
// used to tell "no such credential" apart from "bad signature" (CWE-203).
const AUTH_FAILED = { error: 'Authentication failed', status: 401 } as const;
interface CredentialRow {
id: number;
user_id: number;
credential_id: string;
public_key: Buffer;
counter: number;
transports: string | null;
device_type: string | null;
backed_up: number;
name: string | null;
aaguid: string | null;
created_at: string;
last_used_at: string | null;
}
// ---------------------------------------------------------------------------
// Challenge store (DB-backed, single-use, TTL'd)
// ---------------------------------------------------------------------------
function purgeExpiredChallenges(now: number): void {
db.prepare('DELETE FROM webauthn_challenges WHERE expires_at < ?').run(now);
}
function storeChallenge(challenge: string, userId: number | null, type: 'registration' | 'authentication', now: number): void {
db.prepare('INSERT INTO webauthn_challenges (challenge, user_id, type, expires_at) VALUES (?, ?, ?, ?)')
.run(challenge, userId, type, now + CHALLENGE_TTL_MS);
}
/**
* Atomically claim a challenge by its EXACT bytes + type. This is a single
* DELETE ... RETURNING statement that runs BEFORE any async verification, so a
* concurrent double-submit of the same assertion can never spend one challenge
* twice (the replay window a SELECT→await→DELETE ordering would open).
*/
function claimChallenge(challenge: string, type: 'registration' | 'authentication', now: number): { user_id: number | null } | null {
const row = db.prepare(
'DELETE FROM webauthn_challenges WHERE challenge = ? AND type = ? AND expires_at > ? RETURNING user_id',
).get(challenge, type, now) as { user_id: number | null } | undefined;
return row ?? null;
}
/** Decode the challenge the authenticator echoed back inside clientDataJSON. */
function challengeFromResponse(resp: unknown): string | null {
try {
const cdj = (resp as { response?: { clientDataJSON?: unknown } })?.response?.clientDataJSON;
if (typeof cdj !== 'string') return null;
const parsed = JSON.parse(Buffer.from(cdj, 'base64url').toString('utf8')) as { challenge?: unknown };
return typeof parsed.challenge === 'string' ? parsed.challenge : null;
} catch {
return null;
}
}
function parseTransports(raw: string | null): AuthenticatorTransportFuture[] | undefined {
if (!raw) return undefined;
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as AuthenticatorTransportFuture[]) : undefined;
} catch {
return undefined;
}
}
function sanitizeName(raw: unknown): string | null {
if (typeof raw !== 'string') return null;
const trimmed = raw.trim().slice(0, 60);
return trimmed || null;
}
function defaultCredentialName(deviceType: string | undefined): string {
return deviceType === 'multiDevice' ? 'Passkey (synced)' : 'Passkey';
}
// ---------------------------------------------------------------------------
// Registration (authenticated — from Settings, password re-auth required)
// ---------------------------------------------------------------------------
export async function passkeyRegisterOptions(
userId: number,
password: string | undefined,
): Promise<{ error?: string; status?: number; options?: Awaited<ReturnType<typeof generateRegistrationOptions>> }> {
const cfg = resolveWebauthnConfig();
if (!cfg) return { ...NOT_CONFIGURED };
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId) as User | undefined;
if (!user) return { error: 'User not found', status: 404 };
// Re-authentication: a hijacked session must not be able to silently plant an
// attacker-controlled passkey. Require the current password (parity with the
// change-password / disable-MFA step-up).
if (!password || !user.password_hash || !bcrypt.compareSync(password, user.password_hash)) {
return { error: 'Incorrect password', status: 401 };
}
const existing = db.prepare('SELECT credential_id, transports FROM webauthn_credentials WHERE user_id = ?')
.all(userId) as { credential_id: string; transports: string | null }[];
const now = Date.now();
purgeExpiredChallenges(now);
const options = await generateRegistrationOptions({
rpName: cfg.rpName,
rpID: cfg.rpID,
userName: user.email,
userDisplayName: user.username,
userID: new TextEncoder().encode(String(user.id)),
attestationType: 'none',
// Stop the same authenticator from enrolling twice on this account.
excludeCredentials: existing.map((c) => ({ id: c.credential_id, transports: parseTransports(c.transports) })),
authenticatorSelection: { residentKey: 'preferred', userVerification: 'required' },
supportedAlgorithmIDs: SUPPORTED_ALGORITHM_IDS,
});
storeChallenge(options.challenge, userId, 'registration', now);
return { options };
}
export async function passkeyRegisterVerify(
userId: number,
body: { attestationResponse?: unknown; name?: unknown },
): Promise<{ error?: string; status?: number; success?: boolean; credential?: unknown }> {
const cfg = resolveWebauthnConfig();
if (!cfg) return { ...NOT_CONFIGURED };
const resp = body?.attestationResponse;
if (!resp) return { error: 'Invalid registration response', status: 400 };
const challenge = challengeFromResponse(resp);
if (!challenge) return { error: 'Invalid registration response', status: 400 };
const now = Date.now();
const claimed = claimChallenge(challenge, 'registration', now);
if (!claimed || claimed.user_id !== userId) {
return { error: 'Registration challenge expired. Please try again.', status: 400 };
}
let verification;
try {
verification = await verifyRegistrationResponse({
response: resp as Parameters<typeof verifyRegistrationResponse>[0]['response'],
expectedChallenge: challenge,
expectedOrigin: cfg.origins,
expectedRPID: cfg.rpID,
requireUserVerification: true,
});
} catch {
return { error: 'Could not register this passkey.', status: 400 };
}
if (!verification.verified || !verification.registrationInfo) {
return { error: 'Could not register this passkey.', status: 400 };
}
// Persist ONLY the values the verifier vouches for — never anything parsed
// from the raw client payload.
const { credential, credentialDeviceType, credentialBackedUp, aaguid } = verification.registrationInfo;
if (db.prepare('SELECT id FROM webauthn_credentials WHERE credential_id = ?').get(credential.id)) {
return { error: 'This passkey is already registered.', status: 409 };
}
const name = sanitizeName(body?.name) || defaultCredentialName(credentialDeviceType);
try {
db.prepare(
`INSERT INTO webauthn_credentials
(user_id, credential_id, public_key, counter, transports, device_type, backed_up, name, aaguid, last_used_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)`,
).run(
userId,
credential.id,
Buffer.from(credential.publicKey),
credential.counter ?? 0,
credential.transports ? JSON.stringify(credential.transports) : null,
credentialDeviceType ?? null,
credentialBackedUp ? 1 : 0,
name,
aaguid ?? null,
);
} catch {
return { error: 'Could not register this passkey.', status: 400 };
}
const created = db.prepare(
'SELECT id, name, device_type, backed_up, created_at, last_used_at FROM webauthn_credentials WHERE credential_id = ?',
).get(credential.id) as { backed_up: number } & Record<string, unknown>;
return { success: true, credential: { ...created, backed_up: created.backed_up === 1 } };
}
// ---------------------------------------------------------------------------
// Authentication (public — primary, discoverable-credential login)
// ---------------------------------------------------------------------------
export async function passkeyLoginOptions(): Promise<{
error?: string;
status?: number;
options?: Awaited<ReturnType<typeof generateAuthenticationOptions>>;
}> {
const cfg = resolveWebauthnConfig();
if (!cfg) return { ...NOT_CONFIGURED };
const now = Date.now();
purgeExpiredChallenges(now);
const options = await generateAuthenticationOptions({
rpID: cfg.rpID,
userVerification: 'required',
// Empty allowCredentials → discoverable flow. The server never echoes which
// accounts have passkeys, so the endpoint can't be used to enumerate users.
});
storeChallenge(options.challenge, null, 'authentication', now);
return { options };
}
export async function passkeyLoginVerify(body: { assertionResponse?: unknown }): Promise<{
error?: string;
status?: number;
token?: string;
user?: Record<string, unknown>;
auditUserId?: number | null;
auditAction?: string;
}> {
const cfg = resolveWebauthnConfig();
if (!cfg) return { ...NOT_CONFIGURED };
const resp = body?.assertionResponse;
if (!resp) return { ...AUTH_FAILED };
const challenge = challengeFromResponse(resp);
if (!challenge) return { ...AUTH_FAILED };
// Claim the challenge (single-use) BEFORE looking anything up or verifying.
const now = Date.now();
if (!claimChallenge(challenge, 'authentication', now)) return { ...AUTH_FAILED };
const credId = (resp as { id?: unknown; rawId?: unknown }).id ?? (resp as { rawId?: unknown }).rawId;
if (typeof credId !== 'string') return { ...AUTH_FAILED };
const cred = db.prepare('SELECT * FROM webauthn_credentials WHERE credential_id = ?').get(credId) as CredentialRow | undefined;
if (!cred) return { ...AUTH_FAILED };
let verification;
try {
verification = await verifyAuthenticationResponse({
response: resp as Parameters<typeof verifyAuthenticationResponse>[0]['response'],
expectedChallenge: challenge,
expectedOrigin: cfg.origins,
expectedRPID: cfg.rpID,
requireUserVerification: true,
credential: {
id: cred.credential_id,
publicKey: new Uint8Array(cred.public_key),
counter: cred.counter,
transports: parseTransports(cred.transports),
},
});
} catch {
return { ...AUTH_FAILED };
}
if (!verification.verified) return { ...AUTH_FAILED };
const { newCounter } = verification.authenticationInfo;
// Clone detection only makes sense for authenticators that actually increment.
// Synced passkeys legitimately report a counter that stays 0 — never treat
// that as a clone. A regression from a previously NON-ZERO counter rejects
// THIS assertion (and is audited) but does not disable the credential.
if (cred.counter > 0 && newCounter <= cred.counter) {
return { ...AUTH_FAILED, auditUserId: cred.user_id, auditAction: 'user.passkey_clone_suspected' };
}
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(cred.user_id) as User | undefined;
if (!user) return { ...AUTH_FAILED };
// Persist the new counter + last-used and bump login bookkeeping atomically.
db.transaction(() => {
db.prepare('UPDATE webauthn_credentials SET counter = ?, last_used_at = CURRENT_TIMESTAMP WHERE id = ?').run(newCounter, cred.id);
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?').run(user.id);
})();
// A user-verified passkey is phishing-resistant and inherently two-factor
// (device possession + biometric/PIN), so it mints the real session directly
// — the SAME path as password and OIDC login (no new token shape).
const token = generateToken(user);
const userSafe = stripUserForClient(user) as Record<string, unknown>;
return { token, user: { ...userSafe, avatar_url: avatarUrl(user) }, auditUserId: Number(user.id) };
}
// ---------------------------------------------------------------------------
// Management (authenticated, owner-scoped)
// ---------------------------------------------------------------------------
export function listPasskeys(userId: number): Array<Record<string, unknown>> {
const rows = db.prepare(
'SELECT id, name, device_type, backed_up, created_at, last_used_at FROM webauthn_credentials WHERE user_id = ? ORDER BY created_at DESC',
).all(userId) as Array<{ backed_up: number } & Record<string, unknown>>;
return rows.map((r) => ({ ...r, backed_up: r.backed_up === 1 }));
}
export function renamePasskey(userId: number, id: string, name: unknown): { error?: string; status?: number; success?: boolean } {
const cleanName = sanitizeName(name);
if (!cleanName) return { error: 'Name is required', status: 400 };
// Ownership enforced in SQL (404 on miss, never a 403 that leaks existence).
const result = db.prepare('UPDATE webauthn_credentials SET name = ? WHERE id = ? AND user_id = ?').run(cleanName, Number(id), userId);
if (result.changes === 0) return { error: 'Passkey not found', status: 404 };
return { success: true };
}
export function deletePasskey(
userId: number,
id: string,
password: string | undefined,
): { error?: string; status?: number; success?: boolean } {
// Re-auth before removing a credential (a hijacked session must not be able to
// strip the victim's passkeys). Deleting is always allowed because every
// account keeps a usable password as recovery fallback — losing all passkeys
// can never lock anyone out.
const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(userId) as { password_hash: string } | undefined;
if (!user || !user.password_hash || !password || !bcrypt.compareSync(password, user.password_hash)) {
return { error: 'Incorrect password', status: 401 };
}
const result = db.prepare('DELETE FROM webauthn_credentials WHERE id = ? AND user_id = ?').run(Number(id), userId);
if (result.changes === 0) return { error: 'Passkey not found', status: 404 };
return { success: true };
}
/** Admin: clear all of a user's passkeys (e.g. on suspected compromise). */
export function adminResetPasskeys(targetUserId: number): { error?: string; status?: number; success?: boolean; deleted?: number; email?: string } {
const target = db.prepare('SELECT id, email FROM users WHERE id = ?').get(targetUserId) as { id: number; email: string } | undefined;
if (!target) return { error: 'User not found', status: 404 };
const result = db.prepare('DELETE FROM webauthn_credentials WHERE user_id = ?').run(targetUserId);
return { success: true, deleted: result.changes, email: target.email };
}