export type IdentityEnvironment = "development" | "production"; export type IdentityConfigErrorCode = | "BASE_URL_INSECURE_IN_PRODUCTION" | "BASE_URL_INVALID" | "BASE_URL_LOOPBACK_IN_PRODUCTION" | "BASE_URL_MISSING" | "DASHBOARD_ORIGIN_INSECURE_IN_PRODUCTION" | "DASHBOARD_ORIGIN_INVALID" | "DASHBOARD_ORIGIN_LOOPBACK_IN_PRODUCTION" | "DASHBOARD_ORIGIN_UNTRUSTED" | "PUBLIC_SIGNUP_ENABLED_INVALID" | "PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION" | "SECRET_LOW_ENTROPY" | "SECRET_MISSING" | "SECRET_TOO_SHORT" | "TRUSTED_ORIGIN_INSECURE_IN_PRODUCTION" | "TRUSTED_ORIGIN_INVALID" | "TRUSTED_ORIGIN_LOOPBACK_IN_PRODUCTION" | "TRUSTED_ORIGIN_WILDCARD_UNSUPPORTED" | "TRUSTED_ORIGINS_EMPTY" | "TRUSTED_ORIGINS_MISSING"; const MIN_SECRET_LENGTH = 32; /** * Hostnames that are never a legitimate public production origin, matched exactly (after WHATWG * URL canonicalization, which already folds `127.1`, `2130706433`, and `0x7f000001` into * `127.0.0.1`, and lowercases every hostname). * * This is a purely *syntactic* classifier. It rejects literal and reserved loopback/unspecified * spellings; it performs no DNS resolution, so it cannot and does not prove that some arbitrary * public name will never resolve to a loopback address later. Network reachability remains the * deployment's own firewall/DNS responsibility. */ const IPV4_LOOPBACK_BLOCK = /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/u; const UNSPECIFIED_HOSTNAMES: ReadonlySet = new Set(["0.0.0.0", "[::]"]); const LITERAL_LOOPBACK_HOSTNAMES: ReadonlySet = new Set(["localhost"]); /** Expands a bracketed IPv6 `URL.hostname` into its 8 canonical 16-bit groups, or `undefined` when * the value is not a bracketed IPv6 literal. Handles `::` zero-compression and the mixed * `::ffff:a.b.c.d` notation the WHATWG parser preserves. */ function expandIpv6Hostname(hostname: string): readonly number[] | undefined { if (!hostname.startsWith("[") || !hostname.endsWith("]")) { return undefined; } let body = hostname.slice(1, -1); // Mixed notation: translate a trailing dotted-quad into two hex groups. const dotted = /^(.*:)(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/u.exec(body); if (dotted) { const octets = [dotted[2], dotted[3], dotted[4], dotted[5]].map((part) => Number(part)); if (octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) { return undefined; } const high = ((octets[0] as number) << 8) | (octets[1] as number); const low = ((octets[2] as number) << 8) | (octets[3] as number); body = `${dotted[1]}${high.toString(16)}:${low.toString(16)}`; } const [head, tail, ...extra] = body.split("::"); if (extra.length > 0) { return undefined; } const parseGroups = (value: string | undefined): number[] | undefined => { if (value === undefined || value === "") { return []; } const groups: number[] = []; for (const part of value.split(":")) { if (!/^[0-9a-f]{1,4}$/u.test(part)) { return undefined; } groups.push(Number.parseInt(part, 16)); } return groups; }; const leading = parseGroups(head); const trailing = tail === undefined ? [] : parseGroups(tail); if (!leading || !trailing) { return undefined; } if (tail === undefined) { return leading.length === 8 ? leading : undefined; } const missing = 8 - leading.length - trailing.length; if (missing < 0) { return undefined; } return [...leading, ...Array.from({ length: missing }, () => 0), ...trailing]; } /** Whether a canonicalized `URL.hostname` is a loopback or unspecified listen address in any * spelling this repository accepts — literal name, reserved `.localhost` namespace, the full IPv4 * `127.0.0.0/8` block, IPv6 `::1` compressed or expanded, IPv6 `::` unspecified, and IPv4-mapped * IPv6 forms of both in mixed (`::ffff:127.0.0.1`) and hexadecimal (`::ffff:7f00:1`) notation. */ function isLoopbackOrUnspecifiedHostname(hostname: string): boolean { const normalized = hostname.toLowerCase(); if (LITERAL_LOOPBACK_HOSTNAMES.has(normalized) || UNSPECIFIED_HOSTNAMES.has(normalized)) { return true; } // RFC 6761 reserves the entire `.localhost` namespace for loopback. if (normalized.endsWith(".localhost")) { return true; } if (IPV4_LOOPBACK_BLOCK.test(normalized)) { return true; } const groups = expandIpv6Hostname(normalized); if (!groups) { return false; } const isAllZero = groups.every((group) => group === 0); // `::` (unspecified) and `::1` (loopback). if (isAllZero || (groups.slice(0, 7).every((group) => group === 0) && groups[7] === 1)) { return true; } // IPv4-mapped IPv6 (`::ffff:a.b.c.d`): classify the embedded IPv4 address. const isMapped = groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff; if (isMapped) { const high = groups[6] as number; const low = groups[7] as number; const embedded = `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`; return IPV4_LOOPBACK_BLOCK.test(embedded) || embedded === "0.0.0.0"; } return false; } const errorMessages: Readonly> = Object.freeze({ BASE_URL_INSECURE_IN_PRODUCTION: "The identity base URL must use https in production.", BASE_URL_INVALID: "The identity base URL must be an absolute http or https URL.", BASE_URL_LOOPBACK_IN_PRODUCTION: "The identity base URL must not resolve to a loopback host in production.", BASE_URL_MISSING: "The identity base URL is required.", DASHBOARD_ORIGIN_INSECURE_IN_PRODUCTION: "The Dashboard origin must use https in production.", DASHBOARD_ORIGIN_INVALID: "The Dashboard origin must be an absolute http or https origin with no path, query, or fragment.", DASHBOARD_ORIGIN_LOOPBACK_IN_PRODUCTION: "The Dashboard origin must not resolve to a loopback host in production.", DASHBOARD_ORIGIN_UNTRUSTED: "The Dashboard origin must also be listed as a trusted origin, or emailed links will be refused.", PUBLIC_SIGNUP_ENABLED_INVALID: 'Public signup enablement must be an explicit boolean or the exact string "true"/"false".', PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION: "Public signup cannot be enabled in production until durable signup-abuse controls exist.", SECRET_LOW_ENTROPY: "The identity secret does not have sufficient entropy.", SECRET_MISSING: "The identity secret is required.", SECRET_TOO_SHORT: `The identity secret must be at least ${MIN_SECRET_LENGTH} characters.`, TRUSTED_ORIGIN_INSECURE_IN_PRODUCTION: "Trusted origins must use https in production.", TRUSTED_ORIGIN_INVALID: "Every trusted origin must be an absolute http or https origin with no path, query, or fragment.", TRUSTED_ORIGIN_LOOPBACK_IN_PRODUCTION: "Trusted origins must not resolve to a loopback host in production.", TRUSTED_ORIGIN_WILDCARD_UNSUPPORTED: "Wildcard trusted origins are not supported in this release.", TRUSTED_ORIGINS_EMPTY: "At least one trusted origin is required.", TRUSTED_ORIGINS_MISSING: "Trusted origins are required.", }); /** * Never carries the offending secret, URL, origin, hostname, or any other caller-supplied value. * Only a stable code and a fixed, generic message are ever exposed. */ export class IdentityConfigError extends Error { readonly code: IdentityConfigErrorCode; constructor(code: IdentityConfigErrorCode) { super(errorMessages[code]); this.name = "IdentityConfigError"; this.code = code; this.stack = `${this.name}: ${this.message}`; Object.freeze(this); } } export interface IdentityConfig { readonly baseUrl: string; /** * The browser origin an emailed verification or password-reset link sends a person to. * * Defaults to `baseUrl`, which is the ordinary production shape: the Dashboard bundle and the API * are served from one origin. It exists as its own value because they are not the same *concept* — * `baseUrl` is where the `/api/auth` transport lives, this is where a human's browser lands — and * a deployment may separate them (an API at `https://api.example.org`, the Dashboard at * `https://dash.example.org`). It is deliberately **not** the public church website origin: this * is the authenticated Dashboard host and nothing else. * * Validated to be one of `trustedOrigins` (or `baseUrl`, which Better Auth trusts implicitly), so * a misconfiguration fails at startup rather than producing emailed links every recipient is * refused at. */ readonly dashboardOrigin: string; /** Whether `POST /api/auth/sign-up/email` accepts new accounts. Defaults to `false` (disabled) — * public signup never becomes reachable merely because this input was omitted, misspelled, or * loosely parsed. Development and test composition may enable it explicitly; production * currently cannot enable it at all (`PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION`), because the * durable signup-abuse prerequisites do not exist yet — see `validatePublicSignupEnabled`. In * production this is therefore always `false`. */ readonly publicSignupEnabled: boolean; readonly secret: string; readonly trustedOrigins: readonly string[]; } export interface IdentityConfigInput { readonly baseUrl?: unknown; /** Optional. Absent or empty means the Dashboard is served from `baseUrl`. */ readonly dashboardOrigin?: unknown; readonly publicSignupEnabled?: unknown; readonly secret?: unknown; readonly trustedOrigins?: unknown; } export interface CreateIdentityConfigOptions { /** Defaults to "production" so an omitted environment never silently relaxes validation. */ readonly environment?: IdentityEnvironment; } /** * Parses one value that must be exactly a canonical absolute `http:`/`https:` **origin** — the * single representation both the identity base URL and every trusted origin use, so no component * of the platform can reinterpret the same deployment differently. * * Canonical means `URL.origin` must equal the configured string byte-for-byte. That one comparison * structurally rejects every non-origin component at once — credentials, any path (including a * bare trailing `/`, since `URL.origin` is slashless), a query, a fragment, surrounding * whitespace, an uppercase hostname, and a redundant default port — because the WHATWG parser * normalizes or preserves each of those in a way that no longer matches `origin`. * * Structural parsing is deliberately separate from environment policy: a caller distinguishes * "this is not an origin" from "this origin is not allowed in production" and reports the * structural code first, so an already-malformed URL is never mislabeled as a production-loopback * failure. */ function parseCanonicalHttpOrigin(value: unknown): URL | undefined { if (typeof value !== "string" || value.length === 0) { return undefined; } let url: URL; try { url = new URL(value); } catch { return undefined; } if (url.protocol !== "http:" && url.protocol !== "https:") { return undefined; } // `URL.origin` carries no credentials, path, query, or fragment, and is already lowercased with // any default port elided — so requiring exact equality is the whole canonical-origin rule. return url.origin === value ? url : undefined; } function validateSecret(secret: unknown): string { if (typeof secret !== "string" || secret.length === 0) { throw new IdentityConfigError("SECRET_MISSING"); } if (secret.length < MIN_SECRET_LENGTH) { throw new IdentityConfigError("SECRET_TOO_SHORT"); } // A narrow, deterministic guard against trivially degenerate secrets (e.g. one repeated // character). It cannot measure true entropy; operators remain responsible for generating a // high-entropy value (for example `openssl rand -base64 32`). if (/^(.)\1*$/u.test(secret)) { throw new IdentityConfigError("SECRET_LOW_ENTROPY"); } return secret; } function validateBaseUrl(baseUrl: unknown, environment: IdentityEnvironment): string { if (typeof baseUrl !== "string" || baseUrl.length === 0) { throw new IdentityConfigError("BASE_URL_MISSING"); } // Structural validity first, in every environment: the base URL is the public origin of the // already-fixed `/api/auth` transport, and Ekklesia does not support mounting Better Auth // beneath a configurable URL path. A credentialed, pathed, queried, fragmented, whitespace- // padded, or otherwise non-canonical spelling is structurally invalid, never a policy failure. const url = parseCanonicalHttpOrigin(baseUrl); if (!url) { throw new IdentityConfigError("BASE_URL_INVALID"); } if (environment === "production") { if (url.protocol !== "https:") { throw new IdentityConfigError("BASE_URL_INSECURE_IN_PRODUCTION"); } if (isLoopbackOrUnspecifiedHostname(url.hostname)) { throw new IdentityConfigError("BASE_URL_LOOPBACK_IN_PRODUCTION"); } } // Store the canonical origin — identical to what `validateTrustedOrigin` stores — so CORS, // Better Auth's own origin checks, and this base URL can never disagree about the same origin. return url.origin; } /** * Resolves the origin an emailed link points a browser at. * * Unset falls back to the identity base URL — the same-origin production shape — rather than * failing, because a deployment that has not separated its Dashboard from its API has nothing to * configure. When it *is* set it is held to exactly the base URL's rules and then to one more: it * must be an origin Better Auth already trusts, because Better Auth validates the emailed callback * against `trustedOrigins` before redirecting. Checking it here converts a broken link every * recipient would hit into a startup failure the operator sees once. */ function validateDashboardOrigin( dashboardOrigin: unknown, baseUrl: string, trustedOrigins: readonly string[], environment: IdentityEnvironment, ): string { if (dashboardOrigin === undefined || dashboardOrigin === null || dashboardOrigin === "") { return baseUrl; } const url = parseCanonicalHttpOrigin(dashboardOrigin); if (!url) { throw new IdentityConfigError("DASHBOARD_ORIGIN_INVALID"); } if (environment === "production") { if (url.protocol !== "https:") { throw new IdentityConfigError("DASHBOARD_ORIGIN_INSECURE_IN_PRODUCTION"); } if (isLoopbackOrUnspecifiedHostname(url.hostname)) { throw new IdentityConfigError("DASHBOARD_ORIGIN_LOOPBACK_IN_PRODUCTION"); } } // `baseUrl` is accepted without appearing in `trustedOrigins` because Better Auth itself adds the // base URL's origin to its trusted set (`getTrustedOrigins`, pinned release). if (url.origin !== baseUrl && !trustedOrigins.includes(url.origin)) { throw new IdentityConfigError("DASHBOARD_ORIGIN_UNTRUSTED"); } return url.origin; } function validateTrustedOrigin(origin: unknown, environment: IdentityEnvironment): string { if (typeof origin !== "string" || origin.length === 0) { throw new IdentityConfigError("TRUSTED_ORIGIN_INVALID"); } // Better Auth itself supports glob-style wildcard trusted origins. Phase 4A deliberately does // not: cross-subdomain cookies are not enabled yet (see ADR 0010), so a wildcard origin would // widen the trusted-origin surface without a matching cookie-scope decision. Revisit only // alongside a future ADR that enables cross-subdomain cookies. if (origin.includes("*") || origin.includes("?")) { throw new IdentityConfigError("TRUSTED_ORIGIN_WILDCARD_UNSUPPORTED"); } const url = parseCanonicalHttpOrigin(origin); if (!url) { throw new IdentityConfigError("TRUSTED_ORIGIN_INVALID"); } if (environment === "production") { if (url.protocol !== "https:") { throw new IdentityConfigError("TRUSTED_ORIGIN_INSECURE_IN_PRODUCTION"); } // The identical classifier the base URL uses, so the two can never disagree about whether the // same host is a production-legal origin. if (isLoopbackOrUnspecifiedHostname(url.hostname)) { throw new IdentityConfigError("TRUSTED_ORIGIN_LOOPBACK_IN_PRODUCTION"); } } return url.origin; } /** * Strict, explicit-only parsing: `undefined` is the one safe default (disabled), a genuine boolean * passes straight through for programmatic callers, and the exact lowercase strings `"true"`/ * `"false"` are accepted so an environment variable (always a string) can opt in — anything else * (a misspelling, `"1"`, `"yes"`, mixed case, an empty string) is rejected outright rather than * silently coerced to `false` or `true`. There is deliberately no "loosely truthy" branch. * * Parsing is deliberately separated from the production policy below: a malformed value is * reported as malformed in *every* environment (`PUBLIC_SIGNUP_ENABLED_INVALID`), so an operator * who typo'd the flag is never told the misleading "unsupported in production" story instead. * * Enabling public signup is then refused outright in production * (`PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION`). This is not a preference — the durable * signup-abuse prerequisites a public registration endpoint needs (verified email or an * invitation requirement, plus durable shared/edge throttling rather than this release's * per-process in-memory limiter) do not exist in this repository yet, so there is no safe * production configuration in which the flag could be honored. Development and test composition * may still enable it explicitly through this same typed path. When those prerequisites are * actually implemented and proven, this check is the single place that changes. */ function validatePublicSignupEnabled(value: unknown, environment: IdentityEnvironment): boolean { let enabled: boolean; if (value === undefined) { enabled = false; } else if (typeof value === "boolean") { enabled = value; } else if (value === "true") { enabled = true; } else if (value === "false") { enabled = false; } else { throw new IdentityConfigError("PUBLIC_SIGNUP_ENABLED_INVALID"); } if (enabled && environment === "production") { throw new IdentityConfigError("PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION"); } return enabled; } function validateTrustedOrigins( trustedOrigins: unknown, environment: IdentityEnvironment, ): readonly string[] { if (trustedOrigins === undefined || trustedOrigins === null) { throw new IdentityConfigError("TRUSTED_ORIGINS_MISSING"); } if (!Array.isArray(trustedOrigins) || trustedOrigins.length === 0) { throw new IdentityConfigError("TRUSTED_ORIGINS_EMPTY"); } return Object.freeze(trustedOrigins.map((origin) => validateTrustedOrigin(origin, environment))); } /** * Validates explicit, typed identity configuration. Performs no environment reads, network * calls, or database access; callers are responsible for sourcing `input` (for example from * environment variables) before calling this function. */ export function createIdentityConfig( input: IdentityConfigInput, options: CreateIdentityConfigOptions = {}, ): IdentityConfig { const environment = options.environment ?? "production"; const secret = validateSecret(input.secret); const baseUrl = validateBaseUrl(input.baseUrl, environment); const trustedOrigins = validateTrustedOrigins(input.trustedOrigins, environment); const dashboardOrigin = validateDashboardOrigin( input.dashboardOrigin, baseUrl, trustedOrigins, environment, ); const publicSignupEnabled = validatePublicSignupEnabled(input.publicSignupEnabled, environment); return Object.freeze({ baseUrl, dashboardOrigin, publicSignupEnabled, secret, trustedOrigins, }); }