chore: move passkey backend to proper DI

This commit is contained in:
jubnl
2026-08-02 10:10:33 +02:00
parent f40c66da6b
commit e65f851446
12 changed files with 1026 additions and 411 deletions
+2
View File
@@ -104,6 +104,8 @@ const RESET_TABLES = [
'invite_tokens',
'tags',
'app_settings',
'webauthn_challenges',
'webauthn_credentials',
'users',
];
+27 -25
View File
@@ -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' });
});
@@ -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<typeof import('../../../src/services/webauthnConfig')>();
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<string, unknown> = {}) {
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<string, unknown> = {}) {
_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<string, unknown> = {}, credentialOverrides: Record<string, unknown> = {}) {
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<Record<string, unknown>>;
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<string, unknown>;
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<Record<string, unknown>>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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 }]);
});
});