phase-5/security-hardening #14

Merged
Zayin_Admin merged 21 commits from phase-5/security-hardening into main 2026-07-30 03:04:37 +00:00
6 changed files with 654 additions and 132 deletions
Showing only changes of commit 7a0f624fc3 - Show all commits
+271
View File
@@ -979,6 +979,277 @@ describe.sequential("live API — principal infrastructure failure is never down
}); });
}); });
describe.sequential("live API — session-store readiness against real PostgreSQL", () => {
/**
* Every scenario below drives a real, migrated PostgreSQL database, breaks exactly one property
* of the *session store specifically*, and asserts the public contract. The administrative
* connection each helper opens (`openAdministrativeClient`) is **test-only provisioning
* infrastructure** — it renames a relation, drops a column, or revokes a privilege in a
* disposable database, and is closed in `finally`. It is never part of the API runtime, which
* continues to own exactly one pool through `startApi`.
*/
async function withBrokenSessionStore(
label: string,
breakStore: (admin: Client) => Promise<void>,
assertions: (context: {
readonly cookie: string;
readonly origin: string;
readonly logs: string[];
}) => Promise<void>,
): Promise<void> {
const database = await createDisposableDatabase(label);
await applyCoreMigrations(database.url);
const running = await startConfiguredApi(database.url);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
try {
const origin = apiOrigin(running);
const email = `${label}@example.test`;
const password = "correct horse battery staple";
await signUp(origin, email, password);
const { cookiePair } = await signIn(origin, email, password);
expect(cookiePair).toBeDefined();
// The session resolves normally before anything is broken.
const before = await fetchPrincipal(origin, { cookie: cookiePair as string });
expect(before.response.status).toBe(200);
expect(before.body).toMatchObject({ kind: "authenticated" });
const admin = await openAdministrativeClient(database.url);
try {
await breakStore(admin);
} finally {
await safeEnd(admin);
}
await assertions({
cookie: cookiePair as string,
logs: errorSpy.mock.calls.flat().map((value) => String(value)),
origin,
});
} finally {
errorSpy.mockRestore();
await running.shutdown().catch(() => undefined);
await dropDatabase(database.name);
}
}
/** The exact public contract a broken session store must produce — never anonymous, never 403,
* and never carrying a relation name, column name, privilege detail, SQL, host, or stack. */
async function expectSanitizedUnavailable(origin: string, cookie: string): Promise<Response> {
const response = await fetch(`${origin}/api/v1/principal`, { headers: { cookie } });
expect(response.status).toBe(503);
expect(response.headers.get("cache-control")).toBe("no-store");
const text = await response.clone().text();
expect(JSON.parse(text)).toEqual({ code: "PRINCIPAL_RESOLUTION_FAILED" });
for (const forbidden of [
"ekklesia_core_session",
"permission denied",
"does not exist",
"relation",
"column",
"select",
"postgres",
"5432",
]) {
expect(text.toLowerCase()).not.toContain(forbidden.toLowerCase());
}
return response;
}
it("returns a sanitized 503 — not anonymous — when the session table is missing, even though a generic SELECT 1 still succeeds", async () => {
await withBrokenSessionStore(
"session_missing_table",
async (admin) => {
// Prove the distinction this pass exists for: the connection is perfectly healthy for a
// trivial statement, so the previous `select 1` probe would have reported "reachable" and
// the caller would have been reported as genuinely anonymous.
const trivial = await safeQuery<{ one: number }>(admin, "SELECT 1 AS one");
expect(trivial[0]?.one).toBe(1);
await safeQuery(
admin,
'ALTER TABLE "ekklesia_core_session" RENAME TO "ekklesia_core_session_moved"',
);
},
async ({ cookie, origin }) => {
await expectSanitizedUnavailable(origin, cookie);
},
);
});
it("returns a sanitized 503 when an expected session column is missing", async () => {
await withBrokenSessionStore(
"session_missing_column",
async (admin) => {
await safeQuery(admin, 'ALTER TABLE "ekklesia_core_session" DROP COLUMN "token" CASCADE');
},
async ({ cookie, origin }) => {
await expectSanitizedUnavailable(origin, cookie);
},
);
});
it("returns a sanitized 503 when SELECT privilege on the session table is genuinely revoked", async () => {
// A real privilege denial needs the API to connect as a role that is *not* the table owner —
// an owner's implicit rights cannot be revoked. This provisions a dedicated restricted role
// (test-only infrastructure), grants it everything except SELECT on the session table, and
// runs the API entirely as that role.
const database = await createDisposableDatabase("session_denied");
await applyCoreMigrations(database.url);
const restrictedRole = `ekk_restricted_${randomBytes(4).toString("hex")}`;
const restrictedPassword = randomBytes(12).toString("hex");
const admin = await openAdministrativeClient(database.url);
try {
await safeQuery(
admin,
`CREATE ROLE "${restrictedRole}" LOGIN PASSWORD '${restrictedPassword}'`,
);
await safeQuery(admin, `GRANT USAGE ON SCHEMA public TO "${restrictedRole}"`);
await safeQuery(
admin,
`GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO "${restrictedRole}"`,
);
// The one thing this role may not do: read the session relation.
await safeQuery(admin, `REVOKE ALL ON "ekklesia_core_session" FROM "${restrictedRole}"`);
} finally {
await safeEnd(admin);
}
const restrictedUrl = (() => {
const parsed = new URL(database.url);
parsed.username = restrictedRole;
parsed.password = restrictedPassword;
return parsed.toString();
})();
const running = await startConfiguredApi(restrictedUrl);
try {
const origin = apiOrigin(running);
// The restricted role can still run a trivial statement and can still read non-session
// tables, so only the session store specifically is broken.
const response = await fetch(`${origin}/api/v1/principal`, {
headers: { cookie: "__Secure-better-auth.session_token=any-token-shape" },
});
const text = await response.clone().text();
expect(response.status).toBe(503);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(JSON.parse(text)).toEqual({ code: "PRINCIPAL_RESOLUTION_FAILED" });
expect(text).not.toContain("ekklesia_core_session");
expect(text.toLowerCase()).not.toContain("permission denied");
expect(text).not.toContain(restrictedRole);
} finally {
await running.shutdown().catch(() => undefined);
// Drop the disposable database first (it holds the role's dependencies), then the role.
await dropDatabase(database.name).catch(() => undefined);
const cleanup = await openAdministrativeClient(baseDatabaseUrl());
try {
await safeQuery(cleanup, `DROP ROLE IF EXISTS "${restrictedRole}"`);
} finally {
await safeEnd(cleanup);
}
}
});
it("keeps a genuinely unknown, expired, or revoked session anonymous while the store is healthy", async () => {
const database = await createDisposableDatabase("session_healthy");
await applyCoreMigrations(database.url);
const running = await startConfiguredApi(database.url);
try {
const origin = apiOrigin(running);
const email = "[email protected]";
const password = "correct horse battery staple";
await signUp(origin, email, password);
const { cookiePair } = await signIn(origin, email, password);
expect(cookiePair).toBeDefined();
const cookieName = (cookiePair as string).split("=")[0] as string;
// A completely unknown token against a healthy store is genuinely anonymous.
const unknown = await fetchPrincipal(origin, { cookie: `${cookieName}=not-a-real-token` });
expect(unknown.response.status).toBe(200);
expect(unknown.body).toEqual({ kind: "anonymous" });
// A revoked (signed-out) session is likewise genuinely anonymous, not a 503.
const signOut = await fetch(`${origin}/api/auth/sign-out`, {
body: "{}",
headers: {
"content-type": "application/json",
cookie: cookiePair as string,
origin: dashboardOrigin,
},
method: "POST",
});
expect(signOut.status).toBe(200);
const revoked = await fetchPrincipal(origin, { cookie: cookiePair as string });
expect(revoked.response.status).toBe(200);
expect(revoked.body).toEqual({ kind: "anonymous" });
// An expired session (expiry forced into the past) is anonymous, not a 503.
const admin = await openAdministrativeClient(database.url);
try {
await safeQuery(
admin,
`UPDATE "ekklesia_core_session" SET expires_at = now() - interval '1 day'`,
);
} finally {
await safeEnd(admin);
}
const secondSignIn = await signIn(origin, email, password);
const expiredCookie = secondSignIn.cookiePair as string;
const adminAgain = await openAdministrativeClient(database.url);
try {
await safeQuery(
adminAgain,
`UPDATE "ekklesia_core_session" SET expires_at = now() - interval '1 day'`,
);
} finally {
await safeEnd(adminAgain);
}
const expired = await fetchPrincipal(origin, { cookie: expiredCookie });
expect(expired.response.status).toBe(200);
expect(expired.body).toEqual({ kind: "anonymous" });
} finally {
await running.shutdown().catch(() => undefined);
await dropDatabase(database.name);
}
});
it("performs no identity database work for an unrelated cookie, proven against a database whose session table is missing", async () => {
const database = await createDisposableDatabase("session_unrelated_cookie");
await applyCoreMigrations(database.url);
const running = await startConfiguredApi(database.url);
try {
const origin = apiOrigin(running);
// Break the session store outright. Any request that actually touched it would now 503.
const admin = await openAdministrativeClient(database.url);
try {
await safeQuery(
admin,
'ALTER TABLE "ekklesia_core_session" RENAME TO "ekklesia_core_session_moved"',
);
} finally {
await safeEnd(admin);
}
// Unrelated cookies must still resolve as anonymous with a 200: they never triggered
// session-store work in the first place. This is the original reproduction, inverted.
for (const cookie of ["x=y", "a=1; b=2; c=3", "better_auth_session_token=v"]) {
const { body, response } = await fetchPrincipal(origin, { cookie });
expect(response.status).toBe(200);
expect(body).toEqual({ kind: "anonymous" });
}
// No cookie at all behaves identically.
const none = await fetchPrincipal(origin);
expect(none.response.status).toBe(200);
expect(none.body).toEqual({ kind: "anonymous" });
} finally {
await running.shutdown().catch(() => undefined);
await dropDatabase(database.name);
}
});
});
describe.sequential("live API — Better Auth logging never leaks operational detail", () => { describe.sequential("live API — Better Auth logging never leaks operational detail", () => {
it("emits no raw exception, SQL, connection detail, origin header, or stack trace when a real identity database failure occurs", async () => { it("emits no raw exception, SQL, connection detail, origin header, or stack trace when a real identity database failure occurs", async () => {
const database = await createDisposableDatabase("logging_leak"); const database = await createDisposableDatabase("logging_leak");
+181 -101
View File
@@ -3,12 +3,13 @@ import {
createAuthenticatedPrincipal, createAuthenticatedPrincipal,
type EkklesiaPrincipal, type EkklesiaPrincipal,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
import { betterAuth } from "better-auth"; import { betterAuth, type BetterAuthOptions } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { getCookies } from "better-auth/cookies";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import type { IdentityConfig } from "./config.js"; import type { IdentityConfig } from "./config.js";
import type { IdentityDrizzleDatabase } from "./database.js"; import type { IdentityDrizzleDatabase } from "./database.js";
import { identityAdapterSchema } from "./schema.js"; import { ekklesiaCoreSession, identityAdapterSchema } from "./schema.js";
export type IdentityRuntimeErrorCode = export type IdentityRuntimeErrorCode =
| "AUTH_REQUEST_FAILED" | "AUTH_REQUEST_FAILED"
@@ -119,11 +120,76 @@ async function withTimeout<T>(operation: Promise<T>, timeoutMs: number): Promise
} }
} }
async function probeIdentityDatabase(db: IdentityDrizzleDatabase): Promise<boolean> { /**
* Whether the caller actually presented *the* configured Better Auth session cookie — not merely
* some cookie. An arbitrary anonymous cookie (`Cookie: x=y`) is not evidence that Better Auth ever
* attempted to resolve an Ekklesia session, so it must never trigger session-store work.
*
* Parsing deliberately mirrors RFC 6265's `cookie-string` grammar the same way Better Auth's own
* parser does: split on `;`, take each pair's name as the text before its first `=`, and trim
* surrounding whitespace. Matching is an exact, whole-name string comparison — never `includes`, a
* substring test, or an unanchored regular expression — so `better-auth.session_token_x`,
* `x_better-auth.session_token`, a differently-cased spelling, and a percent-encoded lookalike are
* all correctly *not* the session cookie (cookie names are case-sensitive per RFC 6265 §4.1.1, and
* cookie names are never percent-decoded).
*
* Ambiguity fails safe *towards* checking rather than away from it: a duplicated session-cookie
* name still reports `true`, because reporting `false` is what would let a genuinely broken
* session store be reported as an anonymous request. Returning `true` never grants access on its
* own — it only means the readiness check runs and Better Auth's own result still governs.
*/
export function hasConfiguredSessionCookie(headers: Headers, cookieName: string): boolean {
const cookieHeader = headers.get("cookie");
if (cookieHeader === null) {
return false;
}
for (const pair of cookieHeader.split(";")) {
const separatorIndex = pair.indexOf("=");
const name = (separatorIndex === -1 ? pair : pair.slice(0, separatorIndex)).trim();
if (name === cookieName) {
return true;
}
}
return false;
}
/**
* Whether the actual Better Auth session store is usable — not merely whether PostgreSQL can
* execute a trivial statement.
*
* The previous `select 1` probe proved only that *a* connection could run *a* query, so a missing
* session relation, a drifted/absent column, or a revoked `SELECT` privilege all still looked
* "reachable", and Better Auth's ambiguous `null` was then reported as a genuinely anonymous
* request. This instead plans a real read of the exact session relation and the exact columns the
* adapter depends on, under a `where false` predicate: PostgreSQL still resolves the relation,
* every named column, and the caller's `SELECT` privilege at plan time, while guaranteeing zero
* rows are matched, read, or returned — so no session row, token, or user id can ever be
* enumerated, logged, or exposed by the check itself.
*
* Uses the injected Identity Drizzle view (the API's one shared pool); it never constructs another
* pool, client, connection string, or Drizzle instance. The bounded `withTimeout` guarantees the
* public response is never blocked by a hung query, and `clearTimeout` in its `finally` leaves no
* timer behind.
*/
async function probeSessionStore(db: IdentityDrizzleDatabase): Promise<boolean> {
try { try {
await withTimeout(db.execute(sql`select 1`), identityDatabaseProbeTimeoutMs); await withTimeout(
db
.select({
expiresAt: ekklesiaCoreSession.expiresAt,
id: ekklesiaCoreSession.id,
token: ekklesiaCoreSession.token,
userId: ekklesiaCoreSession.userId,
})
.from(ekklesiaCoreSession)
.where(sql`false`),
identityDatabaseProbeTimeoutMs,
);
return true; return true;
} catch { } catch {
// Deliberately swallowed: the caught value may carry raw SQL, a relation/column name, a
// driver detail, a host/port, or a stack. The caller converts `false` into the fixed,
// sanitized `PRINCIPAL_RESOLUTION_FAILED` contract instead.
return false; return false;
} }
} }
@@ -147,97 +213,109 @@ export function createIdentityRuntime(
): EkklesiaIdentityRuntime { ): EkklesiaIdentityRuntime {
const rateLimitOverride = options.rateLimitOverride; const rateLimitOverride = options.rateLimitOverride;
const auth = constructBetterAuth(() => // One options object feeds both `betterAuth()` and `getCookies()` below, so the session-cookie
betterAuth({ // name this runtime detects is derived from the exact same configuration Better Auth itself
advanced: { // uses. That makes drift structurally impossible rather than merely tested: there is no second
// Better Auth's own default is environment-sensitive: `skipOriginCheck` falls back to // hard-coded copy of the cookie name (or of the `__Secure-` prefix rule the configured HTTPS
// `true` whenever `isTest()` is true (NODE_ENV=test, which Vitest sets, or TEST=1) unless // `baseURL` implies) anywhere in this repository.
// `disableOriginCheck` is explicitly set — and origin skip in turn also silently skips const betterAuthOptions = {
// CSRF validation as long as `disableCSRFCheck` is left unset (Better Auth's documented advanced: {
// backward-compatibility coupling). Left implicit, this means origin/CSRF protection is // Better Auth's own default is environment-sensitive: `skipOriginCheck` falls back to
// silently *off* under the test runner and *on* in production: the exact same code path // `true` whenever `isTest()` is true (NODE_ENV=test, which Vitest sets, or TEST=1) unless
// behaves differently in the two places it matters most to verify. Setting both flags to // `disableOriginCheck` is explicitly set — and origin skip in turn also silently skips
// `false` explicitly removes the `isTest()` branch entirely, so the identical protection // CSRF validation as long as `disableCSRFCheck` is left unset (Better Auth's documented
// is enforced in every environment, including under Vitest. // backward-compatibility coupling). Left implicit, this means origin/CSRF protection is
disableCSRFCheck: false, // silently *off* under the test runner and *on* in production: the exact same code path
disableOriginCheck: false, // behaves differently in the two places it matters most to verify. Setting both flags to
// The pinned release's own client-address resolution (`getIp`, `@better-auth/core/utils/ // `false` explicitly removes the `isTest()` branch entirely, so the identical protection
// ip`) only ever reads request headers — it has no way to see the actual TCP socket peer. // is enforced in every environment, including under Vitest.
// Restricting `ipAddressHeaders` to exactly one platform-owned header name, rather than disableCSRFCheck: false,
// leaving it on Better Auth's own default (`x-forwarded-for`, trusted directly with no disableOriginCheck: false,
// proxy policy), is what stops a caller from choosing its own rate-limit bucket: the named // The pinned release's own client-address resolution (`getIp`, `@better-auth/core/utils/
// header is populated only by `apps/api`'s transport boundary, after it has already // ip`) only ever reads request headers — it has no way to see the actual TCP socket peer.
// resolved and verified the caller's address against the actual socket peer (and, in // Restricting `ipAddressHeaders` to exactly one platform-owned header name, rather than
// explicit proxy mode, a configured trusted-proxy chain) — see // leaving it on Better Auth's own default (`x-forwarded-for`, trusted directly with no
// `apps/api/src/client-address.ts`. `trustedProxies` is deliberately left unset here: that // proxy policy), is what stops a caller from choosing its own rate-limit bucket: the named
// option only inspects header *content* for proxy-shaped addresses and can never verify who // header is populated only by `apps/api`'s transport boundary, after it has already
// actually sent the request, so the equivalent trust decision is made once, correctly, at // resolved and verified the caller's address against the actual socket peer (and, in
// the transport boundary instead of asked of Better Auth a second time. // explicit proxy mode, a configured trusted-proxy chain) — see
ipAddress: { // `apps/api/src/client-address.ts`. `trustedProxies` is deliberately left unset here: that
ipAddressHeaders: [TRUSTED_CLIENT_ADDRESS_HEADER], // option only inspects header *content* for proxy-shaped addresses and can never verify who
}, // actually sent the request, so the equivalent trust decision is made once, correctly, at
// the transport boundary instead of asked of Better Auth a second time.
ipAddress: {
ipAddressHeaders: [TRUSTED_CLIENT_ADDRESS_HEADER],
}, },
baseURL: options.config.baseUrl, },
database: drizzleAdapter(options.db, { baseURL: options.config.baseUrl,
provider: "pg", database: drizzleAdapter(options.db, {
schema: identityAdapterSchema, provider: "pg",
}), schema: identityAdapterSchema,
emailAndPassword: {
autoSignIn: false,
// Public registration is disabled unless explicitly enabled (`IdentityConfig.
// publicSignupEnabled`, `core/src/identity/config.ts`) — never a side effect of an absent,
// misspelled, or loosely parsed configuration value. Better Auth's own `disableSignUp` gate
// rejects every `/sign-up/email` call with a fixed `400 EMAIL_PASSWORD_SIGN_UP_DISABLED`
// before ever looking up the email, so a disabled deployment never reveals whether an
// address is already registered; sign-in and existing sessions are entirely unaffected.
disableSignUp: !options.config.publicSignupEnabled,
enabled: true,
// Pinned explicitly rather than left on Better Auth's fallback default (`|| 8` / `|| 128`
// in the pinned release): an implicit default can change silently on a future Better Auth
// upgrade, and Ekklesia's own tests assert this exact 8–128 boundary.
maxPasswordLength: 128,
minPasswordLength: 8,
},
logger: {
disabled: false,
level: "error",
log: (level) => sanitizedBetterAuthLogger(level),
},
// A second, independent layer alongside `logger` above: the pinned release's own transport
// dependency (`better-call`'s router) has an unconditional `console.error("# SERVER_ERROR:
// ", error)` fallback for any thrown value it does not recognize as one of Better Auth's own
// `APIError`s — this fires *before* Better Auth's `logger` option is ever consulted, so no
// logger configuration can reach it. `onAPIError.throw: true` closes that gap: Better Auth's
// own `APIError`s (wrong password, invalid origin, disabled sign-up, ...) still resolve to
// their normal HTTP response completely unaffected, but a genuinely unexpected failure (for
// example a database outage) now propagates out of `auth.handler()` instead of being caught
// and printed raw internally, so it reaches `handleAuthRequest`'s own try/catch below and
// becomes a sanitized `IdentityRuntimeError` like any other unexpected failure.
onAPIError: {
throw: true,
},
rateLimit: {
enabled: true,
max: 100,
window: 10,
...(rateLimitOverride
? {
customRules: {
"/sign-in/email": {
max: rateLimitOverride.max ?? 100,
window: rateLimitOverride.window ?? 10,
},
"/sign-up/email": {
max: rateLimitOverride.max ?? 100,
window: rateLimitOverride.window ?? 10,
},
},
}
: {}),
},
secret: options.config.secret,
trustedOrigins: [...options.config.trustedOrigins],
}), }),
emailAndPassword: {
autoSignIn: false,
// Public registration is disabled unless explicitly enabled (`IdentityConfig.
// publicSignupEnabled`, `core/src/identity/config.ts`) — never a side effect of an absent,
// misspelled, or loosely parsed configuration value. Better Auth's own `disableSignUp` gate
// rejects every `/sign-up/email` call with a fixed `400 EMAIL_PASSWORD_SIGN_UP_DISABLED`
// before ever looking up the email, so a disabled deployment never reveals whether an
// address is already registered; sign-in and existing sessions are entirely unaffected.
disableSignUp: !options.config.publicSignupEnabled,
enabled: true,
// Pinned explicitly rather than left on Better Auth's fallback default (`|| 8` / `|| 128`
// in the pinned release): an implicit default can change silently on a future Better Auth
// upgrade, and Ekklesia's own tests assert this exact 8–128 boundary.
maxPasswordLength: 128,
minPasswordLength: 8,
},
logger: {
disabled: false,
level: "error",
log: (level) => sanitizedBetterAuthLogger(level),
},
// A second, independent layer alongside `logger` above: the pinned release's own transport
// dependency (`better-call`'s router) has an unconditional `console.error("# SERVER_ERROR:
// ", error)` fallback for any thrown value it does not recognize as one of Better Auth's own
// `APIError`s — this fires *before* Better Auth's `logger` option is ever consulted, so no
// logger configuration can reach it. `onAPIError.throw: true` closes that gap: Better Auth's
// own `APIError`s (wrong password, invalid origin, disabled sign-up, ...) still resolve to
// their normal HTTP response completely unaffected, but a genuinely unexpected failure (for
// example a database outage) now propagates out of `auth.handler()` instead of being caught
// and printed raw internally, so it reaches `handleAuthRequest`'s own try/catch below and
// becomes a sanitized `IdentityRuntimeError` like any other unexpected failure.
onAPIError: {
throw: true,
},
rateLimit: {
enabled: true,
max: 100,
window: 10,
...(rateLimitOverride
? {
customRules: {
"/sign-in/email": {
max: rateLimitOverride.max ?? 100,
window: rateLimitOverride.window ?? 10,
},
"/sign-up/email": {
max: rateLimitOverride.max ?? 100,
window: rateLimitOverride.window ?? 10,
},
},
}
: {}),
},
secret: options.config.secret,
trustedOrigins: [...options.config.trustedOrigins],
} satisfies BetterAuthOptions;
const auth = constructBetterAuth(() => betterAuth(betterAuthOptions));
// `getCookies` is Better Auth's own supported, exported helper (`better-auth/cookies`) for
// resolving the configured cookie names from an options object — not a dependency internal. It
// is given the identical object above, so the returned `sessionToken.name` is exactly the name
// the constructed runtime will read, including the `__Secure-` prefix an https `baseURL` implies.
const sessionCookieName = constructBetterAuth(
() => getCookies(betterAuthOptions).sessionToken.name,
); );
return Object.freeze({ return Object.freeze({
@@ -263,14 +341,16 @@ export function createIdentityRuntime(
return createAuthenticatedPrincipal(userId); return createAuthenticatedPrincipal(userId);
} }
// Better Auth's getSession swallows infrastructure failures internally and returns null // Better Auth's getSession swallows infrastructure failures internally and returns null for
// for them exactly like a genuinely missing, expired, or malformed session — it never // them exactly like a genuinely missing, expired, revoked, or malformed session — it never
// throws. A request with no Cookie header at all is unambiguous: Better Auth never queries // throws. Only a request that actually presented *the configured session cookie* is
// the database for that case, so it is always genuinely anonymous. Only when a cookie was // ambiguous enough to be worth disambiguating: with no cookie at all, or with only unrelated
// actually presented is the null result ambiguous enough to require disambiguation. // cookies, Better Auth never resolved an Ekklesia session in the first place, so `null` is
if (headers.has("cookie")) { // unambiguous and no session-store work is performed. This is what keeps an arbitrary
const reachable = await probeIdentityDatabase(options.db); // anonymous cookie from causing identity database work on every request.
if (!reachable) { if (hasConfiguredSessionCookie(headers, sessionCookieName)) {
const sessionStoreUsable = await probeSessionStore(options.db);
if (!sessionStoreUsable) {
throw new IdentityRuntimeError("PRINCIPAL_RESOLUTION_FAILED"); throw new IdentityRuntimeError("PRINCIPAL_RESOLUTION_FAILED");
} }
} }
+150 -27
View File
@@ -9,6 +9,7 @@ vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock })); vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
import type { IdentityConfig } from "../src/identity/config.js"; import type { IdentityConfig } from "../src/identity/config.js";
import type { IdentityDrizzleDatabase } from "../src/identity/database.js";
import { import {
createIdentityRuntime, createIdentityRuntime,
IdentityRuntimeError, IdentityRuntimeError,
@@ -26,8 +27,43 @@ function validConfig(overrides: Partial<IdentityConfig> = {}): IdentityConfig {
}); });
} }
function fakeDb(overrides: Partial<{ execute: ReturnType<typeof vi.fn> }> = {}) { interface FakeDbOptions {
return { __fakeDb: true, execute: vi.fn().mockResolvedValue(undefined), ...overrides } as never; /** Makes the session-store readiness read reject, standing in for a missing relation, a dropped
* column, a denied `SELECT` privilege, an unavailable connection, or a query timeout — every one
* of which surfaces to this seam identically, as a rejected query. */
readonly sessionProbeError?: Error;
}
/**
* A fake Identity Drizzle view that records every query actually issued, so a test can prove the
* *absence* of identity database work — not merely that a particular helper was not called. Both
* the legacy `execute` seam and the `select(...).from(...).where(...)` chain the session-store
* readiness check uses are recorded into one ordered list.
*/
function fakeDb(options: FakeDbOptions = {}) {
const queries: string[] = [];
const execute = vi.fn(() => {
queries.push("execute");
return Promise.resolve(undefined);
});
// Drizzle's query builder is awaited directly, so the terminal `where(...)` returns a real
// Promise rather than a hand-rolled thenable object.
const select = vi.fn((columns: Record<string, unknown>) => {
queries.push(`select:${Object.keys(columns).sort().join(",")}`);
const builder = {
from: vi.fn(() => builder),
where: vi.fn(() =>
options.sessionProbeError
? Promise.reject(options.sessionProbeError)
: Promise.resolve([] as unknown[]),
),
};
return builder;
});
const db = { __fakeDb: true, execute, queries, select };
// The runtime only ever uses the narrow surface faked above; the cast keeps the fake honest
// about that instead of stubbing all of `NodePgDatabase`, while `queries` stays observable.
return db as unknown as IdentityDrizzleDatabase & { readonly queries: string[] };
} }
describe("Core identity runtime construction boundary", () => { describe("Core identity runtime construction boundary", () => {
@@ -327,65 +363,152 @@ describe("Core identity runtime — resolvePrincipal", () => {
drizzleAdapterMock.mockClear(); drizzleAdapterMock.mockClear();
}); });
it("maps a valid session to an authenticated principal containing only the opaque user id", async () => { /** The configured production (https) session-cookie name, derived by Better Auth's own
* `getCookies` helper from the exact same `baseUrl` this suite's `validConfig` uses. */
const productionSessionCookie = "__Secure-better-auth.session_token";
it("maps a valid session to an authenticated principal containing only the opaque user id, without any follow-up probe", async () => {
const getSession = vi.fn().mockResolvedValue({ const getSession = vi.fn().mockResolvedValue({
session: { id: "sess_1", token: "tok" }, session: { id: "sess_1", token: "tok" },
user: { email: "[email protected]", id: "user_123", name: "Person" }, user: { email: "[email protected]", id: "user_123", name: "Person" },
}); });
betterAuthMock.mockReturnValue({ api: { getSession }, handler: vi.fn() }); betterAuthMock.mockReturnValue({ api: { getSession }, handler: vi.fn() });
const db = fakeDb();
const runtime = createIdentityRuntime({ config: validConfig(), db: fakeDb() }); const runtime = createIdentityRuntime({ config: validConfig(), db });
const principal = await runtime.resolvePrincipal( const principal = await runtime.resolvePrincipal(
new Headers({ cookie: "better-auth.session_token=tok" }), new Headers({ cookie: `${productionSessionCookie}=tok` }),
); );
expect(principal).toEqual({ kind: "authenticated", userId: "user_123" }); expect(principal).toEqual({ kind: "authenticated", userId: "user_123" });
expect(Object.keys(principal)).toEqual(["kind", "userId"]); expect(Object.keys(principal)).toEqual(["kind", "userId"]);
expect(Object.isFrozen(principal)).toBe(true); expect(Object.isFrozen(principal)).toBe(true);
// A resolved session needs no readiness confirmation — the store demonstrably worked.
expect(db.queries).toEqual([]);
}); });
it("maps a missing session with no cookie header to anonymous without probing the database", async () => { it("performs zero identity database work for no cookie, unrelated cookies, or session-cookie lookalikes", async () => {
const getSession = vi.fn().mockResolvedValue(null); const getSession = vi.fn().mockResolvedValue(null);
const execute = vi.fn().mockResolvedValue(undefined);
betterAuthMock.mockReturnValue({ api: { getSession }, handler: vi.fn() }); betterAuthMock.mockReturnValue({ api: { getSession }, handler: vi.fn() });
const runtime = createIdentityRuntime({ config: validConfig(), db: fakeDb({ execute }) }); for (const cookie of [
const principal = await runtime.resolvePrincipal(new Headers()); undefined,
"x=y",
"a=1; b=2; c=3",
// Lookalikes: a prefix, a suffix, a differently-cased spelling, a percent-encoded name, and
// the unprefixed development name (wrong for this https configuration).
`${productionSessionCookie}_extra=v`,
`extra_${productionSessionCookie}=v`,
`${productionSessionCookie.toUpperCase()}=v`,
"__Secure-better-auth%2Esession_token=v",
"better-auth.session_token=v",
]) {
const db = fakeDb();
const runtime = createIdentityRuntime({ config: validConfig(), db });
const principal = await runtime.resolvePrincipal(
cookie === undefined ? new Headers() : new Headers({ cookie }),
);
expect(principal).toEqual({ kind: "anonymous" }); expect(principal).toEqual({ kind: "anonymous" });
expect(execute).not.toHaveBeenCalled(); expect(db.queries).toEqual([]);
}
}); });
it("maps an expired/revoked/malformed session (cookie present, null session) to anonymous when the database is reachable", async () => { it("reads the real session relation and its expected columns only when the configured session cookie was presented", async () => {
const getSession = vi.fn().mockResolvedValue(null); const getSession = vi.fn().mockResolvedValue(null);
const execute = vi.fn().mockResolvedValue(undefined);
betterAuthMock.mockReturnValue({ api: { getSession }, handler: vi.fn() }); betterAuthMock.mockReturnValue({ api: { getSession }, handler: vi.fn() });
const db = fakeDb();
const runtime = createIdentityRuntime({ config: validConfig(), db: fakeDb({ execute }) }); const runtime = createIdentityRuntime({ config: validConfig(), db });
const principal = await runtime.resolvePrincipal( const principal = await runtime.resolvePrincipal(
new Headers({ cookie: "better-auth.session_token=stale-or-garbage" }), new Headers({ cookie: `${productionSessionCookie}=stale-or-garbage` }),
); );
// A healthy session store plus an unresolvable token is a genuinely anonymous caller.
expect(principal).toEqual({ kind: "anonymous" }); expect(principal).toEqual({ kind: "anonymous" });
expect(execute).toHaveBeenCalledTimes(1); // The readiness check is a real read of the session relation's expected columns — never
// `select 1`, and never a generic pool ping.
expect(db.queries).toEqual(["select:expiresAt,id,token,userId"]);
}); });
it("never downgrades an infrastructure failure to anonymous: throws when the database is unreachable", async () => { it("detects the session cookie among several cookies and with irregular whitespace", async () => {
const getSession = vi.fn().mockResolvedValue(null); const getSession = vi.fn().mockResolvedValue(null);
const execute = vi.fn().mockRejectedValue(new Error("connection terminated unexpectedly"));
betterAuthMock.mockReturnValue({ api: { getSession }, handler: vi.fn() }); betterAuthMock.mockReturnValue({ api: { getSession }, handler: vi.fn() });
const runtime = createIdentityRuntime({ config: validConfig(), db: fakeDb({ execute }) }); for (const cookie of [
`a=1; ${productionSessionCookie}=tok; b=2`,
let captured: unknown; ` ${productionSessionCookie}=tok `,
try { `a=1;${productionSessionCookie}=tok`,
await runtime.resolvePrincipal(new Headers({ cookie: "better-auth.session_token=x" })); // A duplicated session cookie is ambiguous and must fail safe *towards* checking the store.
} catch (error) { `${productionSessionCookie}=one; ${productionSessionCookie}=two`,
captured = error; // A valueless session cookie still means Better Auth saw the name.
`${productionSessionCookie}`,
]) {
const db = fakeDb();
const runtime = createIdentityRuntime({ config: validConfig(), db });
await runtime.resolvePrincipal(new Headers({ cookie }));
expect(db.queries).toEqual(["select:expiresAt,id,token,userId"]);
} }
});
expect(captured).toBeInstanceOf(IdentityRuntimeError); it("uses the unprefixed development cookie name when the configured base URL is http", async () => {
expect(captured).toMatchObject({ code: "PRINCIPAL_RESOLUTION_FAILED" }); const getSession = vi.fn().mockResolvedValue(null);
betterAuthMock.mockReturnValue({ api: { getSession }, handler: vi.fn() });
const developmentConfig = validConfig({
baseUrl: "http://localhost:3000",
trustedOrigins: Object.freeze(["http://localhost:3000"]),
});
const unprefixed = fakeDb();
await createIdentityRuntime({ config: developmentConfig, db: unprefixed }).resolvePrincipal(
new Headers({ cookie: "better-auth.session_token=tok" }),
);
expect(unprefixed.queries).toEqual(["select:expiresAt,id,token,userId"]);
// The production `__Secure-` name is *not* what an http deployment reads.
const prefixed = fakeDb();
await createIdentityRuntime({ config: developmentConfig, db: prefixed }).resolvePrincipal(
new Headers({ cookie: `${productionSessionCookie}=tok` }),
);
expect(prefixed.queries).toEqual([]);
});
it("never downgrades a broken session store to anonymous, and leaks no database detail", async () => {
const getSession = vi.fn().mockResolvedValue(null);
betterAuthMock.mockReturnValue({ api: { getSession }, handler: vi.fn() });
// Every distinguishable session-store failure reaches this seam as a rejected read: a missing
// relation, a dropped column, a denied privilege, a dead connection, and a timeout alike.
const hostileFailures = [
new Error('relation "ekklesia_core_session" does not exist'),
new Error('column "token" does not exist'),
new Error("permission denied for table ekklesia_core_session at 10.1.2.3:5432"),
new Error("connection terminated unexpectedly"),
new Error("timed out"),
];
for (const sessionProbeError of hostileFailures) {
const runtime = createIdentityRuntime({
config: validConfig(),
db: fakeDb({ sessionProbeError }),
});
let captured: unknown;
try {
await runtime.resolvePrincipal(new Headers({ cookie: `${productionSessionCookie}=x` }));
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(IdentityRuntimeError);
expect(captured).toMatchObject({ code: "PRINCIPAL_RESOLUTION_FAILED" });
const publicText = `${String(captured)}\n${
captured instanceof Error ? (captured.stack ?? "") : ""
}\n${JSON.stringify(captured)}`;
expect(publicText).not.toContain("ekklesia_core_session");
expect(publicText).not.toContain("permission denied");
expect(publicText).not.toContain("10.1.2.3");
expect(publicText).not.toContain("does not exist");
}
}); });
it("treats an unexpected throw from getSession itself as a sanitized infrastructure failure", async () => { it("treats an unexpected throw from getSession itself as a sanitized infrastructure failure", async () => {
+8 -1
View File
@@ -87,7 +87,7 @@ Authenticated:
{ "kind": "authenticated", "userId": "2aKp4jPeV1QbXwyz1OH6IcAUfl2n1fc1" } { "kind": "authenticated", "userId": "2aKp4jPeV1QbXwyz1OH6IcAUfl2n1fc1" }
``` ```
Infrastructure failure (the identity database could not be reached while resolving the principal — Infrastructure failure (the identity session store was not usable while resolving the principal —
never silently reported as anonymous): never silently reported as anonymous):
```json ```json
@@ -95,6 +95,13 @@ never silently reported as anonymous):
``` ```
returned with HTTP `503`. returned with HTTP `503`.
A request carrying no cookies, or only unrelated cookies, never reaches that path and never performs
identity database work at all: only a request presenting the *configured session cookie* that Better
Auth resolved to `null` is ambiguous enough to check. When it is checked, a missing session table, a
dropped column, a revoked `SELECT` privilege, an unreachable database, and a query timeout all
produce this same sanitized `503` — carrying no relation name, column name, SQL, driver message,
host, or stack. See [Identity § How a principal is resolved](identity.md#how-a-principal-is-resolved).
This is deliberately not the same thing as Better Auth's own `GET /api/auth/get-session` response, This is deliberately not the same thing as Better Auth's own `GET /api/auth/get-session` response,
which returns Better Auth's full `{session, user}` shape (session id, expiry, IP address, user which returns Better Auth's full `{session, user}` shape (session id, expiry, IP address, user
agent, email, name, image, email-verification status, and more). `/api/v1/principal` is the agent, email, name, image, email-verification status, and more). `/api/v1/principal` is the
+31 -1
View File
@@ -144,7 +144,8 @@ ADR 0010.
`handleAuthRequest(request): Promise<Response>` (delegates directly to Better Auth's handler; its `handleAuthRequest(request): Promise<Response>` (delegates directly to Better Auth's handler; its
normal responses, including its own error responses, pass through unmodified) and normal responses, including its own error responses, pass through unmodified) and
`resolvePrincipal(headers): Promise<EkklesiaPrincipal>` (never silently downgrades a database `resolvePrincipal(headers): Promise<EkklesiaPrincipal>` (never silently downgrades a database
outage to an anonymous result — see ADR 0011 for exactly how). Neither method, nor anything else outage to an anonymous result — see ADR 0011 and [session resolution](#how-a-principal-is-resolved)
below). Neither method, nor anything else
exported from `@ekklesia/core/identity`, exposes `auth.api`, Better Auth's session/user types, the exported from `@ekklesia/core/identity`, exposes `auth.api`, Better Auth's session/user types, the
Drizzle adapter, the raw database, a session token, or Better Auth configuration. `apps/api` is the Drizzle adapter, the raw database, a session token, or Better Auth configuration. `apps/api` is the
only current consumer of this boundary; see [API](api.md). only current consumer of this boundary; see [API](api.md).
@@ -166,6 +167,35 @@ hung or slow query, which `connectionTimeoutMs` alone cannot do since it only bo
the connection. `apps/api` sets both timeouts explicitly (see [API](api.md)); an invalid value is the connection. `apps/api` sets both timeouts explicitly (see [API](api.md)); an invalid value is
rejected the same sanitized way an invalid connection string already is. rejected the same sanitized way an invalid connection string already is.
## How a principal is resolved
`resolvePrincipal(headers)` returns the anonymous principal for a missing, expired, revoked, or
malformed session, and throws a sanitized `PRINCIPAL_RESOLUTION_FAILED` for an infrastructure
failure. Telling those apart takes explicit work, because Better Auth's `getSession` swallows
internal failures and returns `null` for them exactly as it does for a genuinely absent session.
1. **No cookies, or only unrelated cookies → no identity database work at all.** `Cookie: x=y` is
not evidence that Better Auth ever attempted to resolve an Ekklesia session, so its `null` is
unambiguous and the anonymous principal is returned immediately. An unauthenticated caller
cannot drive identity database load with a cookie header they chose themselves.
2. **The configured session cookie is present, and Better Auth returned `null` → the ambiguous
case, and only then is the session store checked.** Detection is an exact, whole-name cookie
comparison (RFC 6265 parsing; names are case-sensitive and never percent-decoded), so
`better-auth.session_token_x` and `x_better-auth.session_token` are correctly *not* the session
cookie. The name itself is derived from the same options object passed to Better Auth, via its
own exported `getCookies` helper — including the `__Secure-` prefix an `https` base URL implies
— so there is no second copy of it to drift.
3. **The check reads the real session relation**, not `select 1`: it plans a read of the exact
columns the Better Auth adapter depends on under a `where false` predicate. PostgreSQL resolves
the relation, every named column, and the caller's `SELECT` privilege while matching zero rows,
so no session row, token, or user id can be read, logged, or enumerated by the check. It runs on
the injected pool — never a second connection — under a bounded 3-second timeout.
A missing session table, a dropped or drifted column, a revoked `SELECT` privilege, an unreachable
database, and a query timeout therefore all produce a sanitized
`503 {"code":"PRINCIPAL_RESOLUTION_FAILED"}` — never a silent anonymous result. The response body,
logs, and error carry no relation name, column name, SQL, driver message, host, or stack.
## What is not operational yet ## What is not operational yet
No email verification, password recovery, invitation flow, email delivery, social or OAuth No email verification, password recovery, invitation flow, email delivery, social or OAuth
+13 -2
View File
@@ -19,8 +19,10 @@ connection and operation (`query_timeout`/`statement_timeout`) database timeouts
runtime (explicit construction, exact schema mapping, only email/password and rate limiting runtime (explicit construction, exact schema mapping, only email/password and rate limiting
enabled, an explicit `advanced.disableCSRFCheck`/`disableOriginCheck: false` pair, an explicit enabled, an explicit `advanced.disableCSRFCheck`/`disableOriginCheck: false` pair, an explicit
error-only sanitized logger that strips Better Auth's own message and arguments regardless of what error-only sanitized logger that strips Better Auth's own message and arguments regardless of what
they contain, pinned 8–128 password-length bounds, sanitized failures, and that a database outage they contain, pinned 8–128 password-length bounds, sanitized failures, exact whole-name
during principal resolution is never silently reported as anonymous), and Core migration discovery session-cookie detection that never fires on a prefixed, suffixed, or differently-cased lookalike,
and that a broken session store during principal resolution is never silently reported as anonymous
while an unrelated cookie performs no identity database work at all), and Core migration discovery
(deterministic ordering, the reserved `core` identity, and collision rejection) — all without a (deterministic ordering, the reserved `core` identity, and collision rejection) — all without a
live database. `apps/api`'s own unit tests cover its pure Hono app factory (route shapes, module live database. `apps/api`'s own unit tests cover its pure Hono app factory (route shapes, module
route mounting/enforcement/CORS/response sanitization, CORS allow/deny/preflight, deterministic 404 route mounting/enforcement/CORS/response sanitization, CORS allow/deny/preflight, deterministic 404
@@ -83,6 +85,15 @@ outage during principal resolution producing a sanitized `503` rather than a fal
starting the API against an unmigrated database creating no tables at all, and a clean listener/pool starting the API against an unmigrated database creating no tables at all, and a clean listener/pool
shutdown. shutdown.
A dedicated group within that suite proves session-store readiness against the real database rather
than a mock, since that is the only place a broken store looks exactly like an anonymous request: a
request carrying no cookies and one carrying only an unrelated cookie both resolve as anonymous
while issuing **no** identity database work at all; a request presenting the configured session
cookie against a dropped session table, a dropped column, and a revoked `SELECT` privilege each
produce a sanitized `503 PRINCIPAL_RESOLUTION_FAILED` with no relation name, column name, SQL, or
driver text in the body or the logs; and a genuinely expired or unknown session against a healthy
store still resolves as anonymous.
The same suite also proves Better Auth's own origin/CSRF protection is production-equivalent under The same suite also proves Better Auth's own origin/CSRF protection is production-equivalent under
the test runner, not just in a plain Node script: a no-cookie first authentication attempt succeeds the test runner, not just in a plain Node script: a no-cookie first authentication attempt succeeds
from a trusted browser origin and is rejected (`403`, no permissive CORS header, no leaked detail) from a trusted browser origin and is rejected (`403`, no permissive CORS header, no leaked detail)