diff --git a/migrate.md b/migrate.md index d31bbac55..629c38b69 100644 --- a/migrate.md +++ b/migrate.md @@ -32,7 +32,7 @@ Wave 4 — the coupled cluster (order matters here) Wave 5 — the heavyweights, last -10. adminService (14 Nest + 11 MCP consumers), journeyService/atlasService (done 2026-08 — pulled forward as a zero-dependency frontier member opening the Wave-5 auth chain: the 1612-line core split places-style — the DB half (stats aggregation with its two divergent return shapes, visited countries/regions with the #1490 tombstones + cascade, bucket-list CRUD) folded into the wrapper `AtlasService` over `DatabaseService` alone, while the ~750-line pure-geo half (admin0/admin1 boundary stores with the #1576 streaming builders, poly/box indexes, Nominatim geocoding with its shared throttle, the geocode/region caches incl. the import-time unref'd cleanup interval, the `geocodingInFlight` dedup set) moved verbatim to the plain module `nest/atlas/atlas-geo.ts` so the multi-MB caches stay process-global across the container + bridge instances; the 10-tool registrar `mcp/tools/atlas.ts` **plus all four atlas resources in `mcp/resources.ts`** moved to the decorator-driven `atlas.mcp.ts` (the `when:` atlas-addon gate is parity here — legacy tools/resources gated, REST deliberately doesn't; `resources.test.ts` retired with its last two cases, moved to the atlas suites); the plugin RPC host swapped its 9 atlas imports for the injected service (23rd constructor dep — journeys is the factory's last legacy domain import); a minimal 2-export `atlas.bridge.ts` served legacy `authService.getTravelStats` (the zero-bridge streak ends at five; the bridge died on schedule with the 2026-08 auth fold); the DTO ratchet cleared all three `AtlasController` allow-list entries)/vacayService (done 2026-07 — pulled forward as a zero-dependency frontier member, see migration-graph.md)/collectionsService (done 2026-08 — pulled forward as a frontier-ready member: the 1024-line saved-places core folded into the wrapper `CollectionsService` over DatabaseService + PermissionsService + RealtimeService (the `checkPermission` bridge import and `broadcastToUser` websocket import became injections; the `sendInvite` lazy notificationService `import()` stays, collab precedent; `deleteOldCollectionCover` re-anchored one directory deeper); the 25-tool registrar `mcp/tools/collections.ts` moved to the decorator-driven `collections.mcp.ts` — with NO `when:` addon gate, preserving the legacy registrar's addon-gate asymmetry — plus a new 23-case `tools-collections` characterization suite (the registrar was previously untested); the plugin RPC host swapped its 7 collections imports for the injected service (22nd constructor dep); **no bridge file anywhere**; the DTO ratchet cleared both collections allow-list entries (`reorder`, `delete-many`))/mapsService (done 2026-07 — pulled forward as step 4 of the dependency-honest order: the 1429-line geo core folded into the wrapper `MapsService`, pure helpers to `maps.helpers.ts`, the 3 geo MCP tools to the decorator-driven `maps.mcp.ts` (weather/airport tools stay in the legacy registrar), BookingImportService injects it, and a 3-export `maps.bridge.ts` serves placeEnrichment + the places registrar — transitService → placeService now unblocked)/transitService (done 2026-07 — pulled forward as step 5 of the dependency-honest order: the first fully SQL-free domain fold — the 333-line Transitous/MOTIS proxy became a dep-free `TransitService` (no `DatabaseService`); pure `deriveTransitStats` + mode whitelist + itinerary types moved to `transit.helpers.ts` so the downstream `transitItineraryService` needed no bridge; the whole 3-tool registrar moved to the decorator-driven `transit.mcp.ts`, its days/reservations bridge imports becoming injected `DaysService`/`ReservationsService` — zero bridge files; placeService → transitItineraryService followed, both done 2026-07/08), and the auth stack: authService (done 2026-08 — the 1497-line head of the auth → admin → oauth chain folded into the wrapper `AuthService` over DatabaseService + injected PermissionsService/AtlasService (retiring `atlas.bridge.ts` on schedule); the pure crypto half (backup-code hash/match/generate, `stripUserForClient`, key masking, the import-time `DUMMY_PASSWORD_HASH` timing equaliser + `avatarDir` mkdir, both documented parity exceptions) moved to the plain module `auth.helpers.ts`; `mfaSetupPending` + the per-email reset throttle (with its unref'd cleanup interval) stay module-scoped so the bridge and container instances share them; no MCP registrar existed and the plugin host never imported it, so neither surface changed — but the 15 in-container `*.mcp.ts` `isDemoUser` guards now inject `AuthService` (15 modules import AuthModule), with `atlas.mcp.ts` alone on the new 8-export `auth.bridge.ts` because the AuthModule→AtlasModule edge would otherwise close a cycle (places.mcp precedent); the bridge also serves `mcp/index.ts` token verification, the journey/notifications/transports registrars, and the still-legacy adminService/passkeyService; tests moved with IDs preserved (authService.test.ts → auth.helpers.test.ts, authServiceDb.test.ts → auth.service.test.ts + AUTH-BR-001…007 bridge cases), auth.e2e went DI-native (real bcrypt, real audit rows), oidc.e2e switched its dead path-mock for an instance spy). oidcService followed (done 2026-08 — the first frontier cash-in of the auth fold: the 508-line SSO module folded into the wrapper `OidcService` over DatabaseService + injected AuthService (the `resolveAuthToggles` `auth.bridge` import became the injection); no MCP surface, no plugin-host import, and no bridge at all — nothing outside the container consumes the domain, so the state/auth-code maps, their sweep intervals, and the discovery/JWKS caches became instance state with `onModuleDestroy` clearing the timers; `uuid`/`bcryptjs` lazy requires, the `invite_exhausted` sentinel transaction and every SQL string relocated verbatim; tests moved with OIDC-SVC IDs preserved, oidc.e2e and the integration suite switched their path mocks for `vi.spyOn(app.get(OidcService), …)` instance spies), leaving oauthService/passkeyService — still unblocked repoint-consumers of the bridge — plus adminService next in the chain. backupService also stays late: it owns closeDb/reinitialize lifecycle, which is deliberately +10. adminService (14 Nest + 11 MCP consumers), journeyService/atlasService (done 2026-08 — pulled forward as a zero-dependency frontier member opening the Wave-5 auth chain: the 1612-line core split places-style — the DB half (stats aggregation with its two divergent return shapes, visited countries/regions with the #1490 tombstones + cascade, bucket-list CRUD) folded into the wrapper `AtlasService` over `DatabaseService` alone, while the ~750-line pure-geo half (admin0/admin1 boundary stores with the #1576 streaming builders, poly/box indexes, Nominatim geocoding with its shared throttle, the geocode/region caches incl. the import-time unref'd cleanup interval, the `geocodingInFlight` dedup set) moved verbatim to the plain module `nest/atlas/atlas-geo.ts` so the multi-MB caches stay process-global across the container + bridge instances; the 10-tool registrar `mcp/tools/atlas.ts` **plus all four atlas resources in `mcp/resources.ts`** moved to the decorator-driven `atlas.mcp.ts` (the `when:` atlas-addon gate is parity here — legacy tools/resources gated, REST deliberately doesn't; `resources.test.ts` retired with its last two cases, moved to the atlas suites); the plugin RPC host swapped its 9 atlas imports for the injected service (23rd constructor dep — journeys is the factory's last legacy domain import); a minimal 2-export `atlas.bridge.ts` served legacy `authService.getTravelStats` (the zero-bridge streak ends at five; the bridge died on schedule with the 2026-08 auth fold); the DTO ratchet cleared all three `AtlasController` allow-list entries)/vacayService (done 2026-07 — pulled forward as a zero-dependency frontier member, see migration-graph.md)/collectionsService (done 2026-08 — pulled forward as a frontier-ready member: the 1024-line saved-places core folded into the wrapper `CollectionsService` over DatabaseService + PermissionsService + RealtimeService (the `checkPermission` bridge import and `broadcastToUser` websocket import became injections; the `sendInvite` lazy notificationService `import()` stays, collab precedent; `deleteOldCollectionCover` re-anchored one directory deeper); the 25-tool registrar `mcp/tools/collections.ts` moved to the decorator-driven `collections.mcp.ts` — with NO `when:` addon gate, preserving the legacy registrar's addon-gate asymmetry — plus a new 23-case `tools-collections` characterization suite (the registrar was previously untested); the plugin RPC host swapped its 7 collections imports for the injected service (22nd constructor dep); **no bridge file anywhere**; the DTO ratchet cleared both collections allow-list entries (`reorder`, `delete-many`))/mapsService (done 2026-07 — pulled forward as step 4 of the dependency-honest order: the 1429-line geo core folded into the wrapper `MapsService`, pure helpers to `maps.helpers.ts`, the 3 geo MCP tools to the decorator-driven `maps.mcp.ts` (weather/airport tools stay in the legacy registrar), BookingImportService injects it, and a 3-export `maps.bridge.ts` serves placeEnrichment + the places registrar — transitService → placeService now unblocked)/transitService (done 2026-07 — pulled forward as step 5 of the dependency-honest order: the first fully SQL-free domain fold — the 333-line Transitous/MOTIS proxy became a dep-free `TransitService` (no `DatabaseService`); pure `deriveTransitStats` + mode whitelist + itinerary types moved to `transit.helpers.ts` so the downstream `transitItineraryService` needed no bridge; the whole 3-tool registrar moved to the decorator-driven `transit.mcp.ts`, its days/reservations bridge imports becoming injected `DaysService`/`ReservationsService` — zero bridge files; placeService → transitItineraryService followed, both done 2026-07/08), and the auth stack: authService (done 2026-08 — the 1497-line head of the auth → admin → oauth chain folded into the wrapper `AuthService` over DatabaseService + injected PermissionsService/AtlasService (retiring `atlas.bridge.ts` on schedule); the pure crypto half (backup-code hash/match/generate, `stripUserForClient`, key masking, the import-time `DUMMY_PASSWORD_HASH` timing equaliser + `avatarDir` mkdir, both documented parity exceptions) moved to the plain module `auth.helpers.ts`; `mfaSetupPending` + the per-email reset throttle (with its unref'd cleanup interval) stay module-scoped so the bridge and container instances share them; no MCP registrar existed and the plugin host never imported it, so neither surface changed — but the 15 in-container `*.mcp.ts` `isDemoUser` guards now inject `AuthService` (15 modules import AuthModule), with `atlas.mcp.ts` alone on the new 8-export `auth.bridge.ts` because the AuthModule→AtlasModule edge would otherwise close a cycle (places.mcp precedent); the bridge also serves `mcp/index.ts` token verification, the journey/notifications/transports registrars, and the still-legacy adminService/passkeyService; tests moved with IDs preserved (authService.test.ts → auth.helpers.test.ts, authServiceDb.test.ts → auth.service.test.ts + AUTH-BR-001…007 bridge cases), auth.e2e went DI-native (real bcrypt, real audit rows), oidc.e2e switched its dead path-mock for an instance spy). oidcService followed (done 2026-08 — the first frontier cash-in of the auth fold: the 508-line SSO module folded into the wrapper `OidcService` over DatabaseService + injected AuthService (the `resolveAuthToggles` `auth.bridge` import became the injection); no MCP surface, no plugin-host import, and no bridge at all — nothing outside the container consumes the domain, so the state/auth-code maps, their sweep intervals, and the discovery/JWKS caches became instance state with `onModuleDestroy` clearing the timers; `uuid`/`bcryptjs` lazy requires, the `invite_exhausted` sentinel transaction and every SQL string relocated verbatim; tests moved with OIDC-SVC IDs preserved, oidc.e2e and the integration suite switched their path mocks for `vi.spyOn(app.get(OidcService), …)` instance spies). passkeyService followed (done 2026-08 — the last frontier member of that pair: the 364-line WebAuthn module folded into a new `PasskeyService` in `nest/auth/` over DatabaseService + injected AuthService (the three `auth.bridge` imports resolved to the injection + plain `auth.helpers`/`avatarUrl` imports; `resolveWebauthnConfig` stays a plain helper import); no MCP surface, no plugin-host import, no bridge and no module state — the challenge store is DB-backed — so both in-container consumers just swapped to the injection (`PasskeyController`, and `AdminService.resetUserPasskeys` via `exports: [PasskeyService]` + an AdminModule→AuthModule import); the previously untested service gained a fresh PASSKEY-SVC-001…030 characterization suite with `@simplewebauthn/server` mocked at the verdict boundary), leaving oauthService — still an unblocked repoint-consumer of the bridge — plus adminService next in the chain. backupService also stays late: it owns closeDb/reinitialize lifecycle, which is deliberately outside the DI wrapper. Two special cases to keep in mind: airportService is lazily required by db/database.ts at boot (the flight-endpoint backfill) — whenever it migrates, that call should move into Nest's bootstrap instead, or you recreate the circular diff --git a/server/src/nest/README.md b/server/src/nest/README.md index 1e2459e02..139966658 100644 --- a/server/src/nest/README.md +++ b/server/src/nest/README.md @@ -29,8 +29,8 @@ mount to Nest and leaves the sibling trip routes (days, places, ...) on Express. - **DI-native services (legacy `src/services/*` deleted):** tags, categories, todo, packing, day-notes, trip-invite, assignments, share, settings, files, collab, vacay, reservations, day, permissions, audit, budget, trip, maps, - transit, place, transit-itinerary, collections, atlas, auth, oidc — see the - migration recipe below. + transit, place, transit-itinerary, collections, atlas, auth, oidc, passkey — + see the migration recipe below. ## Cross-cutting Foundation pieces @@ -367,7 +367,7 @@ import AuthModule) — except `atlas.mcp.ts`, which keeps a bridge import because AuthService injects AtlasService and the reverse module edge would close a cycle (places.mcp precedent). The 8-export `auth.bridge.ts` serves `mcp/index.ts` token verification, the three legacy tool registrars' -`isDemoUser`, and the un-migrated adminService/passkeyService. +`isDemoUser`, and the un-migrated adminService. Tests moved with IDs preserved: authService.test.ts → auth.helpers.test.ts, authServiceDb.test.ts → auth.service.test.ts (+ AUTH-BR-001…007 bridge delegation); auth.e2e converted DI-native — the 30-method whole-module mock @@ -394,6 +394,27 @@ cases); oidc.e2e swapped its whole-module path mock for `vi.spyOn(app.get(OidcService), …)` instance spies; the integration suite spies the four HTTP methods on the container instance while driving the real state maps on that same instance.) +passkeyService followed (the last frontier member of the auth stack: the +364-line WebAuthn module folded into a new `nest/auth/passkey.service.ts` +`PasskeyService` over DatabaseService + injected AuthService — its three +`auth.bridge` imports resolved to `this.auth.generateToken` plus plain +`stripUserForClient`/`avatarUrl` helper imports, and `resolveWebauthnConfig` +stays a plain `services/webauthnConfig` import (the helper also feeds +auth.service's `isPasskeyConfigured`). No MCP registrar, no plugin-host +import, and **no bridge at all** — both consumers were already in-container: +`PasskeyController` swapped its `import *` shim for the injection, and +`AdminService` swapped its `adminResetPasskeys` function import for the +injected service (`AuthModule` now exports PasskeyService and `AdminModule` +imports AuthModule — the todo→TripsService in-container precedent). No +module-level mutable state existed to preserve: the challenge store is +DB-backed (`webauthn_challenges`, single-use `DELETE … RETURNING` claim, +5-min TTL), so the fold is a plain stateless injectable — every SQL string, +error string and the counter/login-bookkeeping transaction relocated +verbatim. Tests: the legacy module had **no service-level suite**, so +PASSKEY-SVC-001…030 were written fresh with the fold (characterization over a +real `:memory:` DB, `@simplewebauthn/server` mocked at the ceremony-verdict +boundary — the repo's first such mock); auth-guard.test.ts's PasskeyController +block swapped its path mock for a constructor stub.) Repeat these steps per service (next up: **the notifications fan-in** — per the dependency-honest order in diff --git a/server/src/nest/admin/admin.module.ts b/server/src/nest/admin/admin.module.ts index 9af8b8c60..af866a5ce 100644 --- a/server/src/nest/admin/admin.module.ts +++ b/server/src/nest/admin/admin.module.ts @@ -5,9 +5,11 @@ import { PluginsModule } from '../plugins/plugins.module'; import { SettingsModule } from '../settings/settings.module'; import { AuditModule } from '../audit/audit.module'; import { AddonsModule } from '../addons/addons.module'; +// AuthModule exports PasskeyService for the admin passkey-reset endpoint. +import { AuthModule } from '../auth/auth.module'; @Module({ - imports: [PluginsModule, SettingsModule, AuditModule, AddonsModule], + imports: [PluginsModule, SettingsModule, AuditModule, AddonsModule, AuthModule], controllers: [AdminController], providers: [AdminService], }) diff --git a/server/src/nest/admin/admin.service.ts b/server/src/nest/admin/admin.service.ts index be9d5bc1f..79dd7ce1d 100644 --- a/server/src/nest/admin/admin.service.ts +++ b/server/src/nest/admin/admin.service.ts @@ -4,7 +4,7 @@ import { AddonsService } from '../addons/addons.service'; import { SettingsService } from '../settings/settings.service'; import { invalidateMcpSessions } from '../../mcp'; import { getPreferencesMatrix, setAdminPreferences } from '../../services/notificationPreferencesService'; -import { adminResetPasskeys } from '../../services/passkeyService'; +import { PasskeyService } from '../auth/passkey.service'; /** * Thin Nest wrapper around the existing admin service (+ the settings, @@ -17,6 +17,7 @@ export class AdminService { constructor( private readonly settings: SettingsService, private readonly addons: AddonsService, + private readonly passkeys: PasskeyService, ) {} // Users @@ -24,7 +25,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)); } + resetUserPasskeys(id: string) { return this.passkeys.adminResetPasskeys(Number(id)); } getStats() { return svc.getStats(); } getPermissions() { return svc.getPermissions(); } diff --git a/server/src/nest/auth/auth.bridge.ts b/server/src/nest/auth/auth.bridge.ts index 9412d2967..12f2f4308 100644 --- a/server/src/nest/auth/auth.bridge.ts +++ b/server/src/nest/auth/auth.bridge.ts @@ -9,8 +9,8 @@ import { User } from '../../types'; * Non-Nest entry point for the auth domain — for code running OUTSIDE the * Nest container: the legacy MCP transport (mcp/index.ts token verification), * the legacy tool registrars (mcp/tools/journey|notifications|transports), the - * not-yet-migrated services/adminService and - * services/passkeyService, and — as the one in-container exception — + * not-yet-migrated services/adminService, and — as the one in-container + * exception — * atlas.mcp.ts, which cannot inject AuthService without closing an * AuthModule↔AtlasModule cycle (AuthService injects AtlasService for * getTravelStats; same documented trade-off as places.mcp.ts keeping its diff --git a/server/src/nest/auth/auth.module.ts b/server/src/nest/auth/auth.module.ts index fe48899fa..2cf2a241e 100644 --- a/server/src/nest/auth/auth.module.ts +++ b/server/src/nest/auth/auth.module.ts @@ -3,6 +3,7 @@ import { AuthPublicController } from './auth-public.controller'; import { AuthController } from './auth.controller'; import { PasskeyController } from './passkey.controller'; import { AuthService } from './auth.service'; +import { PasskeyService } from './passkey.service'; import { RateLimitService } from './rate-limit.service'; import { AuditModule } from '../audit/audit.module'; import { PermissionsModule } from '../permissions/permissions.module'; @@ -17,13 +18,13 @@ import { AtlasModule } from '../atlas/atlas.module'; * PermissionsModule feeds getAppConfig's permissions block; AtlasModule feeds * getTravelStats' hidden-countries subtraction. AuthService is exported for * the in-container consumers (the domain *.mcp.ts demo guards, OidcService, - * PasskeyEnabledGuard); everything outside the container goes through - * auth.bridge.ts. + * PasskeyEnabledGuard); PasskeyService for AdminService's passkey reset; + * everything outside the container goes through auth.bridge.ts. */ @Module({ imports: [AuditModule, PermissionsModule, AtlasModule], controllers: [AuthPublicController, AuthController, PasskeyController], - providers: [AuthService, RateLimitService], - exports: [AuthService], + providers: [AuthService, PasskeyService, RateLimitService], + exports: [AuthService, PasskeyService], }) export class AuthModule {} diff --git a/server/src/nest/auth/passkey.controller.ts b/server/src/nest/auth/passkey.controller.ts index d5746216b..9ff5cd965 100644 --- a/server/src/nest/auth/passkey.controller.ts +++ b/server/src/nest/auth/passkey.controller.ts @@ -7,7 +7,7 @@ import { CurrentUser } from './current-user.decorator'; import { setAuthCookie } from '../../services/cookie'; import { getClientIp } from '../audit/client-ip'; import { AuditService } from '../audit/audit.service'; -import * as passkey from '../../services/passkeyService'; +import { PasskeyService } from './passkey.service'; import type { User } from '../../types'; const WINDOW = 15 * 60 * 1000; @@ -28,7 +28,11 @@ const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); */ @Controller('api/auth/passkey') export class PasskeyController { - constructor(private readonly rl: RateLimitService, private readonly audit: AuditService) {} + constructor( + private readonly rl: RateLimitService, + private readonly audit: AuditService, + private readonly passkeys: PasskeyService, + ) {} private limit(bucket: string, req: Request, max: number): void { if (!this.rl.check(bucket, req.ip || 'unknown', max, WINDOW, Date.now())) { @@ -42,7 +46,7 @@ export class PasskeyController { @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); + const result = await this.passkeys.passkeyRegisterOptions(user.id, body?.password); if (result.error) throw new HttpException({ error: result.error }, result.status!); return result.options; } @@ -51,7 +55,7 @@ export class PasskeyController { @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]); + const result = await this.passkeys.passkeyRegisterVerify(user.id, body as Parameters[1]); if (result.error) throw new HttpException({ error: result.error }, result.status!); this.audit.writeAudit({ userId: user.id, action: 'user.passkey_register', ip: getClientIp(req) }); return { success: true, credential: result.credential }; @@ -63,7 +67,7 @@ export class PasskeyController { @UseGuards(PasskeyEnabledGuard) async loginOptions(@Req() req: Request) { this.limit('login', req, 10); - const result = await passkey.passkeyLoginOptions(); + const result = await this.passkeys.passkeyLoginOptions(); if (result.error) throw new HttpException({ error: result.error }, result.status!); return result.options; } @@ -74,7 +78,7 @@ export class PasskeyController { 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]); + const result = await this.passkeys.passkeyLoginVerify(body as Parameters[0]); if (result.auditAction) { this.audit.writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req) }); } @@ -92,13 +96,13 @@ export class PasskeyController { @Get('credentials') @UseGuards(JwtAuthGuard) list(@CurrentUser() user: User) { - return { credentials: passkey.listPasskeys(user.id) }; + return { credentials: this.passkeys.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); + const result = this.passkeys.renamePasskey(user.id, id, body?.name); if (result.error) throw new HttpException({ error: result.error }, result.status!); return { success: true }; } @@ -107,7 +111,7 @@ export class PasskeyController { @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); + const result = this.passkeys.deletePasskey(user.id, id, body?.password); if (result.error) throw new HttpException({ error: result.error }, result.status!); this.audit.writeAudit({ userId: user.id, action: 'user.passkey_delete', resource: String(id), ip: getClientIp(req) }); return { success: true }; diff --git a/server/src/nest/auth/passkey.service.ts b/server/src/nest/auth/passkey.service.ts new file mode 100644 index 000000000..c8e86a2f0 --- /dev/null +++ b/server/src/nest/auth/passkey.service.ts @@ -0,0 +1,385 @@ +import { Injectable } from '@nestjs/common'; +import bcrypt from 'bcryptjs'; +import { + generateRegistrationOptions, + verifyRegistrationResponse, + generateAuthenticationOptions, + verifyAuthenticationResponse, + type AuthenticatorTransportFuture, +} from '@simplewebauthn/server'; +import { resolveWebauthnConfig } from '../../services/webauthnConfig'; +import { avatarUrl } from '../../services/avatarUrl'; +import { stripUserForClient } from './auth.helpers'; +import { AuthService } from './auth.service'; +import { DatabaseService } from '../database/database.service'; +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; +} + +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'; +} + +/** + * WebAuthn (passkey) registration, discoverable-credential login and + * credential management. No instance state — the challenge store is DB-backed + * (single-use, TTL'd) precisely so it survives restarts and is shared across + * processes. + */ +@Injectable() +export class PasskeyService { + constructor( + private readonly db: DatabaseService, + private readonly auth: AuthService, + ) {} + + // ------------------------------------------------------------------------- + // Challenge store (DB-backed, single-use, TTL'd) + // ------------------------------------------------------------------------- + + private purgeExpiredChallenges(now: number): void { + this.db.run('DELETE FROM webauthn_challenges WHERE expires_at < ?', now); + } + + private storeChallenge(challenge: string, userId: number | null, type: 'registration' | 'authentication', now: number): void { + this.db.run( + 'INSERT INTO webauthn_challenges (challenge, user_id, type, expires_at) VALUES (?, ?, ?, ?)', + 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). + */ + private claimChallenge(challenge: string, type: 'registration' | 'authentication', now: number): { user_id: number | null } | null { + const row = this.db.get<{ user_id: number | null }>( + 'DELETE FROM webauthn_challenges WHERE challenge = ? AND type = ? AND expires_at > ? RETURNING user_id', + challenge, type, now, + ); + return row ?? null; + } + + // ------------------------------------------------------------------------- + // Registration (authenticated — from Settings, password re-auth required) + // ------------------------------------------------------------------------- + + async passkeyRegisterOptions( + userId: number, + password: string | undefined, + ): Promise<{ error?: string; status?: number; options?: Awaited> }> { + const cfg = resolveWebauthnConfig(); + if (!cfg) return { ...NOT_CONFIGURED }; + + const user = this.db.get('SELECT * FROM users WHERE id = ?', userId); + 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 = this.db.all<{ credential_id: string; transports: string | null }>( + 'SELECT credential_id, transports FROM webauthn_credentials WHERE user_id = ?', userId, + ); + + const now = Date.now(); + this.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, + }); + + this.storeChallenge(options.challenge, userId, 'registration', now); + return { options }; + } + + async 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 = this.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 (this.db.get('SELECT id FROM webauthn_credentials WHERE credential_id = ?', credential.id)) { + return { error: 'This passkey is already registered.', status: 409 }; + } + + const name = sanitizeName(body?.name) || defaultCredentialName(credentialDeviceType); + try { + this.db.run( + `INSERT INTO webauthn_credentials + (user_id, credential_id, public_key, counter, transports, device_type, backed_up, name, aaguid, last_used_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)`, + 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 = this.db.get<{ backed_up: number } & Record>( + 'SELECT id, name, device_type, backed_up, created_at, last_used_at FROM webauthn_credentials WHERE credential_id = ?', + credential.id, + ) as { backed_up: number } & Record; + return { success: true, credential: { ...created, backed_up: created.backed_up === 1 } }; + } + + // ------------------------------------------------------------------------- + // Authentication (public — primary, discoverable-credential login) + // ------------------------------------------------------------------------- + + async passkeyLoginOptions(): Promise<{ + error?: string; + status?: number; + options?: Awaited>; + }> { + const cfg = resolveWebauthnConfig(); + if (!cfg) return { ...NOT_CONFIGURED }; + + const now = Date.now(); + this.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. + }); + + this.storeChallenge(options.challenge, null, 'authentication', now); + return { options }; + } + + async 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 (!this.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 = this.db.get('SELECT * FROM webauthn_credentials WHERE credential_id = ?', credId); + 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 = this.db.get('SELECT * FROM users WHERE id = ?', cred.user_id); + if (!user) return { ...AUTH_FAILED }; + + // Persist the new counter + last-used and bump login bookkeeping atomically. + this.db.transaction((conn) => { + conn.prepare('UPDATE webauthn_credentials SET counter = ?, last_used_at = CURRENT_TIMESTAMP WHERE id = ?').run(newCounter, cred.id); + conn.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 = this.auth.generateToken(user); + const userSafe = stripUserForClient(user) as Record; + return { token, user: { ...userSafe, avatar_url: avatarUrl(user) }, auditUserId: Number(user.id) }; + } + + // ------------------------------------------------------------------------- + // Management (authenticated, owner-scoped) + // ------------------------------------------------------------------------- + + listPasskeys(userId: number): Array> { + const rows = this.db.all<{ backed_up: number } & Record>( + 'SELECT id, name, device_type, backed_up, created_at, last_used_at FROM webauthn_credentials WHERE user_id = ? ORDER BY created_at DESC', + userId, + ); + return rows.map((r) => ({ ...r, backed_up: r.backed_up === 1 })); + } + + 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 = this.db.run('UPDATE webauthn_credentials SET name = ? WHERE id = ? AND user_id = ?', cleanName, Number(id), userId); + if (result.changes === 0) return { error: 'Passkey not found', status: 404 }; + return { success: true }; + } + + 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 = this.db.get<{ password_hash: string }>('SELECT password_hash FROM users WHERE id = ?', userId); + if (!user || !user.password_hash || !password || !bcrypt.compareSync(password, user.password_hash)) { + return { error: 'Incorrect password', status: 401 }; + } + const result = this.db.run('DELETE FROM webauthn_credentials WHERE id = ? AND user_id = ?', 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). */ + adminResetPasskeys(targetUserId: number): { error?: string; status?: number; success?: boolean; deleted?: number; email?: string } { + const target = this.db.get<{ id: number; email: string }>('SELECT id, email FROM users WHERE id = ?', targetUserId); + if (!target) return { error: 'User not found', status: 404 }; + const result = this.db.run('DELETE FROM webauthn_credentials WHERE user_id = ?', targetUserId); + return { success: true, deleted: result.changes, email: target.email }; + } +} diff --git a/server/src/services/passkeyService.ts b/server/src/services/passkeyService.ts deleted file mode 100644 index a5ec4abfc..000000000 --- a/server/src/services/passkeyService.ts +++ /dev/null @@ -1,364 +0,0 @@ -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 '../nest/auth/auth.bridge'; -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/tests/helpers/test-db.ts b/server/tests/helpers/test-db.ts index ce36498b1..37c48285f 100644 --- a/server/tests/helpers/test-db.ts +++ b/server/tests/helpers/test-db.ts @@ -104,6 +104,8 @@ const RESET_TABLES = [ 'invite_tokens', 'tags', 'app_settings', + 'webauthn_challenges', + 'webauthn_credentials', 'users', ]; diff --git a/server/tests/unit/nest/auth-guard.test.ts b/server/tests/unit/nest/auth-guard.test.ts index b5c915548..9ff9a4f8f 100644 --- a/server/tests/unit/nest/auth-guard.test.ts +++ b/server/tests/unit/nest/auth-guard.test.ts @@ -6,15 +6,6 @@ vi.mock('../../../src/middleware/auth', () => ({ extractToken: vi.fn(), verifyJw vi.mock('../../../src/services/cookie', () => ({ setAuthCookie: vi.fn() })); vi.mock('../../../src/nest/audit/client-ip', () => ({ getClientIp: vi.fn(() => '1.2.3.4') })); vi.mock('../../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn() })); -vi.mock('../../../src/services/passkeyService', () => ({ - passkeyRegisterOptions: vi.fn(), - passkeyRegisterVerify: vi.fn(), - passkeyLoginOptions: vi.fn(), - passkeyLoginVerify: vi.fn(), - listPasskeys: vi.fn(), - renamePasskey: vi.fn(), - deletePasskey: vi.fn(), -})); import { JwtAuthGuard } from '../../../src/nest/auth/jwt-auth.guard'; import { CookieAuthGuard } from '../../../src/nest/auth/cookie-auth.guard'; @@ -28,13 +19,24 @@ import { RateLimitService } from '../../../src/nest/auth/rate-limit.service'; // wrapper keeps the historical construction sites positional. const writeAudit = vi.fn(); const audit = { writeAudit } as unknown as AuditService; -const pc = (limiter: RateLimitService) => new PasskeyController(limiter, audit); +// PasskeyService is constructor-injected since the passkey DI fold; a stub +// object replaces the old services/passkeyService path mock. +const passkey = { + passkeyRegisterOptions: vi.fn(), + passkeyRegisterVerify: vi.fn(), + passkeyLoginOptions: vi.fn(), + passkeyLoginVerify: vi.fn(), + listPasskeys: vi.fn(), + renamePasskey: vi.fn(), + deletePasskey: vi.fn(), +}; +const pc = (limiter: RateLimitService) => new PasskeyController(limiter, audit, passkey as unknown as PasskeyService); import { CurrentUser } from '../../../src/nest/auth/current-user.decorator'; import { extractToken, verifyJwtAndLoadUser } from '../../../src/middleware/auth'; import type { AuthService } from '../../../src/nest/auth/auth.service'; +import type { PasskeyService } from '../../../src/nest/auth/passkey.service'; import { setAuthCookie } from '../../../src/services/cookie'; import type { AuditService } from '../../../src/nest/audit/audit.service'; -import * as passkey from '../../../src/services/passkeyService'; import type { User } from '../../../src/types'; const user = { id: 1, username: 'u', role: 'user', email: 'u@example.test' } as User; @@ -204,52 +206,52 @@ describe('PasskeyController', () => { function rl(): RateLimitService { return new RateLimitService(); } it('register/options maps a service error, else returns the options', async () => { - vi.mocked(passkey.passkeyRegisterOptions).mockResolvedValue({ error: 'Incorrect password', status: 401 }); + passkey.passkeyRegisterOptions.mockResolvedValue({ error: 'Incorrect password', status: 401 }); expect(await thrownAsync(() => pc(rl()).registerOptions(user, { password: 'x' }, req))).toEqual({ status: 401, body: { error: 'Incorrect password' } }); - vi.mocked(passkey.passkeyRegisterOptions).mockResolvedValue({ options: { challenge: 'c' } as never }); + passkey.passkeyRegisterOptions.mockResolvedValue({ options: { challenge: 'c' } as never }); expect(await pc(rl()).registerOptions(user, { password: 'p' }, req)).toEqual({ challenge: 'c' }); }); it('register/verify maps a service error, else audits and returns the credential', async () => { - vi.mocked(passkey.passkeyRegisterVerify).mockResolvedValue({ error: 'Verification failed', status: 400 } as never); + passkey.passkeyRegisterVerify.mockResolvedValue({ error: 'Verification failed', status: 400 } as never); expect(await thrownAsync(() => pc(rl()).registerVerify(user, {}, req))).toEqual({ status: 400, body: { error: 'Verification failed' } }); - vi.mocked(passkey.passkeyRegisterVerify).mockResolvedValue({ credential: { id: 'cr' } } as never); + passkey.passkeyRegisterVerify.mockResolvedValue({ credential: { id: 'cr' } } as never); expect(await pc(rl()).registerVerify(user, {}, req)).toEqual({ success: true, credential: { id: 'cr' } }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.passkey_register' })); }); it('login/options maps a service error, else returns the options', async () => { - vi.mocked(passkey.passkeyLoginOptions).mockResolvedValue({ error: 'Not configured', status: 503 } as never); + passkey.passkeyLoginOptions.mockResolvedValue({ error: 'Not configured', status: 503 } as never); expect(await thrownAsync(() => pc(rl()).loginOptions(req))).toEqual({ status: 503, body: { error: 'Not configured' } }); - vi.mocked(passkey.passkeyLoginOptions).mockResolvedValue({ options: { challenge: 'd' } } as never); + passkey.passkeyLoginOptions.mockResolvedValue({ options: { challenge: 'd' } } as never); expect(await pc(rl()).loginOptions(req)).toEqual({ challenge: 'd' }); }); it('login/verify audits a failure then maps the error, padding latency', async () => { - vi.mocked(passkey.passkeyLoginVerify).mockResolvedValue({ error: 'No match', status: 401, auditAction: 'user.login_fail', auditUserId: null } as never); + passkey.passkeyLoginVerify.mockResolvedValue({ error: 'No match', status: 401, auditAction: 'user.login_fail', auditUserId: null } as never); expect(await thrownAsync(() => pc(rl()).loginVerify({}, req, res))).toEqual({ status: 401, body: { error: 'No match' } }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.login_fail' })); }, 10000); it('login/verify sets the session cookie and audits login on success', async () => { - vi.mocked(passkey.passkeyLoginVerify).mockResolvedValue({ token: 'tk', user, auditUserId: 1 } as never); + passkey.passkeyLoginVerify.mockResolvedValue({ token: 'tk', user, auditUserId: 1 } as never); expect(await pc(rl()).loginVerify({}, req, res)).toEqual({ token: 'tk', user }); expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk', req); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.login', details: { method: 'passkey' } })); }, 10000); it('credentials: list, rename (error + success), delete (error + success)', () => { - vi.mocked(passkey.listPasskeys).mockReturnValue([{ id: 'a' }]); + passkey.listPasskeys.mockReturnValue([{ id: 'a' }]); expect(pc(rl()).list(user)).toEqual({ credentials: [{ id: 'a' }] }); - vi.mocked(passkey.renamePasskey).mockReturnValue({ error: 'Not found', status: 404 }); + passkey.renamePasskey.mockReturnValue({ error: 'Not found', status: 404 }); expect(thrown(() => pc(rl()).rename(user, 'cid', { name: 'x' }))).toEqual({ status: 404, body: { error: 'Not found' } }); - vi.mocked(passkey.renamePasskey).mockReturnValue({ success: true }); + passkey.renamePasskey.mockReturnValue({ success: true }); expect(pc(rl()).rename(user, 'cid', { name: 'x' })).toEqual({ success: true }); - vi.mocked(passkey.deletePasskey).mockReturnValue({ error: 'Incorrect password', status: 401 }); + passkey.deletePasskey.mockReturnValue({ error: 'Incorrect password', status: 401 }); expect(thrown(() => pc(rl()).remove(user, 'cid', { password: 'x' }, req))).toEqual({ status: 401, body: { error: 'Incorrect password' } }); - vi.mocked(passkey.deletePasskey).mockReturnValue({ success: true }); + passkey.deletePasskey.mockReturnValue({ success: true }); expect(pc(rl()).remove(user, 'cid', { password: 'p' }, req)).toEqual({ success: true }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.passkey_delete' })); }); @@ -266,7 +268,7 @@ describe('PasskeyController', () => { }); it('falls back to the "unknown" rate-limit key when req.ip is absent', async () => { - vi.mocked(passkey.passkeyLoginOptions).mockResolvedValue({ options: { challenge: 'z' } } as never); + passkey.passkeyLoginOptions.mockResolvedValue({ options: { challenge: 'z' } } as never); const noIp = {} as Request; expect(await pc(rl()).loginOptions(noIp)).toEqual({ challenge: 'z' }); }); diff --git a/server/tests/unit/nest/passkey.service.test.ts b/server/tests/unit/nest/passkey.service.test.ts new file mode 100644 index 000000000..4788475d1 --- /dev/null +++ b/server/tests/unit/nest/passkey.service.test.ts @@ -0,0 +1,561 @@ +/** + * Unit tests for the DI-native PasskeyService — PASSKEY-SVC-001 through + * PASSKEY-SVC-030. Written fresh with the DI fold: the legacy + * services/passkeyService had no service-level suite, so these characterize + * the relocated behavior (challenge store semantics, WebAuthn ceremony error + * paths, clone detection, management CRUD) over a real in-memory SQLite DB. + * The @simplewebauthn/server ceremony functions are mocked — the library's + * crypto is not under test, the service's handling of its verdicts is. + * Constructed directly (no TestingModule, repo convention). + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; + +// ── DB setup ────────────────────────────────────────────────────────────────── + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); + // Passkeys are trip-free, so the trip-access helpers are inert stubs. + const mock = { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: () => undefined, + isOwner: () => false, + }; + return { testDb: db, dbMock: mock }; +}); + +vi.mock('../../../src/db/database', () => dbMock); +vi.mock('../../../src/config', () => ({ + JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + SESSION_DURATION_SECONDS: 86400, + SESSION_DURATION_REMEMBER_SECONDS: 2592000, + updateJwtSecret: () => {}, +})); + +// Construction insurance for the injected AuthService's import graph (same set +// as oidc.service.test.ts / auth.service.test.ts). +vi.mock('../../../src/services/mfaCrypto', () => ({ + encryptMfaSecret: vi.fn((s) => `enc:${s}`), + decryptMfaSecret: vi.fn((s: string) => s.replace('enc:', '')), +})); +vi.mock('../../../src/services/apiKeyCrypto', () => ({ + decrypt_api_key: vi.fn((v) => v), + maybe_encrypt_api_key: vi.fn((v) => v), + mask_stored_api_key: vi.fn((v: string | null | undefined) => (v ? '••••••••' : null)), + encrypt_api_key: vi.fn((v) => v), +})); +vi.mock('../../../src/services/ephemeralTokens', () => ({ createEphemeralToken: vi.fn() })); +vi.mock('../../../src/services/notifications', () => ({ sendPasswordResetEmail: vi.fn() })); +vi.mock('../../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn() })); +vi.mock('../../../src/scheduler', () => ({ + startTripReminders: vi.fn(), + buildCronExpression: vi.fn(), + loadSettings: vi.fn(() => ({ enabled: false })), + VALID_INTERVALS: ['daily', 'weekly', 'monthly'], +})); + +// The RP config resolver is a plain helper with its own suite +// (webauthnConfig.test.ts); here it is a switch for the configured/ +// not-configured branches. isPasskeyConfigured stays real for auth.service. +const { resolveWebauthnConfigMock } = vi.hoisted(() => ({ resolveWebauthnConfigMock: vi.fn() })); +vi.mock('../../../src/services/webauthnConfig', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, resolveWebauthnConfig: resolveWebauthnConfigMock }; +}); + +// The WebAuthn ceremony verdicts are the mock boundary — the service must +// persist ONLY what the verifier vouches for, so the tests hand it verdicts. +const { swMock } = vi.hoisted(() => ({ + swMock: { + generateRegistrationOptions: vi.fn(), + verifyRegistrationResponse: vi.fn(), + generateAuthenticationOptions: vi.fn(), + verifyAuthenticationResponse: vi.fn(), + }, +})); +vi.mock('@simplewebauthn/server', () => swMock); + +import jwtLib from 'jsonwebtoken'; +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { resetTestDb } from '../../helpers/test-db'; +import { createUser } from '../../helpers/factories'; +import { DatabaseService } from '../../../src/nest/database/database.service'; +import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; +import { AtlasService } from '../../../src/nest/atlas/atlas.service'; +import { AuthService } from '../../../src/nest/auth/auth.service'; +import { PasskeyService } from '../../../src/nest/auth/passkey.service'; + +const auth = new AuthService( + new DatabaseService(testDb), + new PermissionsService(new DatabaseService(testDb)), + new AtlasService(new DatabaseService(testDb)), +); +const svc = new PasskeyService(new DatabaseService(testDb), auth); + +const CFG = { rpID: 'trek.example.com', rpName: 'TREK', origins: ['https://trek.example.com'] }; +const NOT_CONFIGURED_ERROR = 'Passkey login is not configured for this server.'; + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + vi.clearAllMocks(); + resolveWebauthnConfigMock.mockReturnValue(CFG); + resetTestDb(testDb); +}); + +afterAll(() => { + testDb.close(); +}); + +// ── helpers ─────────────────────────────────────────────────────────────────── + +/** A response payload whose clientDataJSON echoes the given challenge. */ +function ceremonyResponse(challenge: string, extra: Record = {}) { + return { + response: { clientDataJSON: Buffer.from(JSON.stringify({ challenge }), 'utf8').toString('base64url') }, + ...extra, + }; +} + +function seedChallenge(challenge: string, userId: number | null, type: 'registration' | 'authentication', expiresAt = Date.now() + 60_000) { + testDb.prepare('INSERT INTO webauthn_challenges (challenge, user_id, type, expires_at) VALUES (?, ?, ?, ?)') + .run(challenge, userId, type, expiresAt); +} + +function challengeCount(): number { + return (testDb.prepare('SELECT COUNT(*) AS n FROM webauthn_challenges').get() as { n: number }).n; +} + +let _credSeq = 0; +function insertCredential(userId: number, overrides: Record = {}) { + _credSeq++; + const row = { + credential_id: `cred-${_credSeq}`, + public_key: Buffer.from([1, 2, 3]), + counter: 0, + transports: null, + device_type: 'singleDevice', + backed_up: 0, + name: `Passkey ${_credSeq}`, + aaguid: null, + ...overrides, + }; + const result = testDb.prepare( + `INSERT INTO webauthn_credentials + (user_id, credential_id, public_key, counter, transports, device_type, backed_up, name, aaguid) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run(userId, row.credential_id, row.public_key, row.counter, row.transports, row.device_type, row.backed_up, row.name, row.aaguid); + return { id: Number(result.lastInsertRowid), ...row }; +} + +function registrationVerdict(overrides: Record = {}, credentialOverrides: Record = {}) { + return { + verified: true, + registrationInfo: { + credential: { + id: 'new-cred', + publicKey: new Uint8Array([9, 9, 9]), + counter: 0, + transports: ['internal'], + ...credentialOverrides, + }, + credentialDeviceType: 'singleDevice', + credentialBackedUp: false, + aaguid: 'aaguid-1', + ...overrides, + }, + }; +} + +// ── passkeyRegisterOptions ──────────────────────────────────────────────────── + +describe('passkeyRegisterOptions', () => { + it('PASSKEY-SVC-001: returns the not-configured 400 when no RP config resolves', async () => { + resolveWebauthnConfigMock.mockReturnValue(null); + expect(await svc.passkeyRegisterOptions(1, 'pw')).toEqual({ error: NOT_CONFIGURED_ERROR, status: 400 }); + }); + + it('PASSKEY-SVC-002: 404s for an unknown user', async () => { + expect(await svc.passkeyRegisterOptions(999_999, 'pw')).toEqual({ error: 'User not found', status: 404 }); + }); + + it('PASSKEY-SVC-003: requires the current password (missing and wrong both 401)', async () => { + const { user, password } = createUser(testDb); + expect(await svc.passkeyRegisterOptions(user.id, undefined)).toEqual({ error: 'Incorrect password', status: 401 }); + expect(await svc.passkeyRegisterOptions(user.id, `${password}x`)).toEqual({ error: 'Incorrect password', status: 401 }); + }); + + it('PASSKEY-SVC-004: generates options excluding existing credentials and stores the challenge', async () => { + const { user, password } = createUser(testDb); + insertCredential(user.id, { credential_id: 'cred-a', transports: JSON.stringify(['usb', 'nfc']) }); + insertCredential(user.id, { credential_id: 'cred-b', transports: 'not-json' }); + seedChallenge('stale', user.id, 'registration', Date.now() - 1000); // purged on the way in + swMock.generateRegistrationOptions.mockResolvedValue({ challenge: 'reg-chal' }); + + const result = await svc.passkeyRegisterOptions(user.id, password); + expect(result).toEqual({ options: { challenge: 'reg-chal' } }); + + const call = swMock.generateRegistrationOptions.mock.calls[0][0]; + expect(call.rpID).toBe(CFG.rpID); + expect(call.rpName).toBe(CFG.rpName); + expect(call.userName).toBe(user.email); + expect(call.attestationType).toBe('none'); + expect(call.supportedAlgorithmIDs).toEqual([-8, -7, -257]); + expect(call.authenticatorSelection).toEqual({ residentKey: 'preferred', userVerification: 'required' }); + expect(call.excludeCredentials).toEqual([ + { id: 'cred-a', transports: ['usb', 'nfc'] }, + { id: 'cred-b', transports: undefined }, // unparseable transports degrade to undefined + ]); + + const stored = testDb.prepare('SELECT * FROM webauthn_challenges').all() as Array>; + expect(stored).toHaveLength(1); // the expired row was purged + expect(stored[0]).toMatchObject({ challenge: 'reg-chal', user_id: user.id, type: 'registration' }); + expect(Number(stored[0].expires_at)).toBeGreaterThan(Date.now()); + }); +}); + +// ── passkeyRegisterVerify ───────────────────────────────────────────────────── + +describe('passkeyRegisterVerify', () => { + it('PASSKEY-SVC-005: returns the not-configured 400 when no RP config resolves', async () => { + resolveWebauthnConfigMock.mockReturnValue(null); + expect(await svc.passkeyRegisterVerify(1, { attestationResponse: {} })).toEqual({ error: NOT_CONFIGURED_ERROR, status: 400 }); + }); + + it('PASSKEY-SVC-006: 400s on a missing or undecodable attestation response', async () => { + expect(await svc.passkeyRegisterVerify(1, {})).toEqual({ error: 'Invalid registration response', status: 400 }); + expect(await svc.passkeyRegisterVerify(1, { attestationResponse: { response: { clientDataJSON: '!!!not-base64-json' } } })) + .toEqual({ error: 'Invalid registration response', status: 400 }); + }); + + it('PASSKEY-SVC-007: 400s on an unknown or expired challenge', async () => { + const { user } = createUser(testDb); + expect(await svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('never-stored') })) + .toEqual({ error: 'Registration challenge expired. Please try again.', status: 400 }); + seedChallenge('expired', user.id, 'registration', Date.now() - 1); + expect(await svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('expired') })) + .toEqual({ error: 'Registration challenge expired. Please try again.', status: 400 }); + }); + + it("PASSKEY-SVC-008: a challenge claimed by a different user fails — and the claim is single-use", async () => { + const { user: alice } = createUser(testDb); + const { user: bob } = createUser(testDb); + seedChallenge('cross', alice.id, 'registration'); + expect(await svc.passkeyRegisterVerify(bob.id, { attestationResponse: ceremonyResponse('cross') })) + .toEqual({ error: 'Registration challenge expired. Please try again.', status: 400 }); + // The mismatched claim still consumed the row: the rightful owner can't use it either. + expect(challengeCount()).toBe(0); + expect(await svc.passkeyRegisterVerify(alice.id, { attestationResponse: ceremonyResponse('cross') })) + .toEqual({ error: 'Registration challenge expired. Please try again.', status: 400 }); + }); + + it('PASSKEY-SVC-009: maps a throwing or unverified verifier to the generic 400', async () => { + const { user } = createUser(testDb); + seedChallenge('c1', user.id, 'registration'); + swMock.verifyRegistrationResponse.mockRejectedValue(new Error('boom')); + expect(await svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('c1') })) + .toEqual({ error: 'Could not register this passkey.', status: 400 }); + + seedChallenge('c2', user.id, 'registration'); + swMock.verifyRegistrationResponse.mockResolvedValue({ verified: false }); + expect(await svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('c2') })) + .toEqual({ error: 'Could not register this passkey.', status: 400 }); + }); + + it('PASSKEY-SVC-010: 409s when the credential id is already registered', async () => { + const { user } = createUser(testDb); + insertCredential(user.id, { credential_id: 'dup-cred' }); + seedChallenge('c3', user.id, 'registration'); + swMock.verifyRegistrationResponse.mockResolvedValue(registrationVerdict({}, { id: 'dup-cred' })); + expect(await svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('c3') })) + .toEqual({ error: 'This passkey is already registered.', status: 409 }); + }); + + it('PASSKEY-SVC-011: persists the verifier-vouched credential and returns it re-selected', async () => { + const { user } = createUser(testDb); + seedChallenge('c4', user.id, 'registration'); + swMock.verifyRegistrationResponse.mockResolvedValue(registrationVerdict({}, { counter: 7 })); + + const result = await svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('c4'), name: ' My Key ' }); + expect(result.success).toBe(true); + expect(result.credential).toMatchObject({ name: 'My Key', device_type: 'singleDevice', backed_up: false, last_used_at: null }); + + const row = testDb.prepare('SELECT * FROM webauthn_credentials WHERE credential_id = ?').get('new-cred') as Record; + expect(row).toMatchObject({ user_id: user.id, counter: 7, transports: JSON.stringify(['internal']), backed_up: 0, aaguid: 'aaguid-1' }); + expect(Buffer.from(row.public_key as Buffer)).toEqual(Buffer.from([9, 9, 9])); + }); + + it('PASSKEY-SVC-012: defaults a blank name by device type and caps a long one at 60 chars', async () => { + const { user } = createUser(testDb); + + seedChallenge('c5', user.id, 'registration'); + swMock.verifyRegistrationResponse.mockResolvedValue(registrationVerdict({ credentialDeviceType: 'multiDevice', credentialBackedUp: true }, { id: 'synced-cred' })); + const synced = await svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('c5'), name: ' ' }); + expect(synced.credential).toMatchObject({ name: 'Passkey (synced)', backed_up: true }); + + seedChallenge('c6', user.id, 'registration'); + swMock.verifyRegistrationResponse.mockResolvedValue(registrationVerdict({}, { id: 'named-cred' })); + const long = await svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('c6'), name: 'x'.repeat(80) }); + expect(long.credential).toMatchObject({ name: 'x'.repeat(60) }); + }); + + it('PASSKEY-SVC-013: an INSERT failure maps to the generic 400 (FK violation stand-in)', async () => { + swMock.verifyRegistrationResponse.mockResolvedValue(registrationVerdict()); + // Claim passes (the challenge belongs to the caller id we pass), but no + // such user row exists — the credential INSERT's FK trips the catch path. + testDb.exec('PRAGMA foreign_keys = OFF'); + seedChallenge('c7', 999_999, 'registration'); + testDb.exec('PRAGMA foreign_keys = ON'); + expect(await svc.passkeyRegisterVerify(999_999, { attestationResponse: ceremonyResponse('c7') })) + .toEqual({ error: 'Could not register this passkey.', status: 400 }); + }); +}); + +// ── passkeyLoginOptions ─────────────────────────────────────────────────────── + +describe('passkeyLoginOptions', () => { + it('PASSKEY-SVC-014: returns the not-configured 400 when no RP config resolves', async () => { + resolveWebauthnConfigMock.mockReturnValue(null); + expect(await svc.passkeyLoginOptions()).toEqual({ error: NOT_CONFIGURED_ERROR, status: 400 }); + }); + + it('PASSKEY-SVC-015: stores an anonymous authentication challenge (discoverable flow)', async () => { + seedChallenge('stale', null, 'authentication', Date.now() - 1000); + swMock.generateAuthenticationOptions.mockResolvedValue({ challenge: 'auth-chal' }); + + expect(await svc.passkeyLoginOptions()).toEqual({ options: { challenge: 'auth-chal' } }); + // No allowCredentials → the endpoint can't enumerate accounts. + expect(swMock.generateAuthenticationOptions.mock.calls[0][0]).toEqual({ rpID: CFG.rpID, userVerification: 'required' }); + + const stored = testDb.prepare('SELECT * FROM webauthn_challenges').all() as Array>; + expect(stored).toHaveLength(1); // the expired row was purged + expect(stored[0]).toMatchObject({ challenge: 'auth-chal', user_id: null, type: 'authentication' }); + }); +}); + +// ── passkeyLoginVerify ──────────────────────────────────────────────────────── + +describe('passkeyLoginVerify', () => { + it('PASSKEY-SVC-016: returns the not-configured 400, not the generic 401', async () => { + resolveWebauthnConfigMock.mockReturnValue(null); + expect(await svc.passkeyLoginVerify({ assertionResponse: {} })).toEqual({ error: NOT_CONFIGURED_ERROR, status: 400 }); + }); + + it('PASSKEY-SVC-017: every malformed-input path collapses into the uniform 401', async () => { + expect(await svc.passkeyLoginVerify({})).toEqual({ error: 'Authentication failed', status: 401 }); + expect(await svc.passkeyLoginVerify({ assertionResponse: { response: { clientDataJSON: 42 } } })) + .toEqual({ error: 'Authentication failed', status: 401 }); + // Unknown challenge + expect(await svc.passkeyLoginVerify({ assertionResponse: ceremonyResponse('never-stored') })) + .toEqual({ error: 'Authentication failed', status: 401 }); + // Claimed challenge but no usable credential id + seedChallenge('a1', null, 'authentication'); + expect(await svc.passkeyLoginVerify({ assertionResponse: ceremonyResponse('a1', { id: 123 }) })) + .toEqual({ error: 'Authentication failed', status: 401 }); + }); + + it('PASSKEY-SVC-018: the challenge is spent on first use, even when verification fails', async () => { + const { user } = createUser(testDb); + insertCredential(user.id, { credential_id: 'known' }); + seedChallenge('a2', null, 'authentication'); + swMock.verifyAuthenticationResponse.mockRejectedValue(new Error('bad signature')); + + expect(await svc.passkeyLoginVerify({ assertionResponse: ceremonyResponse('a2', { id: 'known' }) })) + .toEqual({ error: 'Authentication failed', status: 401 }); + expect(challengeCount()).toBe(0); + // A double-submit of the same assertion cannot spend the challenge twice. + expect(await svc.passkeyLoginVerify({ assertionResponse: ceremonyResponse('a2', { id: 'known' }) })) + .toEqual({ error: 'Authentication failed', status: 401 }); + expect(swMock.verifyAuthenticationResponse).toHaveBeenCalledTimes(1); + }); + + it('PASSKEY-SVC-019: unknown credential and unverified assertion both yield the uniform 401', async () => { + seedChallenge('a3', null, 'authentication'); + expect(await svc.passkeyLoginVerify({ assertionResponse: ceremonyResponse('a3', { id: 'no-such-cred' }) })) + .toEqual({ error: 'Authentication failed', status: 401 }); + + const { user } = createUser(testDb); + insertCredential(user.id, { credential_id: 'known-2' }); + seedChallenge('a4', null, 'authentication'); + swMock.verifyAuthenticationResponse.mockResolvedValue({ verified: false }); + expect(await svc.passkeyLoginVerify({ assertionResponse: ceremonyResponse('a4', { id: 'known-2' }) })) + .toEqual({ error: 'Authentication failed', status: 401 }); + }); + + it('PASSKEY-SVC-020: falls back to rawId when id is absent', async () => { + const { user } = createUser(testDb); + insertCredential(user.id, { credential_id: 'raw-cred', counter: 0 }); + seedChallenge('a5', null, 'authentication'); + swMock.verifyAuthenticationResponse.mockResolvedValue({ verified: true, authenticationInfo: { newCounter: 0 } }); + + const result = await svc.passkeyLoginVerify({ assertionResponse: ceremonyResponse('a5', { rawId: 'raw-cred' }) }); + expect(result.token).toBeTruthy(); + }); + + it('PASSKEY-SVC-021: flags a counter regression as a suspected clone and audits it', async () => { + const { user } = createUser(testDb); + const cred = insertCredential(user.id, { credential_id: 'cloned', counter: 10 }); + seedChallenge('a6', null, 'authentication'); + swMock.verifyAuthenticationResponse.mockResolvedValue({ verified: true, authenticationInfo: { newCounter: 10 } }); + + expect(await svc.passkeyLoginVerify({ assertionResponse: ceremonyResponse('a6', { id: 'cloned' }) })).toEqual({ + error: 'Authentication failed', + status: 401, + auditUserId: user.id, + auditAction: 'user.passkey_clone_suspected', + }); + // Rejects THIS assertion only — the credential is not disabled or touched. + const row = testDb.prepare('SELECT counter, last_used_at FROM webauthn_credentials WHERE id = ?').get(cred.id) as Record; + expect(row).toEqual({ counter: 10, last_used_at: null }); + }); + + it('PASSKEY-SVC-022: never treats a synced passkey stuck at counter 0 as a clone', async () => { + const { user } = createUser(testDb); + insertCredential(user.id, { credential_id: 'synced', counter: 0, device_type: 'multiDevice' }); + seedChallenge('a7', null, 'authentication'); + swMock.verifyAuthenticationResponse.mockResolvedValue({ verified: true, authenticationInfo: { newCounter: 0 } }); + + const result = await svc.passkeyLoginVerify({ assertionResponse: ceremonyResponse('a7', { id: 'synced' }) }); + expect(result.error).toBeUndefined(); + expect(result.token).toBeTruthy(); + }); + + it('PASSKEY-SVC-023: a credential whose user row is gone yields the uniform 401', async () => { + // Orphan credential (FK off for the seed only) — the user lookup misses. + testDb.exec('PRAGMA foreign_keys = OFF'); + insertCredential(424_242, { credential_id: 'orphan' }); + testDb.exec('PRAGMA foreign_keys = ON'); + seedChallenge('a8', null, 'authentication'); + swMock.verifyAuthenticationResponse.mockResolvedValue({ verified: true, authenticationInfo: { newCounter: 1 } }); + + expect(await svc.passkeyLoginVerify({ assertionResponse: ceremonyResponse('a8', { id: 'orphan' }) })) + .toEqual({ error: 'Authentication failed', status: 401 }); + }); + + it('PASSKEY-SVC-024: success mints a real session, strips the user and bumps the bookkeeping', async () => { + const { user } = createUser(testDb); + const cred = insertCredential(user.id, { credential_id: 'good', counter: 5 }); + seedChallenge('a9', null, 'authentication'); + swMock.verifyAuthenticationResponse.mockResolvedValue({ verified: true, authenticationInfo: { newCounter: 6 } }); + + const result = await svc.passkeyLoginVerify({ assertionResponse: ceremonyResponse('a9', { id: 'good' }) }); + expect(result.auditUserId).toBe(user.id); + expect(result.user).toMatchObject({ id: user.id, email: user.email }); + expect(result.user).toHaveProperty('avatar_url'); + expect(result.user).not.toHaveProperty('password_hash'); + // The token is the SAME session shape password login mints ({ id, pv }). + const decoded = jwtLib.verify(result.token!, 'test-jwt-secret-for-trek-testing-only') as Record; + expect(decoded.id).toBe(user.id); + expect(decoded).toHaveProperty('pv'); + + const credRow = testDb.prepare('SELECT counter, last_used_at FROM webauthn_credentials WHERE id = ?').get(cred.id) as Record; + expect(credRow.counter).toBe(6); + expect(credRow.last_used_at).not.toBeNull(); + const userRow = testDb.prepare('SELECT last_login, login_count FROM users WHERE id = ?').get(user.id) as Record; + expect(userRow.last_login).not.toBeNull(); + expect(userRow.login_count).toBe(1); + }); +}); + +// ── listPasskeys ────────────────────────────────────────────────────────────── + +describe('listPasskeys', () => { + it('PASSKEY-SVC-025: lists newest-first with backed_up remapped to a boolean', () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + testDb.prepare( + `INSERT INTO webauthn_credentials (user_id, credential_id, public_key, counter, backed_up, name, created_at) + VALUES (?, 'old', x'01', 0, 0, 'Old', '2026-01-01 00:00:00'), (?, 'new', x'02', 0, 1, 'New', '2026-02-01 00:00:00')`, + ).run(user.id, user.id); + insertCredential(other.id); + + const list = svc.listPasskeys(user.id); + expect(list.map((c) => c.name)).toEqual(['New', 'Old']); + expect(list[0]).toEqual({ + id: expect.any(Number), + name: 'New', + device_type: null, + backed_up: true, + created_at: '2026-02-01 00:00:00', + last_used_at: null, + }); + expect(list[1].backed_up).toBe(false); + }); +}); + +// ── renamePasskey ───────────────────────────────────────────────────────────── + +describe('renamePasskey', () => { + it('PASSKEY-SVC-026: rejects a missing, non-string or whitespace-only name', () => { + const { user } = createUser(testDb); + const cred = insertCredential(user.id); + expect(svc.renamePasskey(user.id, String(cred.id), undefined)).toEqual({ error: 'Name is required', status: 400 }); + expect(svc.renamePasskey(user.id, String(cred.id), 42)).toEqual({ error: 'Name is required', status: 400 }); + expect(svc.renamePasskey(user.id, String(cred.id), ' ')).toEqual({ error: 'Name is required', status: 400 }); + }); + + it('PASSKEY-SVC-027: renames (trimmed, capped at 60) and 404s on foreign or unknown ids', () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const cred = insertCredential(user.id); + + expect(svc.renamePasskey(user.id, String(cred.id), ` ${'n'.repeat(80)} `)).toEqual({ success: true }); + const row = testDb.prepare('SELECT name FROM webauthn_credentials WHERE id = ?').get(cred.id) as { name: string }; + expect(row.name).toBe('n'.repeat(60)); + + expect(svc.renamePasskey(other.id, String(cred.id), 'Steal')).toEqual({ error: 'Passkey not found', status: 404 }); + expect(svc.renamePasskey(user.id, '999999', 'Ghost')).toEqual({ error: 'Passkey not found', status: 404 }); + }); +}); + +// ── deletePasskey ───────────────────────────────────────────────────────────── + +describe('deletePasskey', () => { + it('PASSKEY-SVC-028: requires the current password (missing, wrong, or no hash all 401)', () => { + const { user, password } = createUser(testDb); + const cred = insertCredential(user.id); + expect(svc.deletePasskey(user.id, String(cred.id), undefined)).toEqual({ error: 'Incorrect password', status: 401 }); + expect(svc.deletePasskey(user.id, String(cred.id), `${password}x`)).toEqual({ error: 'Incorrect password', status: 401 }); + testDb.prepare("UPDATE users SET password_hash = '' WHERE id = ?").run(user.id); + expect(svc.deletePasskey(user.id, String(cred.id), password)).toEqual({ error: 'Incorrect password', status: 401 }); + }); + + it('PASSKEY-SVC-029: deletes own credentials only — foreign ids 404 without leaking', () => { + const { user, password } = createUser(testDb); + const { user: other, password: otherPassword } = createUser(testDb); + const cred = insertCredential(user.id); + + expect(svc.deletePasskey(other.id, String(cred.id), otherPassword)).toEqual({ error: 'Passkey not found', status: 404 }); + expect(svc.deletePasskey(user.id, String(cred.id), password)).toEqual({ success: true }); + expect(testDb.prepare('SELECT COUNT(*) AS n FROM webauthn_credentials').get()).toEqual({ n: 0 }); + }); +}); + +// ── adminResetPasskeys ──────────────────────────────────────────────────────── + +describe('adminResetPasskeys', () => { + it('PASSKEY-SVC-030: 404s on an unknown user, else clears all credentials and reports the count', () => { + expect(svc.adminResetPasskeys(999_999)).toEqual({ error: 'User not found', status: 404 }); + + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + insertCredential(user.id); + insertCredential(user.id); + const kept = insertCredential(other.id); + + expect(svc.adminResetPasskeys(user.id)).toEqual({ success: true, deleted: 2, email: user.email }); + expect(svc.adminResetPasskeys(user.id)).toEqual({ success: true, deleted: 0, email: user.email }); + expect(testDb.prepare('SELECT id FROM webauthn_credentials').all()).toEqual([{ id: kept.id }]); + }); +});