diff --git a/package-lock.json b/package-lock.json index 1037c39b..822a53f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "@fontsource/geist-sans": "^5.2.5", "@fontsource/poppins": "^5.2.7", "@react-pdf/renderer": "^4.5.1", + "@simplewebauthn/browser": "^13.1.2", "@trek/shared": "*", "axios": "^1.6.7", "dexie": "^4.4.2", @@ -2525,6 +2526,12 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==", + "license": "MIT" + }, "node_modules/@hono/node-server": { "version": "1.19.14", "license": "MIT", @@ -3656,6 +3663,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@levischuck/tiny-cbor": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz", + "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", + "license": "MIT" + }, "node_modules/@lukeed/csprng": { "version": "1.1.0", "license": "MIT", @@ -4490,6 +4503,174 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@peculiar/asn1-android": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.7.0.tgz", + "integrity": "sha512-iD3VskhVQnM4nE3PN9cBdPTR7JrqZy3FYk+uD2CeG6DUqKoANqaEfx0f7izPmW+Qm5JBM35ek+viLCmjy18ByQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.7.0.tgz", + "integrity": "sha512-hew63shtzzvBcSHbhm+cyAmKe6AIfinT9hzEqSPjDC6opTTMKmTkQ0gHuN2KsWlvqiKw1S/fS94fhag/FJkioQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "@peculiar/asn1-x509-attr": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.7.0.tgz", + "integrity": "sha512-VVsAyGqErT9D1SY4aEqozThXMVI+ssVRiv2DDeYuvpBKLIgZ3hYs3Ay3u/VSoKq6ESFi9cf6rf3IOOzfwh7oMA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.7.0.tgz", + "integrity": "sha512-n7KEs/Q/wrB415cxy4fHOBhegp4NdJ15fkJPwcB/3/8iNBQC2L/N7SChJPKDJPZGYH0jD4Tg4/0vnHmwghnbKw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.7.0.tgz", + "integrity": "sha512-V/nrlQVmhg7lYAsM7E13UDL5erAwFv6kCIVFqNaMIHSVi7dngcT839JkRTkQBqznMG98l2XjxYk74ZztAohZzA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.7.0", + "@peculiar/asn1-pkcs8": "^2.7.0", + "@peculiar/asn1-rsa": "^2.7.0", + "@peculiar/asn1-schema": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.7.0.tgz", + "integrity": "sha512-9GTl1nE8Mx1kTZ+7QyYatDyKsm34QcWRBFkY1iPvWC3X4Dona5s/tlLiQsx5WzVdZqiMBZNYT0buyw4/vbhnjw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.7.0.tgz", + "integrity": "sha512-Bh7m+OuIaSEllPQcSd9OSp93F4ROWH7sbITWV8MI+8dwsjE5111/87VxiWVvYFKyww3vp39geLv9ENqhwWHcew==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.7.0", + "@peculiar/asn1-pfx": "^2.7.0", + "@peculiar/asn1-pkcs8": "^2.7.0", + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "@peculiar/asn1-x509-attr": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.7.0.tgz", + "integrity": "sha512-/qvENQrXyTZURjMqSeofHul0JJt2sNSzSwk36pl2olkHbaioMQgrASDZAlHXl0xUlnVbHj0uGgOrBMTb5x2aJQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.7.0.tgz", + "integrity": "sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg==", + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.7.0.tgz", + "integrity": "sha512-mUn9RRrkGDnG4ALfunDmzyRW5dg+sWCj/pfnCCqEHYbkGxEpvUt6iVJv8Yw1cyp6SWZ26ZE5oSmI5SqEaen15g==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.7.0.tgz", + "integrity": "sha512-NS8e7SOgXipkzUPLF/sce7ukpMpWjhxYsH0n6Y+bHYo4TTxOb95Zv7hqwSuL212mj5YxovjdOKQOgH1As3E94w==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "dev": true, @@ -5179,6 +5360,31 @@ "win32" ] }, + "node_modules/@simplewebauthn/browser": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz", + "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==", + "license": "MIT" + }, + "node_modules/@simplewebauthn/server": { + "version": "13.3.1", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.1.tgz", + "integrity": "sha512-GV/oM/qeycWn8p42JZIMJBsXWQcNFg+nJFzeQTnMA4gN8mXg0+HZFWJerHg8ZN/zlveMS3iV1wzuFpOVWS/46w==", + "license": "MIT", + "dependencies": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.1", + "@peculiar/asn1-rsa": "^2.6.1", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/x509": "^1.14.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@swc/core": { "version": "1.15.40", "dev": true, @@ -6442,6 +6648,20 @@ "dev": true, "license": "MIT" }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "dev": true, @@ -12765,6 +12985,24 @@ "node": ">=6" } }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/qrcode": { "version": "1.5.4", "license": "MIT", @@ -15445,6 +15683,24 @@ "@esbuild/win32-x64": "0.28.0" } }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/tunnel-agent": { "version": "0.6.0", "license": "Apache-2.0", @@ -17346,6 +17602,7 @@ "@nestjs/common": "^11.1.24", "@nestjs/core": "^11.1.24", "@nestjs/platform-express": "^11.1.24", + "@simplewebauthn/server": "^13.1.2", "@trek/shared": "*", "archiver": "^6.0.1", "bcryptjs": "^2.4.3", diff --git a/server/package.json b/server/package.json index a2b403a9..f5663c74 100644 --- a/server/package.json +++ b/server/package.json @@ -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", diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 67563076..234fbfb1 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -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) { diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 310b869d..71b3ad73 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -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, diff --git a/server/src/middleware/mfaPolicy.ts b/server/src/middleware/mfaPolicy.ts index 70b00896..e1538419 100644 --- a/server/src/middleware/mfaPolicy.ts +++ b/server/src/middleware/mfaPolicy.ts @@ -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; } diff --git a/server/src/nest/admin/admin.controller.ts b/server/src/nest/admin/admin.controller.ts index 9cca9026..340e165d 100644 --- a/server/src/nest/admin/admin.controller.ts +++ b/server/src/nest/admin/admin.controller.ts @@ -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(); } diff --git a/server/src/nest/admin/admin.service.ts b/server/src/nest/admin/admin.service.ts index 754e0e6c..27340ccb 100644 --- a/server/src/nest/admin/admin.service.ts +++ b/server/src/nest/admin/admin.service.ts @@ -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[0]); } updateUser(id: string, body: unknown) { return svc.updateUser(id, body as Parameters[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(); } diff --git a/server/src/nest/auth/auth.module.ts b/server/src/nest/auth/auth.module.ts index 3f8ae266..7c649537 100644 --- a/server/src/nest/auth/auth.module.ts +++ b/server/src/nest/auth/auth.module.ts @@ -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 {} diff --git a/server/src/nest/auth/passkey-enabled.guard.ts b/server/src/nest/auth/passkey-enabled.guard.ts new file mode 100644 index 00000000..26954162 --- /dev/null +++ b/server/src/nest/auth/passkey-enabled.guard.ts @@ -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; + } +} diff --git a/server/src/nest/auth/passkey.controller.ts b/server/src/nest/auth/passkey.controller.ts new file mode 100644 index 00000000..e4f70749 --- /dev/null +++ b/server/src/nest/auth/passkey.controller.ts @@ -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[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[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 }; + } +} diff --git a/server/src/services/authService.ts b/server/src/services/authService.ts index 61b6193d..e7d86b84 100644 --- a/server/src/services/authService.ts +++ b/server/src/services/authService.ts @@ -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, }; } diff --git a/server/src/services/passkeyService.ts b/server/src/services/passkeyService.ts new file mode 100644 index 00000000..7c8c2548 --- /dev/null +++ b/server/src/services/passkeyService.ts @@ -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> }> { + 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[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; + 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>; +}> { + 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; + 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[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; + return { token, user: { ...userSafe, avatar_url: avatarUrl(user) }, auditUserId: Number(user.id) }; +} + +// --------------------------------------------------------------------------- +// Management (authenticated, owner-scoped) +// --------------------------------------------------------------------------- + +export function listPasskeys(userId: number): Array> { + 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>; + 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 }; +} diff --git a/server/src/services/webauthnConfig.ts b/server/src/services/webauthnConfig.ts new file mode 100644 index 00000000..d6fcf924 --- /dev/null +++ b/server/src/services/webauthnConfig.ts @@ -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; +} diff --git a/server/tests/unit/services/webauthnConfig.test.ts b/server/tests/unit/services/webauthnConfig.test.ts new file mode 100644 index 00000000..157645a4 --- /dev/null +++ b/server/tests/unit/services/webauthnConfig.test.ts @@ -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(), + 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']); + }); +});