Files
Ekklesia/core/src/identity/config.ts
T
ekklesiaandClaude Opus 5 1161f5a076 fix: fail closed on production signup
Production configuration accepted publicSignupEnabled: true and started a
working Identity/API runtime, even though Ekklesia has no verified email,
invitation requirement, CAPTCHA, or durable shared/edge limiter — Doctor
merely reported "Public signup is explicitly ENABLED" as a pass.

validatePublicSignupEnabled now takes the same IdentityEnvironment the rest
of this config validates against and throws the stable
PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION whenever the parsed value is true in
production. Parsing stays separate from the policy, so a malformed value
still reports PUBLIC_SIGNUP_ENABLED_INVALID everywhere. Because the
executable entry point validates configuration before startApi, such a
deployment fails before any pool, Better Auth instance, module runtime, or
listener exists, and Doctor reports NOT READY via its existing
IdentityConfigError path. Development and test composition still enable it
explicitly through the identical typed path.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 15:58:08 -05:00

240 lines
9.8 KiB
TypeScript

export type IdentityEnvironment = "development" | "production";
export type IdentityConfigErrorCode =
| "BASE_URL_INSECURE_IN_PRODUCTION"
| "BASE_URL_INVALID"
| "BASE_URL_LOOPBACK_IN_PRODUCTION"
| "BASE_URL_MISSING"
| "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;
const LOOPBACK_HOSTNAMES: ReadonlySet<string> = new Set([
"localhost",
"127.0.0.1",
"::1",
"0.0.0.0",
]);
const errorMessages: Readonly<Record<IdentityConfigErrorCode, string>> = 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.",
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;
/** 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;
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;
}
function isLoopbackHostname(hostname: string): boolean {
return LOOPBACK_HOSTNAMES.has(hostname.toLowerCase());
}
function parseAbsoluteHttpUrl(value: unknown): URL | undefined {
if (typeof value !== "string" || value.length === 0) {
return undefined;
}
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:" ? url : undefined;
} catch {
return 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");
}
const url = parseAbsoluteHttpUrl(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 (isLoopbackHostname(url.hostname)) {
throw new IdentityConfigError("BASE_URL_LOOPBACK_IN_PRODUCTION");
}
}
return baseUrl;
}
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 = parseAbsoluteHttpUrl(origin);
if (!url || url.origin !== origin) {
throw new IdentityConfigError("TRUSTED_ORIGIN_INVALID");
}
if (environment === "production") {
if (url.protocol !== "https:") {
throw new IdentityConfigError("TRUSTED_ORIGIN_INSECURE_IN_PRODUCTION");
}
if (isLoopbackHostname(url.hostname)) {
throw new IdentityConfigError("TRUSTED_ORIGIN_LOOPBACK_IN_PRODUCTION");
}
}
return 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 publicSignupEnabled = validatePublicSignupEnabled(input.publicSignupEnabled, environment);
return Object.freeze({ baseUrl, publicSignupEnabled, secret, trustedOrigins });
}