mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
chore: move passkey backend to proper DI
This commit is contained in:
@@ -104,6 +104,8 @@ const RESET_TABLES = [
|
||||
'invite_tokens',
|
||||
'tags',
|
||||
'app_settings',
|
||||
'webauthn_challenges',
|
||||
'webauthn_credentials',
|
||||
'users',
|
||||
];
|
||||
|
||||
|
||||
@@ -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 }]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user