mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
feat: Passkey (WebAuthn) login (#1111)
* feat(auth): passkey (WebAuthn) login — server endpoints, schema + admin toggle Add @simplewebauthn/server registration and primary (discoverable) login ceremonies under /api/auth/passkey, a webauthn_credentials + single-use webauthn_challenges schema (migration), the instance-wide passkey_login toggle (default off) enforced before auth by a guard, and require_mfa satisfaction via a verified passkey. RP ID/origin come only from server config (webauthn_rp_id/origins -> APP_URL), never request headers. * feat(auth): passkey enrolment, login button + admin settings UI PasskeysSection in account settings (add/rename/remove with a current-password step-up), a 'Sign in with a passkey' button on the login page, the admin enable + RP-ID/origins controls, and a per-user admin reset action. * i18n(auth): passkey strings across all locales Add login/settings/admin passkey keys to en and all 19 translated locales.
This commit is contained in:
@@ -27,6 +27,7 @@
|
||||
"@nestjs/common": "^11.1.24",
|
||||
"@nestjs/core": "^11.1.24",
|
||||
"@nestjs/platform-express": "^11.1.24",
|
||||
"@simplewebauthn/server": "^13.1.2",
|
||||
"archiver": "^6.0.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
|
||||
@@ -2340,6 +2340,35 @@ function runMigrations(db: Database.Database): void {
|
||||
"UPDATE addons SET name = 'Costs', description = 'Track and split trip expenses' WHERE id = 'budget' AND name = 'Budget Planner'",
|
||||
).run();
|
||||
},
|
||||
// WebAuthn / passkey support: per-user credentials + single-use login
|
||||
// challenges. Additive (CREATE TABLE IF NOT EXISTS) so existing installs are
|
||||
// untouched; both tables also live in schema.ts for fresh installs.
|
||||
() => db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
credential_id TEXT NOT NULL UNIQUE,
|
||||
public_key BLOB NOT NULL,
|
||||
counter INTEGER NOT NULL DEFAULT 0,
|
||||
transports TEXT,
|
||||
device_type TEXT,
|
||||
backed_up INTEGER NOT NULL DEFAULT 0,
|
||||
name TEXT,
|
||||
aaguid TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at DATETIME
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id);
|
||||
CREATE TABLE IF NOT EXISTS webauthn_challenges (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
challenge TEXT NOT NULL UNIQUE,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
type TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires ON webauthn_challenges(expires_at);
|
||||
`),
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -42,6 +42,32 @@ function createTables(db: Database.Database): void {
|
||||
CREATE INDEX IF NOT EXISTS idx_prt_user ON password_reset_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_prt_hash ON password_reset_tokens(token_hash);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
credential_id TEXT NOT NULL UNIQUE,
|
||||
public_key BLOB NOT NULL,
|
||||
counter INTEGER NOT NULL DEFAULT 0,
|
||||
transports TEXT,
|
||||
device_type TEXT,
|
||||
backed_up INTEGER NOT NULL DEFAULT 0,
|
||||
name TEXT,
|
||||
aaguid TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at DATETIME
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS webauthn_challenges (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
challenge TEXT NOT NULL UNIQUE,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
type TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires ON webauthn_challenges(expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
@@ -12,6 +12,9 @@ export function isPublicApiPath(method: string, pathNoQuery: string): boolean {
|
||||
if (method === 'POST' && pathNoQuery === '/api/auth/demo-login') return true;
|
||||
if (method === 'GET' && pathNoQuery.startsWith('/api/auth/invite/')) return true;
|
||||
if (method === 'POST' && pathNoQuery === '/api/auth/mfa/verify-login') return true;
|
||||
// Unauthenticated passkey (primary) login ceremony.
|
||||
if (method === 'POST' && pathNoQuery === '/api/auth/passkey/login/options') return true;
|
||||
if (method === 'POST' && pathNoQuery === '/api/auth/passkey/login/verify') return true;
|
||||
if (pathNoQuery.startsWith('/api/auth/oidc/')) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -21,6 +24,11 @@ export function isMfaSetupExemptPath(method: string, pathNoQuery: string): boole
|
||||
if (method === 'GET' && pathNoQuery === '/api/auth/me') return true;
|
||||
if (method === 'POST' && pathNoQuery === '/api/auth/mfa/setup') return true;
|
||||
if (method === 'POST' && pathNoQuery === '/api/auth/mfa/enable') return true;
|
||||
// Allow enrolling a passkey as the second factor (a user-verified passkey
|
||||
// satisfies require_mfa), so a fresh user under the policy isn't stuck.
|
||||
if (method === 'POST' && pathNoQuery === '/api/auth/passkey/register/options') return true;
|
||||
if (method === 'POST' && pathNoQuery === '/api/auth/passkey/register/verify') return true;
|
||||
if (method === 'GET' && pathNoQuery === '/api/auth/passkey/credentials') return true;
|
||||
if ((method === 'GET' || method === 'PUT') && pathNoQuery === '/api/auth/app-settings') return true;
|
||||
return false;
|
||||
}
|
||||
@@ -81,8 +89,12 @@ export function enforceGlobalMfaPolicy(req: Request, res: Response, next: NextFu
|
||||
return;
|
||||
}
|
||||
|
||||
// A user-verified passkey is phishing-resistant and inherently two-factor, so
|
||||
// owning at least one satisfies the require_mfa policy exactly like TOTP does.
|
||||
// (All stored passkeys were registered with userVerification required.)
|
||||
const mfaOk = row.mfa_enabled === 1 || row.mfa_enabled === true;
|
||||
if (mfaOk) {
|
||||
const passkeyOk = !!db.prepare('SELECT 1 FROM webauthn_credentials WHERE user_id = ? LIMIT 1').get(userId);
|
||||
if (mfaOk || passkeyOk) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -60,6 +60,13 @@ export class AdminController {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Delete('users/:id/passkeys')
|
||||
resetUserPasskeys(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
const result = ok(this.admin.resetUserPasskeys(id));
|
||||
writeAudit({ userId: user.id, action: 'admin.user_passkeys_reset', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email, deleted: result.deleted } });
|
||||
return { success: true, deleted: result.deleted };
|
||||
}
|
||||
|
||||
// ── Stats / permissions / audit ──
|
||||
@Get('stats')
|
||||
stats() { return this.admin.getStats(); }
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as svc from '../../services/adminService';
|
||||
import { getAdminUserDefaults, setAdminUserDefaults } from '../../services/settingsService';
|
||||
import { invalidateMcpSessions } from '../../mcp';
|
||||
import { getPreferencesMatrix, setAdminPreferences } from '../../services/notificationPreferencesService';
|
||||
import { adminResetPasskeys } from '../../services/passkeyService';
|
||||
|
||||
/**
|
||||
* Thin Nest wrapper around the existing admin service (+ the settings,
|
||||
@@ -17,6 +18,7 @@ export class AdminService {
|
||||
createUser(body: unknown) { return svc.createUser(body as Parameters<typeof svc.createUser>[0]); }
|
||||
updateUser(id: string, body: unknown) { return svc.updateUser(id, body as Parameters<typeof svc.updateUser>[1]); }
|
||||
deleteUser(id: string, actingUserId: number) { return svc.deleteUser(id, actingUserId); }
|
||||
resetUserPasskeys(id: string) { return adminResetPasskeys(Number(id)); }
|
||||
|
||||
getStats() { return svc.getStats(); }
|
||||
getPermissions() { return svc.getPermissions(); }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthPublicController } from './auth-public.controller';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { PasskeyController } from './passkey.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { RateLimitService } from './rate-limit.service';
|
||||
|
||||
@@ -11,7 +12,7 @@ import { RateLimitService } from './rate-limit.service';
|
||||
* sub-paths explicitly rather than claiming all of /api/auth.
|
||||
*/
|
||||
@Module({
|
||||
controllers: [AuthPublicController, AuthController],
|
||||
controllers: [AuthPublicController, AuthController, PasskeyController],
|
||||
providers: [AuthService, RateLimitService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { CanActivate, HttpException, Injectable } from '@nestjs/common';
|
||||
import { resolveAuthToggles } from '../../services/authService';
|
||||
|
||||
/**
|
||||
* Server-side enforcement of the instance-wide `passkey_login` toggle. Placed
|
||||
* BEFORE the auth guard on every passkey ceremony route so a disabled feature
|
||||
* returns 404 (not "auth required") and cannot be driven by direct API calls —
|
||||
* hiding the button in the UI is not enough. Mirrors JourneyAddonGuard.
|
||||
*
|
||||
* The credential-management routes (list/rename/delete) are deliberately NOT
|
||||
* gated by this guard so users can still clean up their passkeys after an admin
|
||||
* turns the feature off.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PasskeyEnabledGuard implements CanActivate {
|
||||
canActivate(): boolean {
|
||||
if (!resolveAuthToggles().passkey_login) {
|
||||
throw new HttpException({ error: 'Passkey login is not enabled' }, 404);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpException, Param, Patch, Post, Req, Res, UseGuards } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { RateLimitService } from './rate-limit.service';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import { PasskeyEnabledGuard } from './passkey-enabled.guard';
|
||||
import { CurrentUser } from './current-user.decorator';
|
||||
import { setAuthCookie } from '../../services/cookie';
|
||||
import { writeAudit, getClientIp } from '../../services/auditLog';
|
||||
import * as passkey from '../../services/passkeyService';
|
||||
import type { User } from '../../types';
|
||||
|
||||
const WINDOW = 15 * 60 * 1000;
|
||||
const LOGIN_MIN_LATENCY_MS = 350;
|
||||
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/**
|
||||
* /api/auth/passkey — WebAuthn (passkey) registration, primary login and
|
||||
* credential management.
|
||||
*
|
||||
* - register/* : authenticated, gated by the admin toggle + password re-auth.
|
||||
* - login/* : UNauthenticated discoverable-credential login, gated by the
|
||||
* admin toggle; mints the SAME session cookie as password login.
|
||||
* - credentials : owner-scoped management — intentionally NOT toggle-gated so a
|
||||
* user can always view/remove their passkeys.
|
||||
*
|
||||
* PasskeyEnabledGuard is listed first so a disabled feature 404s before auth.
|
||||
*/
|
||||
@Controller('api/auth/passkey')
|
||||
export class PasskeyController {
|
||||
constructor(private readonly rl: RateLimitService) {}
|
||||
|
||||
private limit(bucket: string, req: Request, max: number): void {
|
||||
if (!this.rl.check(bucket, req.ip || 'unknown', max, WINDOW, Date.now())) {
|
||||
throw new HttpException({ error: 'Too many attempts. Please try again later.' }, 429);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Registration (authenticated) ──
|
||||
@Post('register/options')
|
||||
@HttpCode(200)
|
||||
@UseGuards(PasskeyEnabledGuard, JwtAuthGuard)
|
||||
async registerOptions(@CurrentUser() user: User, @Body() body: { password?: string }, @Req() req: Request) {
|
||||
this.limit('mfa', req, 5);
|
||||
const result = await passkey.passkeyRegisterOptions(user.id, body?.password);
|
||||
if (result.error) throw new HttpException({ error: result.error }, result.status!);
|
||||
return result.options;
|
||||
}
|
||||
|
||||
@Post('register/verify')
|
||||
@HttpCode(200)
|
||||
@UseGuards(PasskeyEnabledGuard, JwtAuthGuard)
|
||||
async registerVerify(@CurrentUser() user: User, @Body() body: unknown, @Req() req: Request) {
|
||||
const result = await passkey.passkeyRegisterVerify(user.id, body as Parameters<typeof passkey.passkeyRegisterVerify>[1]);
|
||||
if (result.error) throw new HttpException({ error: result.error }, result.status!);
|
||||
writeAudit({ userId: user.id, action: 'user.passkey_register', ip: getClientIp(req) });
|
||||
return { success: true, credential: result.credential };
|
||||
}
|
||||
|
||||
// ── Authentication (public — primary login) ──
|
||||
@Post('login/options')
|
||||
@HttpCode(200)
|
||||
@UseGuards(PasskeyEnabledGuard)
|
||||
async loginOptions(@Req() req: Request) {
|
||||
this.limit('login', req, 10);
|
||||
const result = await passkey.passkeyLoginOptions();
|
||||
if (result.error) throw new HttpException({ error: result.error }, result.status!);
|
||||
return result.options;
|
||||
}
|
||||
|
||||
@Post('login/verify')
|
||||
@HttpCode(200)
|
||||
@UseGuards(PasskeyEnabledGuard)
|
||||
async loginVerify(@Body() body: unknown, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||
this.limit('login', req, 10);
|
||||
const started = Date.now();
|
||||
const result = await passkey.passkeyLoginVerify(body as Parameters<typeof passkey.passkeyLoginVerify>[0]);
|
||||
if (result.auditAction) {
|
||||
writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req) });
|
||||
}
|
||||
// Pad to the same floor as password login so timing can't distinguish a
|
||||
// known credential from an unknown one.
|
||||
const elapsed = Date.now() - started;
|
||||
if (elapsed < LOGIN_MIN_LATENCY_MS) await delay(LOGIN_MIN_LATENCY_MS - elapsed);
|
||||
if (result.error) throw new HttpException({ error: result.error }, result.status!);
|
||||
writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { method: 'passkey' } });
|
||||
setAuthCookie(res, result.token!, req);
|
||||
return { token: result.token, user: result.user };
|
||||
}
|
||||
|
||||
// ── Management (authenticated, owner-scoped — NOT toggle-gated) ──
|
||||
@Get('credentials')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
list(@CurrentUser() user: User) {
|
||||
return { credentials: passkey.listPasskeys(user.id) };
|
||||
}
|
||||
|
||||
@Patch('credentials/:id')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
rename(@CurrentUser() user: User, @Param('id') id: string, @Body() body: { name?: unknown }) {
|
||||
const result = passkey.renamePasskey(user.id, id, body?.name);
|
||||
if (result.error) throw new HttpException({ error: result.error }, result.status!);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Delete('credentials/:id')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
remove(@CurrentUser() user: User, @Param('id') id: string, @Body() body: { password?: string }, @Req() req: Request) {
|
||||
this.limit('login', req, 5);
|
||||
const result = passkey.deletePasskey(user.id, id, body?.password);
|
||||
if (result.error) throw new HttpException({ error: result.error }, result.status!);
|
||||
writeAudit({ userId: user.id, action: 'user.passkey_delete', resource: String(id), ip: getClientIp(req) });
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { verifyJwtAndLoadUser } from '../middleware/auth';
|
||||
import { User } from '../types';
|
||||
import { DEMO_EMAIL_PRIMARY, isDemoEmail } from './demo';
|
||||
import { avatarUrl } from './avatarUrl';
|
||||
import { isPasskeyConfigured } from './webauthnConfig';
|
||||
|
||||
export { avatarUrl };
|
||||
|
||||
@@ -51,6 +52,7 @@ const ADMIN_SETTINGS_KEYS = [
|
||||
'notification_channels', 'admin_webhook_url', 'admin_ntfy_server', 'admin_ntfy_topic', 'admin_ntfy_token',
|
||||
'notify_trip_reminder',
|
||||
'password_login', 'password_registration', 'oidc_login', 'oidc_registration',
|
||||
'passkey_login', 'webauthn_rp_id', 'webauthn_origins',
|
||||
];
|
||||
|
||||
const avatarDir = path.join(__dirname, '../../uploads/avatars');
|
||||
@@ -128,10 +130,17 @@ export function resolveAuthToggles(): {
|
||||
password_registration: boolean;
|
||||
oidc_login: boolean;
|
||||
oidc_registration: boolean;
|
||||
passkey_login: boolean;
|
||||
} {
|
||||
const get = (key: string) =>
|
||||
(db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value ?? null;
|
||||
|
||||
// Passkey login is independent of the password/OIDC "new keys" probe, so it
|
||||
// must be resolved OUTSIDE the branch below — otherwise on a fresh install
|
||||
// that never touched the password/OIDC toggles it would silently read false
|
||||
// even after an admin enabled it. Default OFF (opt-in).
|
||||
const passkey_login = get('passkey_login') === 'true';
|
||||
|
||||
const hasNewKeys = ['password_login', 'password_registration', 'oidc_login', 'oidc_registration']
|
||||
.some(k => get(k) !== null);
|
||||
|
||||
@@ -141,6 +150,7 @@ export function resolveAuthToggles(): {
|
||||
password_registration: get('password_registration') !== 'false',
|
||||
oidc_login: get('oidc_login') !== 'false',
|
||||
oidc_registration: get('oidc_registration') !== 'false',
|
||||
passkey_login,
|
||||
};
|
||||
if (process.env.OIDC_ONLY?.toLowerCase() === 'true') {
|
||||
result.password_login = false;
|
||||
@@ -163,6 +173,7 @@ export function resolveAuthToggles(): {
|
||||
password_registration: !oidcOnly && allowReg,
|
||||
oidc_login: true,
|
||||
oidc_registration: allowReg,
|
||||
passkey_login,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -299,6 +310,12 @@ export function getAppConfig(authenticatedUser: { id: number } | null) {
|
||||
password_registration: isDemo ? false : toggles.password_registration,
|
||||
oidc_login: toggles.oidc_login,
|
||||
oidc_registration: isDemo ? false : toggles.oidc_registration,
|
||||
// Passkey login: the instance toggle + whether a usable RP ID resolves for
|
||||
// this deployment. The login page shows the passkey button only when both
|
||||
// are true. `passkey_configured` stays a pure boolean — it never leaks the
|
||||
// resolved RP ID / origin / APP_URL on this unauthenticated endpoint.
|
||||
passkey_login: toggles.passkey_login,
|
||||
passkey_configured: isPasskeyConfigured(),
|
||||
env_override_oidc_only: process.env.OIDC_ONLY === 'true',
|
||||
has_users: userCount > 0,
|
||||
setup_complete: setupComplete,
|
||||
@@ -812,9 +829,12 @@ export function updateAppSettings(
|
||||
const { require_mfa } = body;
|
||||
if (require_mfa === true || require_mfa === 'true') {
|
||||
const adminMfa = db.prepare('SELECT mfa_enabled FROM users WHERE id = ?').get(userId) as { mfa_enabled: number } | undefined;
|
||||
if (!(adminMfa?.mfa_enabled === 1)) {
|
||||
// A user-verified passkey satisfies the MFA policy, so an admin who secured
|
||||
// their own account with a passkey may enable it too (not only TOTP).
|
||||
const adminHasPasskey = !!db.prepare('SELECT 1 FROM webauthn_credentials WHERE user_id = ? LIMIT 1').get(userId);
|
||||
if (!(adminMfa?.mfa_enabled === 1) && !adminHasPasskey) {
|
||||
return {
|
||||
error: 'Enable two-factor authentication on your own account before requiring it for all users.',
|
||||
error: 'Secure your own account with two-factor authentication or a passkey before requiring it for all users.',
|
||||
status: 400,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { db } from '../db/database';
|
||||
import { getAppUrl } from './notifications';
|
||||
|
||||
/**
|
||||
* Resolves the WebAuthn Relying Party ID + allowed origins for this deployment.
|
||||
*
|
||||
* SECURITY: the RP ID and the allowed origins are derived ONLY from server-side
|
||||
* configuration — the `webauthn_rp_id` / `webauthn_origins` admin settings (or
|
||||
* the matching env vars), falling back to APP_URL. They are NEVER taken from the
|
||||
* request `Host` / `X-Forwarded-Host` header: a forged forwarded host would
|
||||
* otherwise let an attacker bind credentials to a domain they control, or brick
|
||||
* every enrolled user. This mirrors how OIDC derives its redirect URI from
|
||||
* APP_URL (oidc.controller.ts) rather than from request input.
|
||||
*
|
||||
* Returns null when no usable RP ID can be resolved (bare IP host, or nothing
|
||||
* configured) — the feature then reports itself as "not configured" and stays
|
||||
* disabled so nobody can enrol a credential bound to the wrong origin.
|
||||
*/
|
||||
|
||||
function getSetting(key: string): string | null {
|
||||
const raw = (db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value;
|
||||
const trimmed = raw?.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function hostOf(url: string): string | null {
|
||||
try {
|
||||
return new URL(url).hostname || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** WebAuthn RP IDs must be registrable domains — never bare IP literals. */
|
||||
function isIpHost(host: string): boolean {
|
||||
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return true; // IPv4
|
||||
if (host.includes(':')) return true; // IPv6 (hostname keeps the colons)
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface WebauthnConfig {
|
||||
rpID: string;
|
||||
rpName: string;
|
||||
/** Exact allowed origins (scheme + host + port). One in prod; localhost dev adds the Vite/API ports. */
|
||||
origins: string[];
|
||||
}
|
||||
|
||||
export function resolveWebauthnConfig(): WebauthnConfig | null {
|
||||
// 1. Explicit operator config always wins.
|
||||
const explicitRpId = (process.env.WEBAUTHN_RP_ID || getSetting('webauthn_rp_id'))?.trim() || null;
|
||||
const explicitOrigins = (process.env.WEBAUTHN_ORIGINS || getSetting('webauthn_origins') || '')
|
||||
.split(',')
|
||||
.map((o) => o.trim().replace(/\/+$/, ''))
|
||||
.filter(Boolean);
|
||||
|
||||
const appUrl = getAppUrl();
|
||||
const appHost = hostOf(appUrl);
|
||||
|
||||
// 2. Derive the RP ID from APP_URL when not explicitly set.
|
||||
let rpID = explicitRpId;
|
||||
if (!rpID && appHost && !isIpHost(appHost)) {
|
||||
rpID = appHost; // a real domain, or "localhost"
|
||||
}
|
||||
if (!rpID) return null; // bare IP / unresolved → WebAuthn cannot be used here
|
||||
|
||||
// 3. Resolve the allowed origins. Explicit list wins verbatim (operator's
|
||||
// responsibility). Otherwise derive a SINGLE origin from APP_URL — we never
|
||||
// silently union dev localhost origins into a production allow-list.
|
||||
let origins = explicitOrigins;
|
||||
if (origins.length === 0) {
|
||||
if (appHost) origins = [appUrl.replace(/\/+$/, '')];
|
||||
if (rpID === 'localhost') {
|
||||
// Dev: the browser origin is the Vite dev server (:5173), not the API port.
|
||||
origins = Array.from(new Set([...origins, 'http://localhost:5173', 'http://localhost:3001']));
|
||||
}
|
||||
}
|
||||
if (origins.length === 0) return null;
|
||||
|
||||
return { rpID, rpName: 'TREK', origins };
|
||||
}
|
||||
|
||||
/** True when a usable RP ID resolves for this deployment (exposed as a pure boolean on app-config). */
|
||||
export function isPasskeyConfigured(): boolean {
|
||||
return resolveWebauthnConfig() !== null;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* webauthnConfig.test.ts
|
||||
*
|
||||
* The RP-ID / allowed-origin resolver is the single highest-risk piece of the
|
||||
* passkey feature: a wrong RP ID permanently bricks every enrolled credential.
|
||||
* These tests pin the security-relevant rules — config wins over APP_URL, bare
|
||||
* IPs are rejected, localhost dev uses the browser (Vite) origin, and the
|
||||
* resolver NEVER reads request headers.
|
||||
*/
|
||||
|
||||
const { settingsStore, appUrlRef } = vi.hoisted(() => ({
|
||||
settingsStore: new Map<string, string>(),
|
||||
appUrlRef: { value: '' },
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/db/database', () => ({
|
||||
db: {
|
||||
prepare: (_sql: string) => ({
|
||||
get: (key: string) => {
|
||||
const v = settingsStore.get(key);
|
||||
return v === undefined ? undefined : { value: v };
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/services/notifications', () => ({
|
||||
getAppUrl: () => appUrlRef.value,
|
||||
}));
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { resolveWebauthnConfig, isPasskeyConfigured } from '../../../src/services/webauthnConfig';
|
||||
|
||||
beforeEach(() => {
|
||||
settingsStore.clear();
|
||||
appUrlRef.value = '';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('resolveWebauthnConfig', () => {
|
||||
it('WAC-001: derives the RP ID and single origin from a real APP_URL domain', () => {
|
||||
appUrlRef.value = 'https://trek.example.org';
|
||||
const cfg = resolveWebauthnConfig();
|
||||
expect(cfg).not.toBeNull();
|
||||
expect(cfg!.rpID).toBe('trek.example.org');
|
||||
expect(cfg!.origins).toEqual(['https://trek.example.org']);
|
||||
expect(isPasskeyConfigured()).toBe(true);
|
||||
});
|
||||
|
||||
it('WAC-002: returns null for a bare-IP host (IPs are not valid RP IDs)', () => {
|
||||
appUrlRef.value = 'http://192.168.1.50:3001';
|
||||
expect(resolveWebauthnConfig()).toBeNull();
|
||||
expect(isPasskeyConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
it('WAC-003: returns null when nothing is configured', () => {
|
||||
expect(resolveWebauthnConfig()).toBeNull();
|
||||
expect(isPasskeyConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
it('WAC-004: localhost dev uses the browser (Vite :5173) origin, not just the API port', () => {
|
||||
appUrlRef.value = 'http://localhost:3001';
|
||||
const cfg = resolveWebauthnConfig();
|
||||
expect(cfg!.rpID).toBe('localhost');
|
||||
expect(cfg!.origins).toContain('http://localhost:5173');
|
||||
expect(cfg!.origins).toContain('http://localhost:3001');
|
||||
});
|
||||
|
||||
it('WAC-005: an explicit webauthn_rp_id app-setting overrides APP_URL', () => {
|
||||
appUrlRef.value = 'https://internal.example.org';
|
||||
settingsStore.set('webauthn_rp_id', 'public.example.org');
|
||||
settingsStore.set('webauthn_origins', 'https://public.example.org');
|
||||
const cfg = resolveWebauthnConfig();
|
||||
expect(cfg!.rpID).toBe('public.example.org');
|
||||
expect(cfg!.origins).toEqual(['https://public.example.org']);
|
||||
});
|
||||
|
||||
it('WAC-006: webauthn_origins is parsed as a comma-separated, trimmed list', () => {
|
||||
settingsStore.set('webauthn_rp_id', 'example.org');
|
||||
settingsStore.set('webauthn_origins', 'https://a.example.org , https://b.example.org/');
|
||||
const cfg = resolveWebauthnConfig();
|
||||
expect(cfg!.origins).toEqual(['https://a.example.org', 'https://b.example.org']);
|
||||
});
|
||||
|
||||
it('WAC-007: the WEBAUTHN_RP_ID env var takes priority', () => {
|
||||
vi.stubEnv('WEBAUTHN_RP_ID', 'env.example.org');
|
||||
vi.stubEnv('WEBAUTHN_ORIGINS', 'https://env.example.org');
|
||||
appUrlRef.value = 'https://ignored.example.org';
|
||||
const cfg = resolveWebauthnConfig();
|
||||
expect(cfg!.rpID).toBe('env.example.org');
|
||||
expect(cfg!.origins).toEqual(['https://env.example.org']);
|
||||
});
|
||||
|
||||
it('WAC-008: a configured RP ID with no origins falls back to the APP_URL origin', () => {
|
||||
appUrlRef.value = 'https://trek.example.org';
|
||||
settingsStore.set('webauthn_rp_id', 'trek.example.org');
|
||||
const cfg = resolveWebauthnConfig();
|
||||
expect(cfg!.origins).toEqual(['https://trek.example.org']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user