fix: secure authentication request identity
Replace Better Auth's default trust of a caller-controlled X-Forwarded-For with a platform-owned client-address resolution boundary: direct mode (the safe default) trusts only the real socket peer, and an explicit trusted-proxy mode requires a header name, a concrete trusted-proxy list, and only honors a forwarded chain from an actual trusted peer. This closes a rate-limit bypass where rotating a spoofed header let a caller choose a fresh bucket on every request. Public sign-up is also disabled by default and requires an explicit typed opt-in, using Better Auth's own disableSignUp gate so no email-existence leak or second signup path is introduced. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
5e09330001
commit
cafd513019
@@ -96,6 +96,8 @@ Identity
|
||||
○ Identity configuration skipped
|
||||
Core identity schema defines 4 tables (ekklesia_core_account, ekklesia_core_session, ekklesia_core_user, ekklesia_core_verification).
|
||||
Identity runtime configuration (signing key, base URL, trusted origins) is not set.
|
||||
✓ Direct client-address mode
|
||||
Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.
|
||||
|
||||
Object storage
|
||||
○ Check unavailable
|
||||
|
||||
@@ -209,6 +209,7 @@ describe("Ekklesia Doctor diagnostics", () => {
|
||||
"postgresql.connection",
|
||||
"postgresql.plan",
|
||||
"identity.configuration",
|
||||
"identity.client-address",
|
||||
"object-storage.integration",
|
||||
]);
|
||||
expect(check(report, "modules.discovery").details).toEqual(["people 0.1.0", "calendar 0.2.0"]);
|
||||
@@ -613,6 +614,137 @@ describe("Ekklesia Doctor diagnostics", () => {
|
||||
expect(JSON.stringify(report)).not.toContain(hostileSecret);
|
||||
});
|
||||
|
||||
it("passes identity configuration and reports public signup disabled by default", async () => {
|
||||
const report = await diagnoseEkklesia(
|
||||
{
|
||||
...options(),
|
||||
identityConfiguration: {
|
||||
baseUrl: "https://identity.example.test",
|
||||
environment: "production",
|
||||
secret: "correct-horse-battery-staple-0123456789",
|
||||
trustedOrigins: ["https://identity.example.test"],
|
||||
},
|
||||
},
|
||||
dependencies(),
|
||||
);
|
||||
|
||||
const identity = check(report, "identity.configuration");
|
||||
expect(identity.status).toBe("pass");
|
||||
expect(identity.details.some((detail) => detail.includes("disabled (safe default)"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("reports public signup as explicitly enabled when configured", async () => {
|
||||
const report = await diagnoseEkklesia(
|
||||
{
|
||||
...options(),
|
||||
identityConfiguration: {
|
||||
baseUrl: "https://identity.example.test",
|
||||
environment: "production",
|
||||
publicSignupEnabled: "true",
|
||||
secret: "correct-horse-battery-staple-0123456789",
|
||||
trustedOrigins: ["https://identity.example.test"],
|
||||
},
|
||||
},
|
||||
dependencies(),
|
||||
);
|
||||
|
||||
const identity = check(report, "identity.configuration");
|
||||
expect(identity.status).toBe("pass");
|
||||
expect(identity.details.some((detail) => detail.includes("explicitly ENABLED"))).toBe(true);
|
||||
});
|
||||
|
||||
it("fails identity configuration for a misspelled public-signup value, never silently disabling or enabling it", async () => {
|
||||
const report = await diagnoseEkklesia(
|
||||
{
|
||||
...options(),
|
||||
identityConfiguration: {
|
||||
baseUrl: "https://identity.example.test",
|
||||
environment: "production",
|
||||
publicSignupEnabled: "yes",
|
||||
secret: "correct-horse-battery-staple-0123456789",
|
||||
trustedOrigins: ["https://identity.example.test"],
|
||||
},
|
||||
},
|
||||
dependencies(),
|
||||
);
|
||||
|
||||
const identity = check(report, "identity.configuration");
|
||||
expect(identity.status).toBe("failure");
|
||||
expect(
|
||||
identity.details.some((detail) => detail.includes("PUBLIC_SIGNUP_ENABLED_INVALID")),
|
||||
).toBe(true);
|
||||
expect(report.result).toBe("not-ready");
|
||||
});
|
||||
|
||||
it("passes client-address configuration with direct mode when nothing is configured", async () => {
|
||||
const report = await diagnoseEkklesia(options(), dependencies());
|
||||
|
||||
const clientAddress = check(report, "identity.client-address");
|
||||
expect(clientAddress.status).toBe("pass");
|
||||
expect(clientAddress.summary).toContain("Direct");
|
||||
expect(report.result).toBe("ready-with-warnings");
|
||||
});
|
||||
|
||||
it("passes client-address configuration with an explicitly configured trusted-proxy mode", async () => {
|
||||
const report = await diagnoseEkklesia(
|
||||
{
|
||||
...options(),
|
||||
clientAddress: {
|
||||
header: "x-forwarded-for",
|
||||
mode: "trusted-proxy",
|
||||
trustedProxies: ["10.0.0.1", "10.0.0.2"],
|
||||
},
|
||||
},
|
||||
dependencies(),
|
||||
);
|
||||
|
||||
const clientAddress = check(report, "identity.client-address");
|
||||
expect(clientAddress.status).toBe("pass");
|
||||
expect(clientAddress.summary).toContain("Trusted-proxy");
|
||||
expect(clientAddress.details.some((detail) => detail.includes("x-forwarded-for"))).toBe(true);
|
||||
});
|
||||
|
||||
it("fails client-address configuration for an invalid trusted-proxy entry without leaking it", async () => {
|
||||
const hostileEntry = "not-an-ip-secret-value";
|
||||
const report = await diagnoseEkklesia(
|
||||
{
|
||||
...options(),
|
||||
clientAddress: {
|
||||
header: "x-forwarded-for",
|
||||
mode: "trusted-proxy",
|
||||
trustedProxies: [hostileEntry],
|
||||
},
|
||||
},
|
||||
dependencies(),
|
||||
);
|
||||
|
||||
const clientAddress = check(report, "identity.client-address");
|
||||
expect(clientAddress.status).toBe("failure");
|
||||
expect(
|
||||
clientAddress.details.some((detail) =>
|
||||
detail.includes("CLIENT_ADDRESS_TRUSTED_PROXY_INVALID"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(JSON.stringify(report)).not.toContain(hostileEntry);
|
||||
expect(report.result).toBe("not-ready");
|
||||
});
|
||||
|
||||
it("fails client-address configuration for an incomplete trusted-proxy mode (missing header)", async () => {
|
||||
const report = await diagnoseEkklesia(
|
||||
{
|
||||
...options(),
|
||||
clientAddress: { mode: "trusted-proxy", trustedProxies: ["10.0.0.1"] },
|
||||
},
|
||||
dependencies(),
|
||||
);
|
||||
|
||||
const clientAddress = check(report, "identity.client-address");
|
||||
expect(clientAddress.status).toBe("failure");
|
||||
expect(report.result).toBe("not-ready");
|
||||
});
|
||||
|
||||
it("never constructs a migration database or Better Auth merely to check identity configuration", async () => {
|
||||
const services = dependencies();
|
||||
await diagnoseEkklesia(
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ClientAddressConfigError,
|
||||
createClientAddressConfig,
|
||||
createClientAddressResolver,
|
||||
findInvalidTrustedProxies,
|
||||
normalizeClientAddress,
|
||||
} from "../src/identity/client-address.js";
|
||||
|
||||
const INTERNAL_HEADER = "x-ekklesia-client-address";
|
||||
|
||||
describe("createClientAddressConfig", () => {
|
||||
it("defaults to direct mode when mode is entirely omitted", () => {
|
||||
expect(createClientAddressConfig()).toEqual({ mode: "direct" });
|
||||
expect(createClientAddressConfig({})).toEqual({ mode: "direct" });
|
||||
expect(Object.isFrozen(createClientAddressConfig())).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects direct mode combined with a header or trusted proxies", () => {
|
||||
expect(() => createClientAddressConfig({ header: "x-forwarded-for", mode: "direct" })).toThrow(
|
||||
ClientAddressConfigError,
|
||||
);
|
||||
try {
|
||||
createClientAddressConfig({ mode: "direct", trustedProxies: ["10.0.0.1"] });
|
||||
} catch (error) {
|
||||
expect(error).toMatchObject({ code: "CLIENT_ADDRESS_DIRECT_MODE_CONTRADICTION" });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an invalid mode value", () => {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createClientAddressConfig({ mode: "proxy" });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toMatchObject({ code: "CLIENT_ADDRESS_MODE_INVALID" });
|
||||
});
|
||||
|
||||
it("accepts a valid trusted-proxy configuration", () => {
|
||||
const config = createClientAddressConfig({
|
||||
header: "X-Forwarded-For",
|
||||
mode: "trusted-proxy",
|
||||
trustedProxies: ["10.0.0.5", "192.168.1.0/24"],
|
||||
});
|
||||
expect(config).toEqual({
|
||||
header: "x-forwarded-for",
|
||||
mode: "trusted-proxy",
|
||||
trustedProxies: ["10.0.0.5", "192.168.1.0/24"],
|
||||
});
|
||||
expect(Object.isFrozen(config)).toBe(true);
|
||||
if (config.mode === "trusted-proxy") {
|
||||
expect(Object.isFrozen(config.trustedProxies)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects trusted-proxy mode missing a header", () => {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createClientAddressConfig({ mode: "trusted-proxy", trustedProxies: ["10.0.0.1"] });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toMatchObject({ code: "CLIENT_ADDRESS_HEADER_MISSING" });
|
||||
});
|
||||
|
||||
it("rejects an invalid header name", () => {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createClientAddressConfig({
|
||||
header: "not a header!",
|
||||
mode: "trusted-proxy",
|
||||
trustedProxies: ["10.0.0.1"],
|
||||
});
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toMatchObject({ code: "CLIENT_ADDRESS_HEADER_INVALID" });
|
||||
});
|
||||
|
||||
it("rejects the internal platform header as a configured source header", () => {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createClientAddressConfig(
|
||||
{
|
||||
header: INTERNAL_HEADER,
|
||||
mode: "trusted-proxy",
|
||||
trustedProxies: ["10.0.0.1"],
|
||||
},
|
||||
INTERNAL_HEADER,
|
||||
);
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toMatchObject({ code: "CLIENT_ADDRESS_HEADER_INVALID" });
|
||||
});
|
||||
|
||||
it("rejects trusted-proxy mode with an empty trusted-proxy list", () => {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createClientAddressConfig({
|
||||
header: "x-forwarded-for",
|
||||
mode: "trusted-proxy",
|
||||
trustedProxies: [],
|
||||
});
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toMatchObject({ code: "CLIENT_ADDRESS_TRUSTED_PROXIES_EMPTY" });
|
||||
});
|
||||
|
||||
it("rejects a malformed trusted-proxy entry", () => {
|
||||
for (const trustedProxies of [
|
||||
["not-an-ip"],
|
||||
["10.0.0.1/33"],
|
||||
["10.0.0.1/-1"],
|
||||
["10.0.0.1/1e3"],
|
||||
[""],
|
||||
]) {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createClientAddressConfig({
|
||||
header: "x-forwarded-for",
|
||||
mode: "trusted-proxy",
|
||||
trustedProxies,
|
||||
});
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toMatchObject({ code: "CLIENT_ADDRESS_TRUSTED_PROXY_INVALID" });
|
||||
}
|
||||
});
|
||||
|
||||
it("never leaks the offending mode, header, or trusted-proxy value in the thrown error", () => {
|
||||
const hostileHeader = "x-hostile-secret-header";
|
||||
let captured: unknown;
|
||||
try {
|
||||
createClientAddressConfig({
|
||||
header: hostileHeader,
|
||||
mode: "trusted-proxy",
|
||||
trustedProxies: ["definitely-not-an-ip"],
|
||||
});
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
const publicText = `${String(captured)}\n${
|
||||
captured instanceof Error ? (captured.stack ?? "") : ""
|
||||
}`;
|
||||
expect(publicText).not.toContain(hostileHeader);
|
||||
expect(publicText).not.toContain("definitely-not-an-ip");
|
||||
});
|
||||
});
|
||||
|
||||
describe("findInvalidTrustedProxies", () => {
|
||||
it("identifies exactly the malformed entries", () => {
|
||||
expect(
|
||||
findInvalidTrustedProxies(["10.0.0.1", "not-an-ip", "192.168.0.0/24", "10.0.0.1/99"]),
|
||||
).toEqual(["not-an-ip", "10.0.0.1/99"]);
|
||||
});
|
||||
|
||||
it("accepts IPv6 addresses and ranges", () => {
|
||||
expect(findInvalidTrustedProxies(["::1", "fd00::/8", "2001:db8::1"])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeClientAddress", () => {
|
||||
it("passes a plain IPv4 address through unchanged", () => {
|
||||
expect(normalizeClientAddress("192.0.2.10")).toBe("192.0.2.10");
|
||||
});
|
||||
|
||||
it("lowercases a plain IPv6 address", () => {
|
||||
expect(normalizeClientAddress("2001:DB8::1")).toBe("2001:db8::1");
|
||||
});
|
||||
|
||||
it("collapses an IPv4-mapped IPv6 address to its embedded IPv4 form", () => {
|
||||
expect(normalizeClientAddress("::ffff:127.0.0.1")).toBe("127.0.0.1");
|
||||
expect(normalizeClientAddress("::FFFF:203.0.113.5")).toBe("203.0.113.5");
|
||||
});
|
||||
|
||||
it("rejects a zone id as ambiguous", () => {
|
||||
expect(normalizeClientAddress("fe80::1%eth0")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects malformed, empty, or non-IP input", () => {
|
||||
for (const value of ["", " ", "not-an-ip", "999.999.999.999", "1.2.3"]) {
|
||||
expect(normalizeClientAddress(value)).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("trims surrounding whitespace", () => {
|
||||
expect(normalizeClientAddress(" 192.0.2.10 ")).toBe("192.0.2.10");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createClientAddressResolver — direct mode", () => {
|
||||
it("resolves the actual immediate socket peer, ignoring any forwarded header", () => {
|
||||
const resolver = createClientAddressResolver({ mode: "direct" });
|
||||
const result = resolver.resolve({
|
||||
headers: new Headers({ "x-forwarded-for": "203.0.113.99" }),
|
||||
peerAddress: "192.0.2.1",
|
||||
});
|
||||
expect(result).toEqual({ address: "192.0.2.1", ok: true });
|
||||
});
|
||||
|
||||
it("fails closed when no peer address is available", () => {
|
||||
const resolver = createClientAddressResolver({ mode: "direct" });
|
||||
const result = resolver.resolve({ headers: new Headers(), peerAddress: undefined });
|
||||
expect(result).toEqual({ ok: false, reason: "PEER_ADDRESS_UNAVAILABLE" });
|
||||
});
|
||||
|
||||
it("fails closed when the peer address is not a valid IP", () => {
|
||||
const resolver = createClientAddressResolver({ mode: "direct" });
|
||||
const result = resolver.resolve({ headers: new Headers(), peerAddress: "not-an-ip" });
|
||||
expect(result).toEqual({ ok: false, reason: "PEER_ADDRESS_UNAVAILABLE" });
|
||||
});
|
||||
|
||||
it("normalizes an IPv4-mapped IPv6 peer to its IPv4 form", () => {
|
||||
const resolver = createClientAddressResolver({ mode: "direct" });
|
||||
const result = resolver.resolve({ headers: new Headers(), peerAddress: "::ffff:198.51.100.7" });
|
||||
expect(result).toEqual({ address: "198.51.100.7", ok: true });
|
||||
});
|
||||
|
||||
it("gives distinct real peer addresses distinct resolved identities", () => {
|
||||
const resolver = createClientAddressResolver({ mode: "direct" });
|
||||
const first = resolver.resolve({ headers: new Headers(), peerAddress: "127.0.0.2" });
|
||||
const second = resolver.resolve({ headers: new Headers(), peerAddress: "127.0.0.3" });
|
||||
expect(first).toEqual({ address: "127.0.0.2", ok: true });
|
||||
expect(second).toEqual({ address: "127.0.0.3", ok: true });
|
||||
expect(first).not.toEqual(second);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createClientAddressResolver — trusted-proxy mode", () => {
|
||||
function proxyResolver(trustedProxies: readonly string[] = ["10.0.0.1", "10.0.0.2"]) {
|
||||
return createClientAddressResolver({
|
||||
header: "x-forwarded-for",
|
||||
mode: "trusted-proxy",
|
||||
trustedProxies,
|
||||
});
|
||||
}
|
||||
|
||||
it("ignores the forwarded header entirely when the immediate peer is not a trusted proxy", () => {
|
||||
const resolver = proxyResolver();
|
||||
const result = resolver.resolve({
|
||||
headers: new Headers({ "x-forwarded-for": "203.0.113.99" }),
|
||||
peerAddress: "198.51.100.1",
|
||||
});
|
||||
// Falls back to direct-mode behavior: the untrusted peer itself, never the header content.
|
||||
expect(result).toEqual({ address: "198.51.100.1", ok: true });
|
||||
});
|
||||
|
||||
it("honors a single-value forwarded header from a trusted proxy", () => {
|
||||
const resolver = proxyResolver();
|
||||
const result = resolver.resolve({
|
||||
headers: new Headers({ "x-forwarded-for": "203.0.113.5" }),
|
||||
peerAddress: "10.0.0.1",
|
||||
});
|
||||
expect(result).toEqual({ address: "203.0.113.5", ok: true });
|
||||
});
|
||||
|
||||
it("walks a chained forwarded header right-to-left, skipping trusted-proxy hops", () => {
|
||||
const resolver = proxyResolver();
|
||||
const result = resolver.resolve({
|
||||
headers: new Headers({ "x-forwarded-for": "203.0.113.5, 10.0.0.2" }),
|
||||
peerAddress: "10.0.0.1",
|
||||
});
|
||||
expect(result).toEqual({ address: "203.0.113.5", ok: true });
|
||||
});
|
||||
|
||||
it("walks past multiple trusted-proxy hops to find the genuine client", () => {
|
||||
const resolver = proxyResolver(["10.0.0.1", "10.0.0.2", "10.0.0.3"]);
|
||||
const result = resolver.resolve({
|
||||
headers: new Headers({ "x-forwarded-for": "203.0.113.5, 10.0.0.3, 10.0.0.2" }),
|
||||
peerAddress: "10.0.0.1",
|
||||
});
|
||||
expect(result).toEqual({ address: "203.0.113.5", ok: true });
|
||||
});
|
||||
|
||||
it("fails closed when the trusted proxy sends no forwarded header at all", () => {
|
||||
const resolver = proxyResolver();
|
||||
const result = resolver.resolve({ headers: new Headers(), peerAddress: "10.0.0.1" });
|
||||
expect(result).toEqual({ ok: false, reason: "FORWARDED_HEADER_MISSING" });
|
||||
});
|
||||
|
||||
it("fails closed on an empty (but present) forwarded header value", () => {
|
||||
const resolver = proxyResolver();
|
||||
const result = resolver.resolve({
|
||||
headers: new Headers({ "x-forwarded-for": "" }),
|
||||
peerAddress: "10.0.0.1",
|
||||
});
|
||||
expect(result).toEqual({ ok: false, reason: "FORWARDED_HEADER_MALFORMED" });
|
||||
});
|
||||
|
||||
it("fails closed on a malformed token anywhere in the chain", () => {
|
||||
const resolver = proxyResolver();
|
||||
const result = resolver.resolve({
|
||||
headers: new Headers({ "x-forwarded-for": "203.0.113.5, not-an-ip" }),
|
||||
peerAddress: "10.0.0.1",
|
||||
});
|
||||
expect(result).toEqual({ ok: false, reason: "FORWARDED_HEADER_MALFORMED" });
|
||||
});
|
||||
|
||||
it("fails closed on an empty comma-separated segment (ambiguous multi-valued form)", () => {
|
||||
const resolver = proxyResolver();
|
||||
const result = resolver.resolve({
|
||||
headers: new Headers({ "x-forwarded-for": "203.0.113.5,, 10.0.0.1" }),
|
||||
peerAddress: "10.0.0.1",
|
||||
});
|
||||
expect(result).toEqual({ ok: false, reason: "FORWARDED_HEADER_MALFORMED" });
|
||||
});
|
||||
|
||||
it("fails closed when the entire chain resolves to trusted-proxy hops with no genuine client left", () => {
|
||||
const resolver = proxyResolver();
|
||||
const result = resolver.resolve({
|
||||
headers: new Headers({ "x-forwarded-for": "10.0.0.2, 10.0.0.1" }),
|
||||
peerAddress: "10.0.0.1",
|
||||
});
|
||||
expect(result).toEqual({ ok: false, reason: "FORWARDED_CHAIN_EXHAUSTED" });
|
||||
});
|
||||
|
||||
it("normalizes an IPv4-mapped IPv6 hop before matching it against a trusted IPv4 proxy", () => {
|
||||
const resolver = proxyResolver(["10.0.0.1"]);
|
||||
const result = resolver.resolve({
|
||||
headers: new Headers({ "x-forwarded-for": "203.0.113.5, ::ffff:10.0.0.1" }),
|
||||
peerAddress: "10.0.0.1",
|
||||
});
|
||||
expect(result).toEqual({ address: "203.0.113.5", ok: true });
|
||||
});
|
||||
|
||||
it("matches a trusted-proxy CIDR range, not only an exact address", () => {
|
||||
const resolver = proxyResolver(["192.168.1.0/24"]);
|
||||
const result = resolver.resolve({
|
||||
headers: new Headers({ "x-forwarded-for": "203.0.113.5" }),
|
||||
peerAddress: "192.168.1.42",
|
||||
});
|
||||
expect(result).toEqual({ address: "203.0.113.5", ok: true });
|
||||
});
|
||||
|
||||
it("never lets a spoofed header from an untrusted peer choose a client identity that collides with a trusted flow", () => {
|
||||
const resolver = proxyResolver();
|
||||
// An attacker connecting directly (not through the real proxy) claims to forward from a
|
||||
// trusted-proxy address itself — since the *immediate peer* is what gates trust, not header
|
||||
// content, this must still resolve to the attacker's own real peer address.
|
||||
const result = resolver.resolve({
|
||||
headers: new Headers({ "x-forwarded-for": "203.0.113.5, 10.0.0.1" }),
|
||||
peerAddress: "203.0.113.200",
|
||||
});
|
||||
expect(result).toEqual({ address: "203.0.113.200", ok: true });
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ describe("Core identity configuration", () => {
|
||||
|
||||
expect(config).toEqual({
|
||||
baseUrl: validBaseUrl,
|
||||
publicSignupEnabled: false,
|
||||
secret: validSecret,
|
||||
trustedOrigins: validTrustedOrigins,
|
||||
});
|
||||
@@ -167,6 +168,41 @@ describe("Core identity configuration", () => {
|
||||
expect(config.baseUrl).toBe("http://localhost:3000");
|
||||
});
|
||||
|
||||
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", () => {
|
||||
expect(
|
||||
createIdentityConfig({ ...validInput(), publicSignupEnabled: true }).publicSignupEnabled,
|
||||
).toBe(true);
|
||||
expect(
|
||||
createIdentityConfig({ ...validInput(), publicSignupEnabled: false }).publicSignupEnabled,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts the exact strings "true"/"false" (environment variables are always strings)', () => {
|
||||
expect(
|
||||
createIdentityConfig({ ...validInput(), publicSignupEnabled: "true" }).publicSignupEnabled,
|
||||
).toBe(true);
|
||||
expect(
|
||||
createIdentityConfig({ ...validInput(), publicSignupEnabled: "false" }).publicSignupEnabled,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
@@ -9,14 +9,20 @@ vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
|
||||
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
|
||||
|
||||
import type { IdentityConfig } from "../src/identity/config.js";
|
||||
import { createIdentityRuntime, IdentityRuntimeError } from "../src/identity/runtime.js";
|
||||
import {
|
||||
createIdentityRuntime,
|
||||
IdentityRuntimeError,
|
||||
TRUSTED_CLIENT_ADDRESS_HEADER,
|
||||
} from "../src/identity/runtime.js";
|
||||
import { identityAdapterSchema } from "../src/identity/schema.js";
|
||||
|
||||
function validConfig(): IdentityConfig {
|
||||
function validConfig(overrides: Partial<IdentityConfig> = {}): IdentityConfig {
|
||||
return Object.freeze({
|
||||
baseUrl: "https://identity.example.test",
|
||||
publicSignupEnabled: false,
|
||||
secret: "correct-horse-battery-staple-0123456789",
|
||||
trustedOrigins: Object.freeze(["https://identity.example.test"]),
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -87,7 +93,7 @@ describe("Core identity runtime construction boundary", () => {
|
||||
]);
|
||||
expect(options.emailAndPassword).toEqual({
|
||||
autoSignIn: false,
|
||||
disableSignUp: false,
|
||||
disableSignUp: true,
|
||||
enabled: true,
|
||||
maxPasswordLength: 128,
|
||||
minPasswordLength: 8,
|
||||
@@ -98,13 +104,37 @@ describe("Core identity runtime construction boundary", () => {
|
||||
expect(options.trustedOrigins).toEqual([...validConfig().trustedOrigins]);
|
||||
});
|
||||
|
||||
it("enables sign-up (disableSignUp: false) only when publicSignupEnabled is explicitly true", () => {
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
|
||||
createIdentityRuntime({ config: validConfig({ publicSignupEnabled: true }), db: fakeDb() });
|
||||
|
||||
const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>];
|
||||
expect(options.emailAndPassword).toMatchObject({ disableSignUp: false });
|
||||
});
|
||||
|
||||
it("explicitly disables Better Auth's default test-environment origin/CSRF skip", () => {
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
|
||||
createIdentityRuntime({ config: validConfig(), db: fakeDb() });
|
||||
|
||||
const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>];
|
||||
expect(options.advanced).toEqual({ disableCSRFCheck: false, disableOriginCheck: false });
|
||||
expect(options.advanced).toEqual({
|
||||
disableCSRFCheck: false,
|
||||
disableOriginCheck: false,
|
||||
ipAddress: { ipAddressHeaders: [TRUSTED_CLIENT_ADDRESS_HEADER] },
|
||||
});
|
||||
});
|
||||
|
||||
it("trusts exactly the one platform-owned client-address header, never Better Auth's own default", () => {
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
|
||||
createIdentityRuntime({ config: validConfig(), db: fakeDb() });
|
||||
|
||||
const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>];
|
||||
const advanced = options.advanced as { ipAddress?: { ipAddressHeaders?: string[] } };
|
||||
expect(advanced.ipAddress?.ipAddressHeaders).toEqual([TRUSTED_CLIENT_ADDRESS_HEADER]);
|
||||
expect(advanced.ipAddress?.ipAddressHeaders).not.toContain("x-forwarded-for");
|
||||
});
|
||||
|
||||
it("configures onAPIError.throw so an unexpected failure escapes Better Auth's own unsanitized fallback logger instead of being swallowed internally", () => {
|
||||
|
||||
Reference in New Issue
Block a user