/** * Who this application is, as far as WebAuthn is concerned (#37). * * ## Why this is derived rather than written down * * The Relying Party ID is a domain, and **a credential is bound to it * permanently**. A passkey registered against one RP ID cannot be used against * another — there is no migration, no re-signing, and no way to carry one over. * So the RP ID is the one piece of configuration that must never be wrong, and * must never be a value someone remembered to change. * * It comes from `PUBLIC_URL`, which is the same value every customer-facing * link is already built from. That makes the RP ID correct by construction in * any environment where mail works, and wrong only in environments where the * links were already wrong. * * ## The consequence worth stating plainly * * Each environment is a different Relying Party: * * | Environment | RP ID | Effect | * | --- | --- | --- | * | Local | `localhost` | A secure context by exception, so passkeys work | * | QA | the QA hostname | Registered here, usable only here | * | Production | the production hostname | Different credentials again | * * **QA can prove the flow and can never prove the credentials.** A passkey * registered in QA will not sign in to production, and that is correct rather * than a bug to work around. * * It also means **#313 destroys every passkey registered before it**. Moving to * `redefined-designs.com` changes the RP ID, so credentials bound to * `*.bermudalamb.synology.me` stop working at the cutover with no way back. * This code needs no change when that happens — it follows `PUBLIC_URL` — but * anyone who registered a passkey beforehand has to register it again. That is * free today, because production is not live and no real customer holds one, * and it stops being free the moment the shop opens. */ /** Everything the ceremonies need to identify this Relying Party. */ export interface RelyingParty { /** The RP ID: a bare domain, no scheme and no port. */ id: string; /** Shown by the authenticator when it asks the customer to confirm. */ name: string; /** * Origins a ceremony may legitimately come from. * * A list rather than one string because local development serves the app from * two: Vite on 5173 during `npm run dev`, and the backend on 3000 when the * built frontend is served by Express. Both are `localhost`, so both are the * same Relying Party — only the port differs, and the port is not part of the * RP ID. Deployed environments have exactly one. */ origins: string[]; } export const RELYING_PARTY_NAME = 'Redefined Designs'; /** * Local development, where `PUBLIC_URL` is legitimately unset. * * `envValidation` requires `PUBLIC_URL` only when SMTP is configured, so a local * setup that cannot send mail does not have it — and refusing to start there * would break every such setup to prevent nothing. `localhost` is a secure * context by exception in every browser that implements WebAuthn, so this works * without TLS. */ const LOCAL_ORIGINS = ['http://localhost:5173', 'http://localhost:3000']; /** * The Relying Party for this environment. * * Takes the environment as an argument so it can be tested without touching * `process.env`, and reads it on each call rather than at import time: the * module would otherwise capture whatever was set when it was first required, * which in tests is whatever the previous suite happened to leave behind. * * Throws on a `PUBLIC_URL` that is set but unparseable. That is a deployment * that will also produce broken links in every email, so failing here is not * the first thing to go wrong — it is the first thing to *say so*. */ export function relyingParty(env: NodeJS.ProcessEnv = process.env): RelyingParty { const publicUrl = (env.PUBLIC_URL ?? '').trim(); if (publicUrl === '') { return { id: 'localhost', name: RELYING_PARTY_NAME, origins: LOCAL_ORIGINS }; } let parsed: URL; try { parsed = new URL(publicUrl); } catch { throw new Error( `PUBLIC_URL is not a URL (${publicUrl}), so the WebAuthn Relying Party ID cannot be ` + 'derived from it. Every passkey is bound permanently to that ID, so this is refused ' + 'rather than guessed at.' ); } return { // `hostname` rather than `host`: the RP ID is a domain and must not carry a // port. `host` includes one when the URL has it, and an RP ID of // "example.com:8443" matches nothing. id: parsed.hostname, name: RELYING_PARTY_NAME, // `origin` normalises away any path, trailing slash or default port, which // is exactly the string the browser will report. origins: [parsed.origin] }; }