Files
TREK/server/tests/unit/nest/passkey.service.test.ts
T
Maurice df10dc6ec2 test(server): assert the row exists before checking its stamped columns
findRow returns null for a missing row, and `row?.deleted_at` is then
undefined, which `.not.toBeNull()` accepts. The converted soft delete,
album sync and passkey counter checks would therefore pass if the row were
removed outright. They now assert the row is there first, as the raw
`.get()` reads they replace did by throwing on a missing row.
2026-10-08 18:47:25 +02:00

838 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Unit tests for the DI-native PasskeyService — PASSKEY-SVC-001 through
* PASSKEY-SVC-041. 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;
* 031–032 pin the post-fold `fix(server)` quirk fix (the transactional
* dup-check → INSERT with its 409-vs-400 sentinel split); 033 to 041 pin the
* #2147 origin hardening (pre-ceremony Origin gate for the derived localhost
* fallback, scheme/port widening of expectedOrigin for derived configs, and
* that the host itself is never widened).
* 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 ──────────────────────────────────────────────────────────────────
vi.mock('../../../src/db/database', async () => {
const { createSnapshotTestDb } = await import('../../helpers/db-mock');
const db = createSnapshotTestDb();
// Passkeys are trip-free, so the trip-access helpers are inert stubs.
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: () => undefined,
isOwner: () => false,
};
return mock;
});
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/nest/common/crypto/mfaCrypto', () => ({
encryptMfaSecret: vi.fn((s) => `enc:${s}`),
decryptMfaSecret: vi.fn((s: string) => s.replace('enc:', '')),
}));
vi.mock('../../../src/nest/common/crypto/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/nest/auth/ephemeral-tokens', () => ({ createEphemeralToken: vi.fn() }));
vi.mock('../../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn() }));
// 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 { db as testDb } from '../../../src/db/database';
import jwtLib from 'jsonwebtoken';
import { resetTestDb } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { BudgetService } from '../../../src/nest/budget/budget.service';
import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { TripMembershipService } from '../../../src/nest/trip-membership/trip-membership.service';
import { AuthService } from '../../../src/nest/auth/auth.service';
import { PasskeyService } from '../../../src/nest/auth/passkey.service';
import { WebauthnConfigService } from '../../../src/nest/auth/webauthn-config.service';
import { UserCleanupService } from '../../../src/nest/auth/user-cleanup.service';
import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service';
import { AllowedFileTypesService } from '../../../src/nest/files/allowed-file-types.service';
import { MailerService } from '../../../src/nest/notifications/mailer/mailer.service';
import {
createTestUnitOfWork,
createTestAppSettingsRepo,
sharedTestOrm,
createTestUsersRepo,
createTestWebauthnCredentialsRepo,
createTestWebauthnChallengesRepo,
createTestInviteTokensRepo,
createTestMcpTokensRepo,
createTestOauthTokensRepo,
createTestPasswordResetTokensRepo,
createTestTripsRepo,
createTestTripMembersRepo,
} from '../../helpers/test-uow';
import { budgetRepoArgs } from '../../helpers/budget-repos';
import { createTestShareTokensRepo, createTestPluginsRepo, createTestPluginUserErasureQueueRepo } from '../../helpers/share-repos';
import { createTestBudgetItemsRepo } from '../../helpers/files-repos';
import { createTestJourneysRepo, createTestJourneyEntriesRepo, createTestJourneyContributorsRepo } from '../../helpers/journey-repos';
import { createTestJourneyShareTokensRepo } from '../../helpers/journey-share-repos';
import { createTestPushSubscriptionsRepo } from '../../helpers/notifications-repos';
import { createTestBudgetSettlementsRepo } from '../../helpers/budget-repos';
import { MaintenanceRepository } from '../../../src/db/repositories/MaintenanceRepository';
import { createTestSessionsService } from '../../helpers/sessions';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { countRows, findRow, findRows, insertRow, insertRows, updateRows } from '../../helpers/factories/rows';
import { readUser } from '../../helpers/factories/users';
import { WebauthnChallenges } from '../../../src/db/entities/WebauthnChallenges.entity';
import { WebauthnCredentials } from '../../../src/db/entities/WebauthnCredentials.entity';
import { Users } from '../../../src/db/entities/Users.entity';
// MailerService is injected since the notifications fold — a stub instead of a
// module mock. sendPasswordResetEmail is the only thing auth reaches for.
const mailerStub = { sendPasswordResetEmail: vi.fn() } as unknown as MailerService;
// The RP config resolver has its own suite (webauthn-config.service.test.ts);
// here it is a switch for the configured / not-configured branches, handed in
// as a stub now that PasskeyService injects it.
const resolveWebauthnConfigMock = vi.fn();
const webauthn = { resolve: resolveWebauthnConfigMock } as unknown as WebauthnConfigService;
// Positional, and previously shifted by one: an AtlasService sat in the
// membership slot, which AuthService no longer takes at all, so
// webauthn/userCleanup/mailer each landed one place too late and the
// EphemeralTokenService was missing entirely. Nothing failed, because the only
// thing PasskeyService asks AuthService for is generateToken, which reads the
// database and nothing else. The collaborators are real here rather than
// undefined so a future case that does reach one gets a working object; each
// takes only the DatabaseService. AuthService's own webauthn is the real
// resolver, separate from the `webauthn` switch PasskeyService is handed.
let auth: AuthService;
let svc: PasskeyService;
beforeAll(async () => {
auth = new AuthService(
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
new TripMembershipService(await createTestTripsRepo(testDb), await createTestTripMembersRepo(testDb)),
new WebauthnConfigService(await createTestAppSettingsRepo(testDb)),
new UserCleanupService(new MaintenanceRepository((await sharedTestOrm(testDb)).em), new BudgetService(new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(testDb), ...(await budgetRepoArgs(testDb))), await createTestUnitOfWork(testDb), await createTestUsersRepo(testDb), await createTestTripMembersRepo(testDb), await createTestBudgetItemsRepo(testDb), await createTestBudgetSettlementsRepo(testDb), await createTestJourneyShareTokensRepo(testDb), await createTestJourneysRepo(testDb), await createTestJourneyEntriesRepo(testDb), await createTestJourneyContributorsRepo(testDb), await createTestShareTokensRepo(testDb), await createTestPluginsRepo(testDb), await createTestPluginUserErasureQueueRepo(testDb)),
mailerStub,
new EphemeralTokenService(),
new AllowedFileTypesService(await createTestAppSettingsRepo(testDb)), await createTestUnitOfWork(testDb),
await createTestAppSettingsRepo(testDb), await createTestUsersRepo(testDb),
await createTestInviteTokensRepo(testDb), await createTestMcpTokensRepo(testDb), await createTestOauthTokensRepo(testDb),
await createTestWebauthnCredentialsRepo(testDb), await createTestPasswordResetTokensRepo(testDb),
await createTestPushSubscriptionsRepo(testDb),
await createTestSessionsService(testDb),
);
svc = new PasskeyService(
auth,
webauthn,
await createTestUnitOfWork(testDb),
await createTestWebauthnCredentialsRepo(testDb),
await createTestWebauthnChallengesRepo(testDb),
await createTestUsersRepo(testDb),
);
});
const CFG = { rpID: 'trek.example.com', rpName: 'TREK', origins: ['https://trek.example.com'], explicitOrigins: false };
// The unconfigured fallback resolve() yields when APP_URL is unset (#2147).
const LOCALHOST_CFG = { rpID: 'localhost', rpName: 'TREK', origins: ['http://localhost:5173', 'http://localhost:3001'], explicitOrigins: false };
const NOT_CONFIGURED_ERROR = 'Passkey login is not configured for this server.';
beforeEach(() => {
vi.clearAllMocks();
resolveWebauthnConfigMock.mockReturnValue(CFG);
resetTestDb(testDb);
});
let orm: TestOrm;
beforeAll(async () => {
orm = await createTestOrm(testDb);
});
afterAll(async () => {
await orm.close();
testDb.close();
});
// ── helpers ───────────────────────────────────────────────────────────────────
/** A response payload whose clientDataJSON echoes the given challenge (plus optional extra client data, e.g. origin). */
function ceremonyResponse(challenge: string, extra: Record<string, unknown> = {}, clientData: Record<string, unknown> = {}) {
return {
response: { clientDataJSON: Buffer.from(JSON.stringify({ challenge, ...clientData }), 'utf8').toString('base64url') },
...extra,
};
}
async function seedChallenge(challenge: string, userId: number | null, type: 'registration' | 'authentication', expiresAt = Date.now() + 60_000) {
await insertRow(orm, WebauthnChallenges, { challenge, user: userId, type, expires_at: expiresAt });
}
function challengeCount(): Promise<number> {
return countRows(orm, WebauthnChallenges);
}
interface CredentialOverrides {
credential_id?: string;
public_key?: Buffer;
counter?: number;
transports?: string | null;
device_type?: string | null;
backed_up?: number;
name?: string | null;
aaguid?: string | null;
}
let _credSeq = 0;
async function insertCredential(userId: number, overrides: CredentialOverrides = {}) {
_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 id = await insertRow(orm, WebauthnCredentials, { user: userId, ...row });
return { id, ...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);
await insertCredential(user.id, { credential_id: 'cred-a', transports: JSON.stringify(['usb', 'nfc']) });
await insertCredential(user.id, { credential_id: 'cred-b', transports: 'not-json' });
await 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 = await findRows(orm, WebauthnChallenges);
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());
});
it('PASSKEY-SVC-033: the derived localhost fallback fails fast for a foreign request origin (#2147)', async () => {
// APP_URL unset → resolve() yields the localhost phantom config. A browser
// on a real domain can never finish that ceremony, so the actionable
// not-configured 400 comes BEFORE the ceremony, not as a cryptic verify 400.
resolveWebauthnConfigMock.mockReturnValue(LOCALHOST_CFG);
const { user, password } = createUser(testDb);
expect(await svc.passkeyRegisterOptions(user.id, password, 'https://trip.example.org'))
.toEqual({ error: NOT_CONFIGURED_ERROR, status: 400 });
expect(swMock.generateRegistrationOptions).not.toHaveBeenCalled();
expect(await challengeCount()).toBe(0);
});
it('PASSKEY-SVC-034: localhost origins and headerless requests keep working against the dev fallback', async () => {
resolveWebauthnConfigMock.mockReturnValue(LOCALHOST_CFG);
const { user, password } = createUser(testDb);
let seq = 0; // distinct challenges — the store has a UNIQUE constraint
swMock.generateRegistrationOptions.mockImplementation(async () => ({ challenge: `reg-chal-${++seq}` }));
// Listed origin, in-scope origin on another port, no header (curl) — all pass.
expect(await svc.passkeyRegisterOptions(user.id, password, 'http://localhost:5173')).toEqual({ options: { challenge: 'reg-chal-1' } });
expect(await svc.passkeyRegisterOptions(user.id, password, 'http://localhost:8080')).toEqual({ options: { challenge: 'reg-chal-2' } });
expect(await svc.passkeyRegisterOptions(user.id, password, undefined)).toEqual({ options: { challenge: 'reg-chal-3' } });
});
it('PASSKEY-SVC-035: a mismatching origin never blocks a real or explicit config (proxy tolerance)', async () => {
// Proxies may rewrite the Origin header to an internal value while
// clientDataJSON still carries the real one — options must not hard-fail
// for a config that verify can still satisfy.
const { user, password } = createUser(testDb);
let seq = 0;
swMock.generateRegistrationOptions.mockImplementation(async () => ({ challenge: `reg-chal-${++seq}` }));
resolveWebauthnConfigMock.mockReturnValue(CFG); // derived from a real APP_URL
expect(await svc.passkeyRegisterOptions(user.id, password, 'http://10.0.0.5:3001')).toEqual({ options: { challenge: 'reg-chal-1' } });
resolveWebauthnConfigMock.mockReturnValue({ ...LOCALHOST_CFG, explicitOrigins: true }); // operator contract
expect(await svc.passkeyRegisterOptions(user.id, password, 'https://trip.example.org')).toEqual({ options: { challenge: 'reg-chal-2' } });
});
});
// ── 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 });
await 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);
await 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(await 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);
await 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 });
await 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);
await insertCredential(user.id, { credential_id: 'dup-cred' });
await 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);
await 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 = (await findRow(orm, WebauthnCredentials, { credential_id: 'new-cred' }));
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);
await 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 });
await 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');
await 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 });
});
// 031/032 pin the post-fold quirk fix: the dup check + INSERT run in one
// transaction, keeping the 409-vs-400 split and rolling back cleanly.
it('PASSKEY-SVC-031: a failed INSERT rolls back without partial credential state', async () => {
swMock.verifyRegistrationResponse.mockResolvedValue(registrationVerdict());
testDb.exec('PRAGMA foreign_keys = OFF');
await seedChallenge('c8', 999_999, 'registration');
testDb.exec('PRAGMA foreign_keys = ON');
await svc.passkeyRegisterVerify(999_999, { attestationResponse: ceremonyResponse('c8') });
expect(await countRows(orm, WebauthnCredentials)).toBe(0);
expect(await challengeCount()).toBe(0); // the claim stays spent — the challenge is not resurrected
});
it('PASSKEY-SVC-032: the in-transaction duplicate 409 leaves the original row untouched', async () => {
const { user } = createUser(testDb);
await insertCredential(user.id, { credential_id: 'dup-cred-2', name: 'Original' });
await seedChallenge('c9', user.id, 'registration');
swMock.verifyRegistrationResponse.mockResolvedValue(registrationVerdict({}, { id: 'dup-cred-2' }));
expect(await svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('c9'), name: 'Impostor' }))
.toEqual({ error: 'This passkey is already registered.', status: 409 });
const row = (await findRow(orm, WebauthnCredentials, { credential_id: 'dup-cred-2' }));
expect(row).toMatchObject({ name: 'Original', user_id: user.id });
expect(await countRows(orm, WebauthnCredentials)).toBe(1);
});
it('PASSKEY-SVC-036: widens expectedOrigin with the in-scope browser origin for a derived config (#2147)', async () => {
// ALLOWED_ORIGINS-derived configs carry the wrong scheme behind a
// TLS-terminating proxy (http://… while the browser is on https://…). The
// browser-asserted origin is added because it falls within the RP scope;
// rpIdHash/challenge/signature checks still run in the verifier.
resolveWebauthnConfigMock.mockReturnValue({ rpID: 'trek.example.org', rpName: 'TREK', origins: ['http://trek.example.org'], explicitOrigins: false });
const { user } = createUser(testDb);
await seedChallenge('w1', user.id, 'registration');
swMock.verifyRegistrationResponse.mockResolvedValue(registrationVerdict());
const result = await svc.passkeyRegisterVerify(user.id, {
attestationResponse: ceremonyResponse('w1', {}, { origin: 'https://trek.example.org' }),
});
expect(result.success).toBe(true);
expect(swMock.verifyRegistrationResponse.mock.calls[0][0].expectedOrigin)
.toEqual(['http://trek.example.org', 'https://trek.example.org']);
});
it('PASSKEY-SVC-037: never widens for an explicit origins list or an out-of-scope origin', async () => {
const { user } = createUser(testDb);
// Operator-supplied list is verbatim — an in-scope but unlisted origin
// still has to fail verify (documented contract, options let it through).
resolveWebauthnConfigMock.mockReturnValue({ ...CFG, origins: ['https://trek.example.com'], explicitOrigins: true });
await seedChallenge('w2', user.id, 'registration');
swMock.verifyRegistrationResponse.mockResolvedValue(registrationVerdict());
await svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('w2', {}, { origin: 'https://app.trek.example.com' }) });
expect(swMock.verifyRegistrationResponse.mock.calls[0][0].expectedOrigin).toEqual(['https://trek.example.com']);
// Derived config, but the asserted origin is outside the RP scope.
resolveWebauthnConfigMock.mockReturnValue(CFG);
await seedChallenge('w3', user.id, 'registration');
await svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('w3', {}, { origin: 'https://evil.example.net' }) });
expect(swMock.verifyRegistrationResponse.mock.calls[1][0].expectedOrigin).toEqual(['https://trek.example.com']);
});
it('PASSKEY-SVC-040: a derived config heals scheme and port, never the host (#2147)', async () => {
// With the RP ID on an apex domain the browser runs a ceremony from any
// subdomain of it, so a taken-over sibling could hand over an assertion
// nothing else in the chain rejects. The exact-origin list is the only
// defence there, so only scheme/port drift on the RP ID's host is healed.
resolveWebauthnConfigMock.mockReturnValue({ rpID: 'example.com', rpName: 'TREK', origins: ['https://example.com'], explicitOrigins: false });
const { user } = createUser(testDb);
await seedChallenge('w4', user.id, 'registration');
swMock.verifyRegistrationResponse.mockResolvedValueOnce(registrationVerdict({}, { id: 'sibling-cred' }));
await svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('w4', {}, { origin: 'https://blog.example.com' }) });
expect(swMock.verifyRegistrationResponse.mock.calls[0][0].expectedOrigin).toEqual(['https://example.com']);
await seedChallenge('w5', user.id, 'registration');
swMock.verifyRegistrationResponse.mockResolvedValueOnce(registrationVerdict({}, { id: 'port-cred' }));
await svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('w5', {}, { origin: 'https://example.com:8443' }) });
expect(swMock.verifyRegistrationResponse.mock.calls[1][0].expectedOrigin).toEqual(['https://example.com', 'https://example.com:8443']);
});
});
// ── 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 () => {
await 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 = await findRows(orm, WebauthnChallenges);
expect(stored).toHaveLength(1); // the expired row was purged
expect(stored[0]).toMatchObject({ challenge: 'auth-chal', user_id: null, type: 'authentication' });
});
it('PASSKEY-SVC-038: the derived localhost fallback fails fast for a foreign request origin (#2147)', async () => {
resolveWebauthnConfigMock.mockReturnValue(LOCALHOST_CFG);
expect(await svc.passkeyLoginOptions('https://trip.example.org')).toEqual({ error: NOT_CONFIGURED_ERROR, status: 400 });
expect(swMock.generateAuthenticationOptions).not.toHaveBeenCalled();
// No Origin header (non-browser client) → unchanged legacy behavior.
swMock.generateAuthenticationOptions.mockResolvedValue({ challenge: 'auth-chal' });
expect(await svc.passkeyLoginOptions()).toEqual({ options: { challenge: 'auth-chal' } });
});
});
// ── 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
await 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);
await insertCredential(user.id, { credential_id: 'known' });
await 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(await 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 () => {
await 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);
await insertCredential(user.id, { credential_id: 'known-2' });
await 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);
await insertCredential(user.id, { credential_id: 'raw-cred', counter: 0 });
await 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 = await insertCredential(user.id, { credential_id: 'cloned', counter: 10 });
await 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 = (await findRow(orm, WebauthnCredentials, { id: cred.id }));
expect(row).toMatchObject({ 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);
await insertCredential(user.id, { credential_id: 'synced', counter: 0, device_type: 'multiDevice' });
await 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');
await insertCredential(424_242, { credential_id: 'orphan' });
testDb.exec('PRAGMA foreign_keys = ON');
await 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);
// A `users` column outside the `User` contract type (Plan 3b Task 3
// review, F5) — the legacy `stripUserForClient(user)` ran on the FULL
// `SELECT *` row at runtime regardless of its `User`-typed generic, so
// this must still survive the repository-backed path's `toClientUser`
// spread, not be silently dropped by a narrower field-by-field mapping.
await updateRows(orm, Users, { id: user.id }, { display_name: 'Legacy Passthrough' });
const cred = await insertCredential(user.id, { credential_id: 'good', counter: 5 });
await 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, display_name: 'Legacy Passthrough' });
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 = (await findRow(orm, WebauthnCredentials, { id: cred.id }));
expect(credRow).not.toBeNull();
expect(credRow!.counter).toBe(6);
expect(credRow!.last_used_at).not.toBeNull();
const userRow = await readUser(orm, user.id);
expect(userRow.last_login).not.toBeNull();
expect(userRow.login_count).toBe(1);
});
it('PASSKEY-SVC-039: widens expectedOrigin for a derived config on login too (#2147)', async () => {
// Same derivation chain as registration: an instance whose APP_URL changes
// scheme after enrollment must not brick every passkey login.
resolveWebauthnConfigMock.mockReturnValue({ rpID: 'trek.example.org', rpName: 'TREK', origins: ['http://trek.example.org'], explicitOrigins: false });
const { user } = createUser(testDb);
await insertCredential(user.id, { credential_id: 'scheme-heal' });
await seedChallenge('a10', null, 'authentication');
swMock.verifyAuthenticationResponse.mockResolvedValue({ verified: true, authenticationInfo: { newCounter: 1 } });
const result = await svc.passkeyLoginVerify({
assertionResponse: ceremonyResponse('a10', { id: 'scheme-heal' }, { origin: 'https://trek.example.org' }),
});
expect(result.token).toBeTruthy();
expect(swMock.verifyAuthenticationResponse.mock.calls[0][0].expectedOrigin)
.toEqual(['http://trek.example.org', 'https://trek.example.org']);
});
it('PASSKEY-SVC-041: login never widens to a sibling subdomain of the RP ID (#2147)', async () => {
// The relay path the exact-origin list exists for: a challenge fetched from
// the public options endpoint, spent by a ceremony a compromised sibling
// subdomain ran against the apex RP ID.
resolveWebauthnConfigMock.mockReturnValue({ rpID: 'example.com', rpName: 'TREK', origins: ['https://example.com'], explicitOrigins: false });
const { user } = createUser(testDb);
await insertCredential(user.id, { credential_id: 'apex-cred' });
await seedChallenge('a11', null, 'authentication');
swMock.verifyAuthenticationResponse.mockResolvedValue({ verified: true, authenticationInfo: { newCounter: 1 } });
await svc.passkeyLoginVerify({ assertionResponse: ceremonyResponse('a11', { id: 'apex-cred' }, { origin: 'https://blog.example.com' }) });
expect(swMock.verifyAuthenticationResponse.mock.calls[0][0].expectedOrigin).toEqual(['https://example.com']);
});
});
// ── listPasskeys ──────────────────────────────────────────────────────────────
describe('listPasskeys', () => {
it('PASSKEY-SVC-025: lists newest-first with backed_up remapped to a boolean', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
await insertRows(orm, WebauthnCredentials, [
{ user: user.id, credential_id: 'old', public_key: Buffer.from([1]), counter: 0, backed_up: 0, name: 'Old', created_at: '2026-01-01 00:00:00' },
{ user: user.id, credential_id: 'new', public_key: Buffer.from([2]), counter: 0, backed_up: 1, name: 'New', created_at: '2026-02-01 00:00:00' },
]);
await insertCredential(other.id);
const list = await 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', async () => {
const { user } = createUser(testDb);
const cred = await insertCredential(user.id);
expect(await svc.renamePasskey(user.id, String(cred.id), undefined)).toEqual({ error: 'Name is required', status: 400 });
expect(await svc.renamePasskey(user.id, String(cred.id), 42)).toEqual({ error: 'Name is required', status: 400 });
expect(await 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', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const cred = await insertCredential(user.id);
expect(await svc.renamePasskey(user.id, String(cred.id), ` ${'n'.repeat(80)} `)).toEqual({ success: true });
const row = (await findRow(orm, WebauthnCredentials, { id: cred.id }));
expect(row?.name).toBe('n'.repeat(60));
expect(await svc.renamePasskey(other.id, String(cred.id), 'Steal')).toEqual({ error: 'Passkey not found', status: 404 });
expect(await svc.renamePasskey(user.id, '999999', 'Ghost')).toEqual({ error: 'Passkey not found', status: 404 });
});
it('PASSKEY-SVC-042: 404s (not a 500) on a non-numeric id — Plan 3b Task 3 review, F1', async () => {
const { user } = createUser(testDb);
expect(await svc.renamePasskey(user.id, 'abc', 'Ghost')).toEqual({ error: 'Passkey not found', status: 404 });
expect(await svc.renamePasskey(user.id, '1abc', 'Ghost')).toEqual({ error: 'Passkey not found', status: 404 });
});
// task-3-rereview.md R1: the legacy `renamePasskey` bound `Number(id)`
// (unlike the invite route's raw-string bind), so `Number('0x10') === 16`
// — the legacy statement would have ACTED on credential 16, not 404'd.
// This pins the deliberate `toRowId` narrowing the fix round chose
// (accepted deviation, not parity), not "the legacy 404".
it('PASSKEY-SVC-042B: refuses a prefixed-numeric id (0x10) via the toRowId narrowing', async () => {
const { user } = createUser(testDb);
expect(await svc.renamePasskey(user.id, '0x10', '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)', async () => {
const { user, password } = createUser(testDb);
const cred = await insertCredential(user.id);
expect(await svc.deletePasskey(user.id, String(cred.id), undefined)).toEqual({ error: 'Incorrect password', status: 401 });
expect(await svc.deletePasskey(user.id, String(cred.id), `${password}x`)).toEqual({ error: 'Incorrect password', status: 401 });
await updateRows(orm, Users, { id: user.id }, { password_hash: '' });
expect(await 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', async () => {
const { user, password } = createUser(testDb);
const { user: other, password: otherPassword } = createUser(testDb);
const cred = await insertCredential(user.id);
expect(await svc.deletePasskey(other.id, String(cred.id), otherPassword)).toEqual({ error: 'Passkey not found', status: 404 });
expect(await svc.deletePasskey(user.id, String(cred.id), password)).toEqual({ success: true });
expect(await countRows(orm, WebauthnCredentials)).toBe(0);
});
it('PASSKEY-SVC-043: 404s (not a 500) on a non-numeric id once the password check passes — Plan 3b Task 3 review, F1', async () => {
const { user, password } = createUser(testDb);
expect(await svc.deletePasskey(user.id, 'abc', password)).toEqual({ error: 'Passkey not found', status: 404 });
});
// task-3-rereview.md R1: same distinction as PASSKEY-SVC-042B —
// `deletePasskey`'s legacy bind was `Number(id)`, so `'0x10'` would have
// acted on credential 16, not 404'd. This pins the `toRowId` narrowing.
it('PASSKEY-SVC-043B: refuses a prefixed-numeric id (0x10) via the toRowId narrowing, once the password check passes', async () => {
const { user, password } = createUser(testDb);
expect(await svc.deletePasskey(user.id, '0x10', password)).toEqual({ error: 'Passkey not found', status: 404 });
});
it('PASSKEY-SVC-044: a wrong password still wins over a non-numeric id — 401, not 404 (legacy statement order)', async () => {
const { user, password } = createUser(testDb);
expect(await svc.deletePasskey(user.id, 'abc', `${password}x`)).toEqual({ error: 'Incorrect password', status: 401 });
});
});
// ── adminResetPasskeys ────────────────────────────────────────────────────────
describe('adminResetPasskeys', () => {
it('PASSKEY-SVC-030: 404s on an unknown user, else clears all credentials and reports the count', async () => {
expect(await svc.adminResetPasskeys(999_999)).toEqual({ error: 'User not found', status: 404 });
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
await insertCredential(user.id);
await insertCredential(user.id);
const kept = await insertCredential(other.id);
expect(await svc.adminResetPasskeys(user.id)).toEqual({ success: true, deleted: 2, email: user.email });
expect(await svc.adminResetPasskeys(user.id)).toEqual({ success: true, deleted: 0, email: user.email });
expect((await findRows(orm, WebauthnCredentials)).map((r) => ({ id: r.id }))).toEqual([{ id: kept.id }]);
});
it('PASSKEY-SVC-045: 404s (not a 500) when the caller hands in NaN — AdminService.resetUserPasskeys converts a non-numeric route id with a bare Number(id) before calling in (Plan 3b Task 3 review, F1)', async () => {
expect(await svc.adminResetPasskeys(Number('abc'))).toEqual({ error: 'User not found', status: 404 });
});
});