Ekklesia could authenticate a person and, since Pass 1, send a transactional email — but it had no way to establish that an address belonged to whoever claimed it, and no way back in for someone who forgot their password. A verified address is now a precondition for holding a session. Better Auth enforces that after the password check and before any session exists, so an unverified sign-in returns 403 with no cookie. That is why no "unverified principal" was needed: a correct password on an unverified address carries exactly the authority of no password, and /api/v1/principal is unchanged. Better Auth keeps every token. Ekklesia adds no token table, no hashing, and no expiry clock. It supplies a recipient, a subject, and two bodies, and the mail provider supplies everything about SMTP. Verification and reset links are rebuilt from a validated Dashboard origin, taking only the token — never the URL Better Auth prebuilds from a caller-supplied callbackURL — so no request can steer where a recipient lands. Following a verification link mints no session, because mail infrastructure dereferences links. Reset revokes that person's other sessions and leaves managed devices alone. Recovery answers identically for an address with an account, without one, already verified, or undeliverable — including when SMTP is broken, which is why the delivery adapter never throws. The initial platform owner is the one exception: created already verified by an operator command with no mail prerequisite, so a fresh server can be signed in to before a relay exists. Migration 0007 extends that to owners of installations that already exist. Public signup stays disabled; verification is not a licence to self-register. Co-Authored-By: Claude Opus 5 <[email protected]>
441 lines
19 KiB
TypeScript
441 lines
19 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"
|
|
| "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<string> = new Set(["0.0.0.0", "[::]"]);
|
|
const LITERAL_LOOPBACK_HOSTNAMES: ReadonlySet<string> = 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<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.",
|
|
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,
|
|
});
|
|
}
|