fix: distinguish identity session failures

Principal resolution could not tell a broken session store from a genuinely
anonymous request, and did unnecessary database work for callers that never
presented a session at all. Both are corrected here.

Any cookie previously triggered the ambiguity check, so `Cookie: x=y` on an
otherwise anonymous request caused an identity database round trip an
unauthenticated caller could drive with a header they chose themselves.
`hasConfiguredSessionCookie` now parses the Cookie header per RFC 6265 (split
on `;`, name is the text before the first `=`, trimmed) and compares the whole
name exactly — 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. A duplicated name still reports true: ambiguity fails
safe towards checking, and reporting true never grants access on its own.

The cookie name is not a second hard-coded copy. One `betterAuthOptions` object
now feeds both `betterAuth()` and Better Auth's own supported `getCookies`
helper, so the detected name — including the `__Secure-` prefix an https baseURL
implies — is derived from the exact configuration Better Auth itself uses, and
drift is structurally impossible rather than merely tested.

The readiness check itself proved almost nothing: `select 1` still succeeded
against a dropped session table, a drifted or removed column, or a revoked
SELECT privilege, so Better Auth's ambiguous null was reported as a genuinely
anonymous request. `probeSessionStore` now plans a real read of the session
relation and the exact columns the adapter depends on, under `where false`:
PostgreSQL resolves the relation, every column, and the caller's privilege at
plan time while matching zero rows, so no session row, token, or user id can be
read, logged, or enumerated by the check. It uses the injected pool, never a
second connection, and stays bounded by the existing timeout.

A missing relation, dropped column, revoked privilege, unreachable connection,
and query timeout now all produce the same sanitized 503 with no relation name,
column name, SQL, driver text, host, or stack — proven against real PostgreSQL,
since that is the only place a broken store looks exactly like an anonymous
request.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-07-26 17:02:42 -05:00
co-authored by Claude Opus 5
parent 3d6c85b0f8
commit 7a0f624fc3
6 changed files with 654 additions and 132 deletions
+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", () => {
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");