Files
Ekklesia/core/tests/identity-config.test.ts
T
ekklesiaandClaude Opus 5 ea28f8aa2e
CI / verify (pull_request) Successful in 42s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 1m42s
feat: verify email addresses and recover forgotten passwords
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]>
2026-08-25 07:21:17 -05:00

530 lines
19 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { createIdentityConfig, IdentityConfigError } from "../src/identity/config.js";
const validSecret = "correct-horse-battery-staple-0123456789";
const validBaseUrl = "https://identity.example.test";
const validTrustedOrigins = ["https://identity.example.test", "https://dashboard.example.test"];
function validInput() {
return { baseUrl: validBaseUrl, secret: validSecret, trustedOrigins: validTrustedOrigins };
}
/** The loopback/`http` shape only `{ environment: "development" }` accepts — used by the tests that
* exercise development-only behavior (such as explicitly enabling public signup) so they never
* accidentally pass through production validation. */
function developmentInput() {
return {
baseUrl: "http://localhost:3000",
secret: validSecret,
trustedOrigins: ["http://localhost:3000"],
};
}
describe("Core identity configuration", () => {
it("accepts a valid secret, base URL, and trusted origins", () => {
const config = createIdentityConfig(validInput(), { environment: "production" });
expect(config).toEqual({
baseUrl: validBaseUrl,
// Unset: the Dashboard is served from the identity base URL, which is the ordinary
// same-origin production shape (ADR 0038).
dashboardOrigin: validBaseUrl,
publicSignupEnabled: false,
secret: validSecret,
trustedOrigins: validTrustedOrigins,
});
expect(Object.isFrozen(config)).toBe(true);
expect(Object.isFrozen(config.trustedOrigins)).toBe(true);
});
it("rejects a missing secret", () => {
expect(() =>
createIdentityConfig({ ...validInput(), secret: undefined }, { environment: "production" }),
).toThrow(IdentityConfigError);
try {
createIdentityConfig({ ...validInput(), secret: undefined });
} catch (error) {
expect(error).toMatchObject({ code: "SECRET_MISSING" });
}
});
it("rejects a secret shorter than 32 characters", () => {
let captured: unknown;
try {
createIdentityConfig({ ...validInput(), secret: "x".repeat(31) });
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "SECRET_TOO_SHORT" });
});
it("accepts a secret of exactly 32 characters", () => {
const secret = "aB1cD2eF3gH4iJ5kL6mN7oP8qR9sT0uV";
expect(secret).toHaveLength(32);
const config = createIdentityConfig({ ...validInput(), secret });
expect(config.secret).toBe(secret);
});
it("rejects a degenerate single-character-repeated secret", () => {
let captured: unknown;
try {
createIdentityConfig({ ...validInput(), secret: "a".repeat(40) });
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "SECRET_LOW_ENTROPY" });
});
it("rejects an invalid base URL", () => {
for (const baseUrl of [undefined, "", "not-a-url", "ftp://example.test", "example.test"]) {
let captured: unknown;
try {
createIdentityConfig({ ...validInput(), baseUrl });
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(IdentityConfigError);
}
});
it("rejects missing trusted origins", () => {
let captured: unknown;
try {
createIdentityConfig({ ...validInput(), trustedOrigins: undefined });
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "TRUSTED_ORIGINS_MISSING" });
});
it("rejects an empty trusted origins array", () => {
let captured: unknown;
try {
createIdentityConfig({ ...validInput(), trustedOrigins: [] });
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "TRUSTED_ORIGINS_EMPTY" });
});
it("rejects a trusted origin containing a path, query, or fragment", () => {
for (const origin of [
"https://example.test/",
"https://example.test/path",
"https://example.test#fragment",
]) {
let captured: unknown;
try {
createIdentityConfig({ ...validInput(), trustedOrigins: [origin] });
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "TRUSTED_ORIGIN_INVALID" });
}
});
it("rejects wildcard trusted origins", () => {
for (const origin of ["https://*.example.test", "https://app?.example.test"]) {
let captured: unknown;
try {
createIdentityConfig({ ...validInput(), trustedOrigins: [origin] });
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "TRUSTED_ORIGIN_WILDCARD_UNSUPPORTED" });
}
});
it("rejects insecure http origins and loopback hosts in production", () => {
let httpError: unknown;
try {
createIdentityConfig(
{ ...validInput(), trustedOrigins: ["http://identity.example.test"] },
{ environment: "production" },
);
} catch (error) {
httpError = error;
}
expect(httpError).toMatchObject({ code: "TRUSTED_ORIGIN_INSECURE_IN_PRODUCTION" });
let loopbackError: unknown;
try {
createIdentityConfig(
{ ...validInput(), baseUrl: "https://localhost", trustedOrigins: ["https://localhost"] },
{ environment: "production" },
);
} catch (error) {
loopbackError = error;
}
expect(loopbackError).toMatchObject({ code: "BASE_URL_LOOPBACK_IN_PRODUCTION" });
});
it("defaults to production validation when environment is not specified", () => {
let captured: unknown;
try {
createIdentityConfig({ ...validInput(), baseUrl: "http://identity.example.test" });
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "BASE_URL_INSECURE_IN_PRODUCTION" });
});
it("explicitly allows local development origins only when environment is development", () => {
const config = createIdentityConfig(
{
baseUrl: "http://localhost:3000",
secret: validSecret,
trustedOrigins: ["http://localhost:3000"],
},
{ environment: "development" },
);
expect(config.baseUrl).toBe("http://localhost:3000");
});
it("requires the base URL to be exactly one canonical origin, rejecting every non-origin component", () => {
for (const baseUrl of [
"https://id.example.test/", // a bare trailing slash is a path; `URL.origin` is slashless
"https://id.example.test/auth", // Better Auth is never mounted beneath a configurable path
"https://id.example.test?a=1",
"https://id.example.test#fragment",
"https://user:[email protected]",
"https://:[email protected]",
" https://id.example.test ",
"https://ID.EXAMPLE.TEST", // non-canonical hostname case
"https://id.example.test:443", // redundant default port
"http://id.example.test:80",
"ftp://id.example.test",
"id.example.test",
]) {
let captured: unknown;
try {
createIdentityConfig({ ...validInput(), baseUrl }, { environment: "production" });
} catch (error) {
captured = error;
}
expect(captured, `expected ${JSON.stringify(baseUrl)} to be rejected`).toMatchObject({
code: "BASE_URL_INVALID",
});
}
});
it("stores the base URL and trusted origins as one identical canonical origin representation", () => {
const config = createIdentityConfig(
{
...validInput(),
baseUrl: "https://id.example.test",
trustedOrigins: ["https://id.example.test", "https://dashboard.example.test:8443"],
},
{ environment: "production" },
);
expect(config.baseUrl).toBe("https://id.example.test");
expect(config.trustedOrigins).toEqual([
"https://id.example.test",
"https://dashboard.example.test:8443",
]);
});
it("rejects every loopback and unspecified-host spelling in production, for the base URL and trusted origins alike", () => {
// Canonical spellings the WHATWG parser preserves — these must be rejected as production
// policy failures, by the identical classifier on both fields.
const canonicalLoopback = [
"https://127.0.0.1",
"https://127.0.0.2", // the full 127.0.0.0/8 block, not just .1
"https://127.255.255.254",
"https://[::1]", // IPv6 loopback, compressed
"https://[::ffff:7f00:1]", // IPv4-mapped loopback, hexadecimal notation
"https://localhost",
"https://app.localhost", // RFC 6761 reserved namespace
"https://0.0.0.0",
"https://[::]", // IPv6 unspecified
"https://[::ffff:0:0]", // IPv4-mapped unspecified
];
for (const value of canonicalLoopback) {
let baseCaptured: unknown;
try {
createIdentityConfig({ ...validInput(), baseUrl: value }, { environment: "production" });
} catch (error) {
baseCaptured = error;
}
expect(baseCaptured, `expected base URL ${value} to be rejected`).toMatchObject({
code: "BASE_URL_LOOPBACK_IN_PRODUCTION",
});
let originCaptured: unknown;
try {
createIdentityConfig(
{ ...validInput(), trustedOrigins: [value] },
{ environment: "production" },
);
} catch (error) {
originCaptured = error;
}
expect(originCaptured, `expected trusted origin ${value} to be rejected`).toMatchObject({
code: "TRUSTED_ORIGIN_LOOPBACK_IN_PRODUCTION",
});
}
});
it("rejects non-canonical loopback spellings structurally, never as a misleading production-policy failure", () => {
// WHATWG canonicalization folds some of these into `127.0.0.1`; others simply are not the
// canonical origin text. Either way the honest answer is "this is not a canonical origin".
for (const baseUrl of [
"https://127.1",
"https://2130706433",
"https://0x7f000001",
"https://[0:0:0:0:0:0:0:1]", // expanded IPv6 loopback
"https://[::ffff:127.0.0.1]", // mixed IPv4-mapped notation
"https://LOCALHOST",
]) {
let captured: unknown;
try {
createIdentityConfig({ ...validInput(), baseUrl }, { environment: "production" });
} catch (error) {
captured = error;
}
expect(captured, `expected ${baseUrl} to be structurally rejected`).toMatchObject({
code: "BASE_URL_INVALID",
});
}
});
it("still accepts canonical loopback development origins", () => {
const config = createIdentityConfig(
{
...validInput(),
baseUrl: "http://localhost:3000",
trustedOrigins: ["http://localhost:3000", "http://127.0.0.1:5173"],
},
{ environment: "development" },
);
expect(config.baseUrl).toBe("http://localhost:3000");
expect(config.trustedOrigins).toEqual(["http://localhost:3000", "http://127.0.0.1:5173"]);
});
it("defaults publicSignupEnabled to false when entirely omitted", () => {
const config = createIdentityConfig(validInput(), { environment: "production" });
expect(config.publicSignupEnabled).toBe(false);
});
it("accepts an explicit boolean publicSignupEnabled in development", () => {
expect(
createIdentityConfig(
{ ...developmentInput(), publicSignupEnabled: true },
{ environment: "development" },
).publicSignupEnabled,
).toBe(true);
expect(
createIdentityConfig(
{ ...developmentInput(), publicSignupEnabled: false },
{ environment: "development" },
).publicSignupEnabled,
).toBe(false);
});
it('accepts the exact strings "true"/"false" in development (environment variables are always strings)', () => {
expect(
createIdentityConfig(
{ ...developmentInput(), publicSignupEnabled: "true" },
{ environment: "development" },
).publicSignupEnabled,
).toBe(true);
expect(
createIdentityConfig(
{ ...developmentInput(), publicSignupEnabled: "false" },
{ environment: "development" },
).publicSignupEnabled,
).toBe(false);
});
it("accepts an explicitly disabled publicSignupEnabled in production", () => {
for (const publicSignupEnabled of [false, "false"]) {
expect(
createIdentityConfig(
{ ...validInput(), publicSignupEnabled },
{ environment: "production" },
).publicSignupEnabled,
).toBe(false);
}
});
it("refuses to enable public signup in production until durable abuse controls exist", () => {
// Ekklesia has no verified email, invitation requirement, CAPTCHA, or durable shared/edge
// limiter yet, so a production deployment must not be able to open public registration at all
// — not even deliberately. This is a fail-closed configuration error, never a warning.
for (const publicSignupEnabled of [true, "true"]) {
let captured: unknown;
try {
createIdentityConfig(
{ ...validInput(), publicSignupEnabled },
{ environment: "production" },
);
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(IdentityConfigError);
expect(captured).toMatchObject({ code: "PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION" });
}
});
it("defaults to production validation for public signup when environment is not specified", () => {
let captured: unknown;
try {
createIdentityConfig({ ...validInput(), publicSignupEnabled: "true" });
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION" });
});
it("reports a malformed public-signup value as malformed even in production, never as unsupported", () => {
let captured: unknown;
try {
createIdentityConfig(
{ ...validInput(), publicSignupEnabled: "TRUE" },
{ environment: "production" },
);
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "PUBLIC_SIGNUP_ENABLED_INVALID" });
});
it("rejects a misspelled, loosely-truthy, or malformed publicSignupEnabled rather than silently defaulting", () => {
for (const publicSignupEnabled of ["1", "0", "yes", "no", "TRUE", "True", " true", "", 1, 0]) {
let captured: unknown;
try {
createIdentityConfig({ ...validInput(), publicSignupEnabled });
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "PUBLIC_SIGNUP_ENABLED_INVALID" });
}
});
it("never leaks the secret, base URL, or trusted origins through a thrown error", () => {
const hostileSecret = "s3cr3t-do-not-log-0123456789abcdef";
let captured: unknown;
try {
createIdentityConfig({ baseUrl: "not-a-url", secret: hostileSecret, trustedOrigins: [] });
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(IdentityConfigError);
const publicText = `${String(captured)}\n${
captured instanceof Error ? (captured.stack ?? "") : ""
}\n${JSON.stringify(captured)}`;
expect(publicText).not.toContain(hostileSecret);
expect(publicText).not.toContain(validBaseUrl);
for (const origin of validTrustedOrigins) {
expect(publicText).not.toContain(origin);
}
});
});
describe("Core identity Dashboard origin", () => {
/**
* Where an emailed verification or reset link sends a browser (ADR 0038).
*
* The rules exist because a link in an email is the one artifact nobody inspects before clicking:
* it must land on the deployment's own Dashboard, over https in production, at an origin Better
* Auth will actually accept as a redirect target — and a misconfiguration must fail at startup
* rather than produce a link every recipient is refused at.
*/
it("defaults to the identity base URL, the ordinary same-origin production shape", () => {
expect(createIdentityConfig(validInput()).dashboardOrigin).toBe(validBaseUrl);
expect(createIdentityConfig({ ...validInput(), dashboardOrigin: "" }).dashboardOrigin).toBe(
validBaseUrl,
);
});
it("accepts a separate Dashboard origin that is also a trusted origin", () => {
const config = createIdentityConfig({
...validInput(),
dashboardOrigin: "https://dashboard.example.test",
});
expect(config.dashboardOrigin).toBe("https://dashboard.example.test");
// Separate from the API origin, deliberately: the two are different concepts and a deployment
// may split them.
expect(config.dashboardOrigin).not.toBe(config.baseUrl);
});
it("refuses an origin Better Auth would not accept as a redirect target", () => {
// The failure this prevents is silent and total: Better Auth validates the emailed callback
// against its trusted origins, so an untrusted Dashboard origin breaks every link it sends.
try {
createIdentityConfig({ ...validInput(), dashboardOrigin: "https://elsewhere.example.test" });
throw new Error("expected the untrusted Dashboard origin to be refused");
} catch (error) {
expect(error).toMatchObject({ code: "DASHBOARD_ORIGIN_UNTRUSTED" });
}
});
it("refuses anything that is not a canonical origin", () => {
for (const value of [
"dashboard.example.test",
"https://dashboard.example.test/",
"https://dashboard.example.test/dashboard",
"https://user:[email protected]",
"https://dashboard.example.test?a=b",
"javascript:alert(1)",
"//dashboard.example.test",
" https://dashboard.example.test",
42,
]) {
try {
createIdentityConfig({ ...validInput(), dashboardOrigin: value });
throw new Error(`expected ${String(value)} to be refused`);
} catch (error) {
expect(error).toMatchObject({ code: "DASHBOARD_ORIGIN_INVALID" });
}
}
});
it("requires https and a non-loopback host in production, and relaxes both in development", () => {
try {
createIdentityConfig(
// Structural and policy validity are checked before membership, so a plaintext origin is
// reported as plaintext rather than as "untrusted" — which would send an operator looking
// in the wrong place.
{ ...validInput(), dashboardOrigin: "http://dashboard.example.test" },
{ environment: "production" },
);
throw new Error("expected plaintext to be refused in production");
} catch (error) {
expect(error).toMatchObject({ code: "DASHBOARD_ORIGIN_INSECURE_IN_PRODUCTION" });
}
try {
createIdentityConfig(
{ ...validInput(), dashboardOrigin: "https://localhost" },
{ environment: "production" },
);
throw new Error("expected loopback to be refused in production");
} catch (error) {
expect(error).toMatchObject({ code: "DASHBOARD_ORIGIN_LOOPBACK_IN_PRODUCTION" });
}
const development = createIdentityConfig(
{
...developmentInput(),
dashboardOrigin: "http://localhost:5173",
trustedOrigins: ["http://localhost:3000", "http://localhost:5173"],
},
{ environment: "development" },
);
expect(development.dashboardOrigin).toBe("http://localhost:5173");
});
it("never carries the offending origin in the thrown error", () => {
try {
createIdentityConfig({ ...validInput(), dashboardOrigin: "https://evil.example.test" });
throw new Error("expected a refusal");
} catch (error) {
expect(JSON.stringify({ message: (error as Error).message })).not.toContain("evil");
}
});
});