fix(auth): fail fast on passkey origins outside the relying party scope (#2147)

This commit is contained in:
Maurice
2026-09-01 14:41:54 +02:00
committed by Maurice
parent d5a3427cbb
commit 4704bf5903
6 changed files with 331 additions and 26 deletions
+4 -2
View File
@@ -49,7 +49,9 @@ export class PasskeyController {
@UseGuards(PasskeyEnabledGuard, JwtAuthGuard)
async registerOptions(@CurrentUser() user: User, @Body() body: PasskeyRegisterOptionsDto, @Req() req: Request) {
this.limit('mfa', req, 5);
const result = await this.passkeys.passkeyRegisterOptions(user.id, body?.password);
// The Origin header is only a pre-ceremony sanity input (see PasskeyService)
// — the RP ID itself is never derived from request headers.
const result = await this.passkeys.passkeyRegisterOptions(user.id, body?.password, req.headers.origin);
if (result.error) throw new HttpException({ error: result.error }, result.status!);
return result.options;
}
@@ -72,7 +74,7 @@ export class PasskeyController {
@UseGuards(PasskeyEnabledGuard)
async loginOptions(@Req() req: Request) {
this.limit('login', req, 10);
const result = await this.passkeys.passkeyLoginOptions();
const result = await this.passkeys.passkeyLoginOptions(req.headers.origin);
if (result.error) throw new HttpException({ error: result.error }, result.status!);
return result.options;
}
+84 -10
View File
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import bcrypt from 'bcryptjs';
import {
generateRegistrationOptions,
@@ -7,7 +7,7 @@ import {
verifyAuthenticationResponse,
type AuthenticatorTransportFuture,
} from '@simplewebauthn/server';
import { WebauthnConfigService } from './webauthn-config.service';
import { WebauthnConfigService, originWithinRpScope, type WebauthnConfig } from './webauthn-config.service';
import { avatarUrl } from '../common/avatarUrl';
import { stripUserForClient } from './auth.helpers';
import { AuthService } from './auth.service';
@@ -52,17 +52,27 @@ interface CredentialRow {
last_used_at: string | null;
}
function challengeFromResponse(resp: unknown): string | null {
function clientDataFromResponse(resp: unknown): { challenge?: unknown; origin?: unknown } | null {
try {
const cdj = (resp as { response?: { clientDataJSON?: unknown } })?.response?.clientDataJSON;
if (typeof cdj !== 'string') return null;
const parsed = JSON.parse(Buffer.from(cdj, 'base64url').toString('utf8')) as { challenge?: unknown };
return typeof parsed.challenge === 'string' ? parsed.challenge : null;
return JSON.parse(Buffer.from(cdj, 'base64url').toString('utf8')) as { challenge?: unknown; origin?: unknown };
} catch {
return null;
}
}
function challengeFromResponse(resp: unknown): string | null {
const challenge = clientDataFromResponse(resp)?.challenge;
return typeof challenge === 'string' ? challenge : null;
}
/** The browser-asserted origin of the ceremony (clientDataJSON.origin). */
function originFromResponse(resp: unknown): string | null {
const origin = clientDataFromResponse(resp)?.origin;
return typeof origin === 'string' ? origin : null;
}
function parseTransports(raw: string | null): AuthenticatorTransportFuture[] | undefined {
if (!raw) return undefined;
try {
@@ -91,6 +101,8 @@ function defaultCredentialName(deviceType: string | undefined): string {
*/
@Injectable()
export class PasskeyService {
private readonly logger = new Logger(PasskeyService.name);
constructor(
private readonly db: DatabaseService,
private readonly auth: AuthService,
@@ -126,6 +138,55 @@ export class PasskeyService {
return row ?? null;
}
// -------------------------------------------------------------------------
// Origin handling (#2147)
// -------------------------------------------------------------------------
/**
* The origins handed to the verifier for this ceremony. An explicit operator
* list is honored verbatim. A derived config (APP_URL fallback) often carries
* the wrong scheme or port (TLS-terminating proxy, first ALLOWED_ORIGINS
* entry), so the browser-asserted origin is added IF it falls within the RP
* ID's WebAuthn scope — the same suffix rule the browser enforces before it
* runs the ceremony at all. rpIdHash, challenge and signature checks are
* untouched, so this never accepts an origin the RP ID doesn't cover.
*/
private expectedOrigins(cfg: WebauthnConfig, resp: unknown): string[] {
if (cfg.explicitOrigins) return cfg.origins;
const clientOrigin = originFromResponse(resp);
if (!clientOrigin || cfg.origins.includes(clientOrigin) || !originWithinRpScope(clientOrigin, cfg.rpID)) {
return cfg.origins;
}
return [...cfg.origins, clientOrigin];
}
/**
* Pre-ceremony check of the request's Origin header against the resolved RP
* config. A browser on an origin the derived localhost fallback can never
* verify (nothing configured, accessed remotely) gets the actionable
* not-configured 400 BEFORE the ceremony instead of a cryptic verify failure
* after it. Any other mismatch is only logged: proxies may rewrite the
* Origin header while clientDataJSON still carries the real one, and an
* explicit origins list is the operator's contract. Requests without a
* parseable http(s) Origin (curl, 'null') skip the check entirely.
*/
private originCannotVerify(cfg: WebauthnConfig, requestOrigin: string | undefined): boolean {
if (!requestOrigin) return false;
let origin: string;
try {
const url = new URL(requestOrigin);
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
origin = url.origin;
} catch {
return false;
}
if (cfg.origins.includes(origin) || originWithinRpScope(origin, cfg.rpID)) return false;
this.logger.warn(
`Passkey ceremony requested from origin ${origin}, but the RP config resolves to rpID=${cfg.rpID}, origins=[${cfg.origins.join(', ')}] — set APP_URL or the webauthn_rp_id/webauthn_origins settings.`,
);
return !cfg.explicitOrigins && cfg.rpID === 'localhost';
}
// -------------------------------------------------------------------------
// Registration (authenticated — from Settings, password re-auth required)
// -------------------------------------------------------------------------
@@ -133,9 +194,11 @@ export class PasskeyService {
async passkeyRegisterOptions(
userId: number,
password: string | undefined,
requestOrigin?: string,
): Promise<{ error?: string; status?: number; options?: Awaited<ReturnType<typeof generateRegistrationOptions>> }> {
const cfg = this.webauthn.resolve();
if (!cfg) return { ...NOT_CONFIGURED };
if (this.originCannotVerify(cfg, requestOrigin)) return { ...NOT_CONFIGURED };
const user = this.db.get<User>('SELECT * FROM users WHERE id = ?', userId);
if (!user) return { error: 'User not found', status: 404 };
@@ -190,16 +253,22 @@ export class PasskeyService {
return { error: 'Registration challenge expired. Please try again.', status: 400 };
}
const expectedOrigin = this.expectedOrigins(cfg, resp);
let verification;
try {
verification = await verifyRegistrationResponse({
response: resp as Parameters<typeof verifyRegistrationResponse>[0]['response'],
expectedChallenge: challenge,
expectedOrigin: cfg.origins,
expectedOrigin,
expectedRPID: cfg.rpID,
requireUserVerification: true,
});
} catch {
} catch (err) {
// Body stays the generic 400 (parity), but the operator gets to see WHY —
// origin/RP mismatches used to be swallowed entirely.
this.logger.warn(
`Passkey registration rejected (expectedRPID=${cfg.rpID}, expectedOrigin=[${expectedOrigin.join(', ')}]): ${err instanceof Error ? err.message : String(err)}`,
);
return { error: 'Could not register this passkey.', status: 400 };
}
@@ -253,13 +322,14 @@ export class PasskeyService {
// Authentication (public — primary, discoverable-credential login)
// -------------------------------------------------------------------------
async passkeyLoginOptions(): Promise<{
async passkeyLoginOptions(requestOrigin?: string): Promise<{
error?: string;
status?: number;
options?: Awaited<ReturnType<typeof generateAuthenticationOptions>>;
}> {
const cfg = this.webauthn.resolve();
if (!cfg) return { ...NOT_CONFIGURED };
if (this.originCannotVerify(cfg, requestOrigin)) return { ...NOT_CONFIGURED };
const now = Date.now();
this.purgeExpiredChallenges(now);
@@ -302,12 +372,13 @@ export class PasskeyService {
const cred = this.db.get<CredentialRow>('SELECT * FROM webauthn_credentials WHERE credential_id = ?', credId);
if (!cred) return { ...AUTH_FAILED };
const expectedOrigin = this.expectedOrigins(cfg, resp);
let verification;
try {
verification = await verifyAuthenticationResponse({
response: resp as Parameters<typeof verifyAuthenticationResponse>[0]['response'],
expectedChallenge: challenge,
expectedOrigin: cfg.origins,
expectedOrigin,
expectedRPID: cfg.rpID,
requireUserVerification: true,
credential: {
@@ -317,7 +388,10 @@ export class PasskeyService {
transports: parseTransports(cred.transports),
},
});
} catch {
} catch (err) {
this.logger.warn(
`Passkey login rejected (expectedRPID=${cfg.rpID}, expectedOrigin=[${expectedOrigin.join(', ')}]): ${err instanceof Error ? err.message : String(err)}`,
);
return { ...AUTH_FAILED };
}
@@ -17,11 +17,38 @@ function isIpHost(host: string): boolean {
return false;
}
/**
* The scope rule WebAuthn itself applies when the browser runs a ceremony: the
* origin's host must equal the RP ID or be a subdomain of it, over https
* (plain http only for localhost loopback dev). Used to judge whether an
* origin could ever complete a ceremony against a given RP ID — never to
* derive the RP ID from anything request-controlled.
*/
export function originWithinRpScope(origin: string, rpID: string): boolean {
let url: URL;
try {
url = new URL(origin);
} catch {
return false;
}
const host = url.hostname;
const loopback = host === 'localhost' || host === '127.0.0.1';
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) return false;
return host === rpID || host.endsWith(`.${rpID}`);
}
export interface WebauthnConfig {
rpID: string;
rpName: string;
/** Exact allowed origins (scheme + host + port). One in prod; localhost dev adds the Vite/API ports. */
origins: string[];
/**
* True when `origins` came from operator config (env / admin setting) — such
* a list is honored verbatim. False when derived from APP_URL, where the
* scheme/port may not match the real browsing origin (TLS-terminating proxy)
* and the verifier may widen within the RP ID's scope.
*/
explicitOrigins: boolean;
}
/**
@@ -87,7 +114,7 @@ export class WebauthnConfigService {
}
if (origins.length === 0) return null;
return { rpID, rpName: 'TREK', origins };
return { rpID, rpName: 'TREK', origins, explicitOrigins: explicitOrigins.length > 0 };
}
/** True when a usable RP ID resolves for this deployment (exposed as a pure boolean on app-config). */
+54 -1
View File
@@ -36,9 +36,12 @@ vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn(), broadcast: vi.
// The audit domain is DI-native: writeAudit runs for real against the temp
// db's audit_log table; only the file logger is silenced.
vi.mock('../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn() }));
// Switchable so the passkey cases can reproduce the real APP_URL-unset
// fallback (http://localhost:{PORT}) without disturbing the other cases.
const { appUrlRef } = vi.hoisted(() => ({ appUrlRef: { value: 'https://x' } }));
vi.mock('../../src/app-config', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/app-config')>();
return { ...actual, getAppUrl: () => 'https://x' };
return { ...actual, getAppUrl: () => appUrlRef.value };
});
import { MailerService } from '../../src/nest/notifications/mailer/mailer.service';
@@ -326,6 +329,56 @@ describe('Auth e2e (real auth guard + real service + real cookie service + temp
expect(await hasMapsKey(admin.user.id)).toBe(true);
});
describe('passkey origin gate (#2147)', () => {
beforeAll(() => {
db.prepare("INSERT INTO app_settings (key, value) VALUES ('passkey_login', 'true') ON CONFLICT(key) DO UPDATE SET value = 'true'").run();
// What getAppUrl() really yields with APP_URL unset — the phantom config.
appUrlRef.value = 'http://localhost:3001';
});
afterAll(() => {
db.prepare("DELETE FROM app_settings WHERE key = 'passkey_login'").run();
appUrlRef.value = 'https://x';
});
it('register/options answers the actionable not-configured 400 for a remote origin', async () => {
const res = await request(server)
.post('/api/auth/passkey/register/options')
.set('Cookie', sessionCookie(userId))
.set('Origin', 'https://trip.example.org')
.send({ password: userPassword });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'Passkey login is not configured for this server.' });
});
it('register/options still serves the localhost dev flow (no Origin header, localhost origin)', async () => {
const bare = await request(server)
.post('/api/auth/passkey/register/options')
.set('Cookie', sessionCookie(userId))
.send({ password: userPassword });
expect(bare.status).toBe(200);
expect(bare.body.rp).toEqual({ name: 'TREK', id: 'localhost' });
const local = await request(server)
.post('/api/auth/passkey/register/options')
.set('Cookie', sessionCookie(userId))
.set('Origin', 'http://localhost:5173')
.send({ password: userPassword });
expect(local.status).toBe(200);
expect(local.body.rp.id).toBe('localhost');
});
it('login/options gets the same gate on the same derivation chain', async () => {
resetRateLimits(app); // shares the per-ip 'login' bucket with the login cases above
const res = await request(server)
.post('/api/auth/passkey/login/options')
.set('Origin', 'https://trip.example.org')
.send({});
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'Passkey login is not configured for this server.' });
});
});
it('POST /logout clears the session cookie', async () => {
const res = await request(server).post('/api/auth/logout');
expect(res.status).toBe(200);
+112 -6
View File
@@ -1,11 +1,13 @@
/**
* Unit tests for the DI-native PasskeyService — PASSKEY-SVC-001 through
* PASSKEY-SVC-032. Written fresh with the DI fold: the legacy
* PASSKEY-SVC-039. 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).
* dup-check → INSERT with its 409-vs-400 sentinel split); 033–039 pin the
* #2147 origin hardening (pre-ceremony Origin gate for the derived localhost
* fallback, in-scope widening of expectedOrigin for derived configs).
* 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).
@@ -118,7 +120,9 @@ const auth = new AuthService(
);
const svc = new PasskeyService(new DatabaseService(testDb), auth, webauthn);
const CFG = { rpID: 'trek.example.com', rpName: 'TREK', origins: ['https://trek.example.com'] };
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.';
beforeAll(() => {
@@ -138,10 +142,10 @@ afterAll(() => {
// ── helpers ───────────────────────────────────────────────────────────────────
/** A response payload whose clientDataJSON echoes the given challenge. */
function ceremonyResponse(challenge: string, extra: Record<string, unknown> = {}) {
/** 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 }), 'utf8').toString('base64url') },
response: { clientDataJSON: Buffer.from(JSON.stringify({ challenge, ...clientData }), 'utf8').toString('base64url') },
...extra,
};
}
@@ -241,6 +245,45 @@ describe('passkeyRegisterOptions', () => {
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(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 ─────────────────────────────────────────────────────
@@ -362,6 +405,42 @@ describe('passkeyRegisterVerify', () => {
expect(row).toEqual({ name: 'Original', user_id: user.id });
expect(testDb.prepare('SELECT COUNT(*) AS n FROM webauthn_credentials').get()).toEqual({ n: 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);
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 });
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);
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']);
});
});
// ── passkeyLoginOptions ───────────────────────────────────────────────────────
@@ -384,6 +463,16 @@ describe('passkeyLoginOptions', () => {
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 ────────────────────────────────────────────────────────
@@ -508,6 +597,23 @@ describe('passkeyLoginVerify', () => {
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);
insertCredential(user.id, { credential_id: 'scheme-heal' });
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']);
});
});
// ── listPasskeys ──────────────────────────────────────────────────────────────
@@ -8,9 +8,12 @@
* 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: '' },
appUrlRef: { value: 'http://localhost:3001' },
}));
vi.mock('../../../src/app-config', async (importOriginal) => {
@@ -20,7 +23,7 @@ vi.mock('../../../src/app-config', async (importOriginal) => {
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { WebauthnConfigService } from '../../../src/nest/auth/webauthn-config.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
@@ -38,7 +41,7 @@ const svc = new WebauthnConfigService(new DatabaseService(conn));
beforeEach(() => {
settingsStore.clear();
appUrlRef.value = '';
appUrlRef.value = 'http://localhost:3001';
});
afterEach(() => {
@@ -52,6 +55,7 @@ describe('WebauthnConfigService.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);
});
@@ -61,9 +65,14 @@ describe('WebauthnConfigService.resolve', () => {
expect(svc.isConfigured()).toBe(false);
});
it('WAC-003: returns null when nothing is configured', () => {
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', () => {
@@ -81,6 +90,7 @@ describe('WebauthnConfigService.resolve', () => {
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', () => {
@@ -104,6 +114,7 @@ describe('WebauthnConfigService.resolve', () => {
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', () => {
@@ -114,4 +125,36 @@ describe('WebauthnConfigService.resolve', () => {
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);
});
});