mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
724c88c201
A mechanical prettier --write over server/src, server/tests, server/scripts and shared/src with the workspaces' .prettierrc; nothing but layout and import order changes, and every file keeps the same set of imports. shared is formatted throughout and its format baseline is empty. The server baseline keeps the 35 files that Prettier would grow past their lint:size limit (they need a split first, which is not a formatting change), and the generated entity files stay as their generator writes them.
159 lines
7.3 KiB
TypeScript
159 lines
7.3 KiB
TypeScript
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
|
|
import { WebauthnConfigService, originWithinRpScope } from '../../../src/nest/auth/webauthn-config.service';
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
|
|
/**
|
|
* 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 };
|
|
});
|
|
|
|
// Injected instead of vi.mocking src/db/database (Plan 3b Task 1: the service
|
|
// now takes an AppSettingsRepository, not DatabaseService): the service reads
|
|
// exactly one method (`getValue`), so a fake repository over the same real
|
|
// Map keeps the suite free of a schema and states the only contact the
|
|
// resolver has, unchanged from the DatabaseService-stub version.
|
|
const appSettings = {
|
|
getValue: async (key: string) => settingsStore.get(key) ?? null,
|
|
} as unknown as AppSettingsRepository;
|
|
|
|
const svc = new WebauthnConfigService(appSettings);
|
|
|
|
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', async () => {
|
|
appUrlRef.value = 'https://trek.example.org';
|
|
const cfg = await 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(await svc.isConfigured()).toBe(true);
|
|
});
|
|
|
|
it('WAC-002: returns null for a bare-IP host (IPs are not valid RP IDs)', async () => {
|
|
appUrlRef.value = 'http://192.168.1.50:3001';
|
|
expect(await svc.resolve()).toBeNull();
|
|
expect(await svc.isConfigured()).toBe(false);
|
|
});
|
|
|
|
it('WAC-003: nothing configured resolves to the localhost dev fallback (#2147)', async () => {
|
|
// 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 = await svc.resolve();
|
|
expect(cfg!.rpID).toBe('localhost');
|
|
expect(cfg!.explicitOrigins).toBe(false);
|
|
expect(await svc.isConfigured()).toBe(true);
|
|
});
|
|
|
|
it('WAC-004: localhost dev uses the browser (Vite :5173) origin, not just the API port', async () => {
|
|
appUrlRef.value = 'http://localhost:3001';
|
|
const cfg = await 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', async () => {
|
|
appUrlRef.value = 'https://internal.example.org';
|
|
settingsStore.set('webauthn_rp_id', 'public.example.org');
|
|
settingsStore.set('webauthn_origins', 'https://public.example.org');
|
|
const cfg = await 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', async () => {
|
|
settingsStore.set('webauthn_rp_id', 'example.org');
|
|
settingsStore.set('webauthn_origins', 'https://a.example.org , https://b.example.org/');
|
|
const cfg = await 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', async () => {
|
|
vi.stubEnv('WEBAUTHN_RP_ID', 'env.example.org');
|
|
vi.stubEnv('WEBAUTHN_ORIGINS', 'https://env.example.org');
|
|
appUrlRef.value = 'https://ignored.example.org';
|
|
const cfg = await 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', async () => {
|
|
appUrlRef.value = 'https://trek.example.org';
|
|
settingsStore.set('webauthn_rp_id', 'trek.example.org');
|
|
const cfg = await 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', async () => {
|
|
appUrlRef.value = 'https://trek.example.org';
|
|
expect((await svc.resolve())!.rpID).toBe('trek.example.org');
|
|
|
|
settingsStore.set('webauthn_rp_id', 'moved.example.org');
|
|
|
|
expect((await svc.resolve())!.rpID).toBe('moved.example.org');
|
|
});
|
|
|
|
it('WAC-010: the WEBAUTHN_ORIGINS env var also marks the origin list explicit', async () => {
|
|
vi.stubEnv('WEBAUTHN_RP_ID', 'env.example.org');
|
|
vi.stubEnv('WEBAUTHN_ORIGINS', 'https://env.example.org');
|
|
expect((await 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);
|
|
});
|
|
});
|