Files
TREK/server/tests/unit/nest/webauthn-config.service.test.ts
T

161 lines
7.1 KiB
TypeScript

/**
* webauthn-config.service.test.ts
*
* The RP-ID / allowed-origin resolver is the single highest-risk piece of the
* passkey feature: a wrong RP ID permanently bricks every enrolled credential.
* These tests pin the security-relevant rules — config wins over APP_URL, bare
* IPs are rejected, localhost dev uses the browser (Vite) origin, and the
* resolver NEVER reads request headers.
*/
// The real getAppUrl() NEVER returns an empty string — with nothing configured
// it falls back to `http://localhost:{PORT}` (app-url.ts). The mock default
// mirrors that; a '' default here once hid the #2147 phantom-config bug.
const { settingsStore, appUrlRef } = vi.hoisted(() => ({
settingsStore: new Map<string, string>(),
appUrlRef: { value: 'http://localhost:3001' },
}));
vi.mock('../../../src/app-config', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../src/app-config')>();
return { ...actual, getAppUrl: () => appUrlRef.value };
});
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { WebauthnConfigService, originWithinRpScope } from '../../../src/nest/auth/webauthn-config.service';
// Injected instead of vi.mocking src/db/database: the service reads exactly one
// row shape (app_settings.value), so a stub connection keeps the suite free of
// a schema and states the only DB contact the resolver has.
const conn = {
prepare: (_sql: string) => ({
get: (key: string) => {
const v = settingsStore.get(key);
return v === undefined ? undefined : { value: v };
},
}),
} as unknown as ConstructorParameters<typeof DatabaseService>[0];
const svc = new WebauthnConfigService(new DatabaseService(conn));
beforeEach(() => {
settingsStore.clear();
appUrlRef.value = 'http://localhost:3001';
});
afterEach(() => {
vi.unstubAllEnvs();
});
describe('WebauthnConfigService.resolve', () => {
it('WAC-001: derives the RP ID and single origin from a real APP_URL domain', () => {
appUrlRef.value = 'https://trek.example.org';
const cfg = svc.resolve();
expect(cfg).not.toBeNull();
expect(cfg!.rpID).toBe('trek.example.org');
expect(cfg!.origins).toEqual(['https://trek.example.org']);
expect(cfg!.explicitOrigins).toBe(false); // derived, not operator-supplied
expect(svc.isConfigured()).toBe(true);
});
it('WAC-002: returns null for a bare-IP host (IPs are not valid RP IDs)', () => {
appUrlRef.value = 'http://192.168.1.50:3001';
expect(svc.resolve()).toBeNull();
expect(svc.isConfigured()).toBe(false);
});
it('WAC-003: nothing configured resolves to the localhost dev fallback (#2147)', () => {
// getAppUrl() never yields '' — an unconfigured instance lands on
// http://localhost:{PORT}, so resolve() returns the localhost dev config.
// The remote-origin guard for that phantom config lives in PasskeyService.
const cfg = svc.resolve();
expect(cfg!.rpID).toBe('localhost');
expect(cfg!.explicitOrigins).toBe(false);
expect(svc.isConfigured()).toBe(true);
});
it('WAC-004: localhost dev uses the browser (Vite :5173) origin, not just the API port', () => {
appUrlRef.value = 'http://localhost:3001';
const cfg = svc.resolve();
expect(cfg!.rpID).toBe('localhost');
expect(cfg!.origins).toContain('http://localhost:5173');
expect(cfg!.origins).toContain('http://localhost:3001');
});
it('WAC-005: an explicit webauthn_rp_id app-setting overrides APP_URL', () => {
appUrlRef.value = 'https://internal.example.org';
settingsStore.set('webauthn_rp_id', 'public.example.org');
settingsStore.set('webauthn_origins', 'https://public.example.org');
const cfg = svc.resolve();
expect(cfg!.rpID).toBe('public.example.org');
expect(cfg!.origins).toEqual(['https://public.example.org']);
expect(cfg!.explicitOrigins).toBe(true);
});
it('WAC-006: webauthn_origins is parsed as a comma-separated, trimmed list', () => {
settingsStore.set('webauthn_rp_id', 'example.org');
settingsStore.set('webauthn_origins', 'https://a.example.org , https://b.example.org/');
const cfg = svc.resolve();
expect(cfg!.origins).toEqual(['https://a.example.org', 'https://b.example.org']);
});
it('WAC-007: the WEBAUTHN_RP_ID env var takes priority', () => {
vi.stubEnv('WEBAUTHN_RP_ID', 'env.example.org');
vi.stubEnv('WEBAUTHN_ORIGINS', 'https://env.example.org');
appUrlRef.value = 'https://ignored.example.org';
const cfg = svc.resolve();
expect(cfg!.rpID).toBe('env.example.org');
expect(cfg!.origins).toEqual(['https://env.example.org']);
});
it('WAC-008: a configured RP ID with no origins falls back to the APP_URL origin', () => {
appUrlRef.value = 'https://trek.example.org';
settingsStore.set('webauthn_rp_id', 'trek.example.org');
const cfg = svc.resolve();
expect(cfg!.origins).toEqual(['https://trek.example.org']);
expect(cfg!.explicitOrigins).toBe(false); // the origin list itself is still derived
});
it('WAC-009: an operator flipping the RP ID at runtime takes effect on the next resolve', () => {
appUrlRef.value = 'https://trek.example.org';
expect(svc.resolve()!.rpID).toBe('trek.example.org');
settingsStore.set('webauthn_rp_id', 'moved.example.org');
expect(svc.resolve()!.rpID).toBe('moved.example.org');
});
it('WAC-010: the WEBAUTHN_ORIGINS env var also marks the origin list explicit', () => {
vi.stubEnv('WEBAUTHN_RP_ID', 'env.example.org');
vi.stubEnv('WEBAUTHN_ORIGINS', 'https://env.example.org');
expect(svc.resolve()!.explicitOrigins).toBe(true);
});
});
// The scope rule mirrors what the browser enforces for a ceremony: https-only
// (http for loopback dev), hostname equal to the RP ID or a subdomain of it.
// Anything laxer would weaken origin binding, so each edge is pinned.
describe('originWithinRpScope', () => {
it('WAC-011: accepts the RP domain and its subdomains over https', () => {
expect(originWithinRpScope('https://trek.example.org', 'trek.example.org')).toBe(true);
expect(originWithinRpScope('https://app.trek.example.org', 'trek.example.org')).toBe(true);
expect(originWithinRpScope('https://trek.example.org:8443', 'trek.example.org')).toBe(true);
});
it('WAC-012: rejects http on a non-localhost host, but allows loopback dev', () => {
expect(originWithinRpScope('http://trek.example.org', 'trek.example.org')).toBe(false);
expect(originWithinRpScope('http://localhost:5173', 'localhost')).toBe(true);
expect(originWithinRpScope('http://127.0.0.1:3001', '127.0.0.1')).toBe(true);
});
it('WAC-013: rejects foreign hosts, suffix tricks and garbage', () => {
expect(originWithinRpScope('https://evil.example.net', 'trek.example.org')).toBe(false);
// "eviltrek.example.org" is NOT a subdomain of "trek.example.org".
expect(originWithinRpScope('https://eviltrek.example.org', 'trek.example.org')).toBe(false);
expect(originWithinRpScope('https://trip.example.org', 'localhost')).toBe(false);
expect(originWithinRpScope('null', 'trek.example.org')).toBe(false);
expect(originWithinRpScope('ftp://trek.example.org', 'trek.example.org')).toBe(false);
});
});