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:
co-authored by
Claude Opus 5
parent
3d6c85b0f8
commit
7a0f624fc3
+181
-101
@@ -3,12 +3,13 @@ import {
|
||||
createAuthenticatedPrincipal,
|
||||
type EkklesiaPrincipal,
|
||||
} from "@ekklesia/contracts";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { betterAuth, type BetterAuthOptions } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { getCookies } from "better-auth/cookies";
|
||||
import { sql } from "drizzle-orm";
|
||||
import type { IdentityConfig } from "./config.js";
|
||||
import type { IdentityDrizzleDatabase } from "./database.js";
|
||||
import { identityAdapterSchema } from "./schema.js";
|
||||
import { ekklesiaCoreSession, identityAdapterSchema } from "./schema.js";
|
||||
|
||||
export type IdentityRuntimeErrorCode =
|
||||
| "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 {
|
||||
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;
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
@@ -147,97 +213,109 @@ export function createIdentityRuntime(
|
||||
): EkklesiaIdentityRuntime {
|
||||
const rateLimitOverride = options.rateLimitOverride;
|
||||
|
||||
const auth = constructBetterAuth(() =>
|
||||
betterAuth({
|
||||
advanced: {
|
||||
// Better Auth's own default is environment-sensitive: `skipOriginCheck` falls back to
|
||||
// `true` whenever `isTest()` is true (NODE_ENV=test, which Vitest sets, or TEST=1) unless
|
||||
// `disableOriginCheck` is explicitly set — and origin skip in turn also silently skips
|
||||
// CSRF validation as long as `disableCSRFCheck` is left unset (Better Auth's documented
|
||||
// backward-compatibility coupling). Left implicit, this means origin/CSRF protection is
|
||||
// silently *off* under the test runner and *on* in production: the exact same code path
|
||||
// behaves differently in the two places it matters most to verify. Setting both flags to
|
||||
// `false` explicitly removes the `isTest()` branch entirely, so the identical protection
|
||||
// is enforced in every environment, including under Vitest.
|
||||
disableCSRFCheck: false,
|
||||
disableOriginCheck: false,
|
||||
// The pinned release's own client-address resolution (`getIp`, `@better-auth/core/utils/
|
||||
// ip`) only ever reads request headers — it has no way to see the actual TCP socket peer.
|
||||
// Restricting `ipAddressHeaders` to exactly one platform-owned header name, rather than
|
||||
// leaving it on Better Auth's own default (`x-forwarded-for`, trusted directly with no
|
||||
// proxy policy), is what stops a caller from choosing its own rate-limit bucket: the named
|
||||
// header is populated only by `apps/api`'s transport boundary, after it has already
|
||||
// resolved and verified the caller's address against the actual socket peer (and, in
|
||||
// explicit proxy mode, a configured trusted-proxy chain) — see
|
||||
// `apps/api/src/client-address.ts`. `trustedProxies` is deliberately left unset here: that
|
||||
// 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],
|
||||
},
|
||||
// One options object feeds both `betterAuth()` and `getCookies()` below, so the session-cookie
|
||||
// name this runtime detects is derived from the exact same configuration Better Auth itself
|
||||
// uses. That makes drift structurally impossible rather than merely tested: there is no second
|
||||
// hard-coded copy of the cookie name (or of the `__Secure-` prefix rule the configured HTTPS
|
||||
// `baseURL` implies) anywhere in this repository.
|
||||
const betterAuthOptions = {
|
||||
advanced: {
|
||||
// Better Auth's own default is environment-sensitive: `skipOriginCheck` falls back to
|
||||
// `true` whenever `isTest()` is true (NODE_ENV=test, which Vitest sets, or TEST=1) unless
|
||||
// `disableOriginCheck` is explicitly set — and origin skip in turn also silently skips
|
||||
// CSRF validation as long as `disableCSRFCheck` is left unset (Better Auth's documented
|
||||
// backward-compatibility coupling). Left implicit, this means origin/CSRF protection is
|
||||
// silently *off* under the test runner and *on* in production: the exact same code path
|
||||
// behaves differently in the two places it matters most to verify. Setting both flags to
|
||||
// `false` explicitly removes the `isTest()` branch entirely, so the identical protection
|
||||
// is enforced in every environment, including under Vitest.
|
||||
disableCSRFCheck: false,
|
||||
disableOriginCheck: false,
|
||||
// The pinned release's own client-address resolution (`getIp`, `@better-auth/core/utils/
|
||||
// ip`) only ever reads request headers — it has no way to see the actual TCP socket peer.
|
||||
// Restricting `ipAddressHeaders` to exactly one platform-owned header name, rather than
|
||||
// leaving it on Better Auth's own default (`x-forwarded-for`, trusted directly with no
|
||||
// proxy policy), is what stops a caller from choosing its own rate-limit bucket: the named
|
||||
// header is populated only by `apps/api`'s transport boundary, after it has already
|
||||
// resolved and verified the caller's address against the actual socket peer (and, in
|
||||
// explicit proxy mode, a configured trusted-proxy chain) — see
|
||||
// `apps/api/src/client-address.ts`. `trustedProxies` is deliberately left unset here: that
|
||||
// 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, {
|
||||
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],
|
||||
},
|
||||
baseURL: options.config.baseUrl,
|
||||
database: drizzleAdapter(options.db, {
|
||||
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],
|
||||
} 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({
|
||||
@@ -263,14 +341,16 @@ export function createIdentityRuntime(
|
||||
return createAuthenticatedPrincipal(userId);
|
||||
}
|
||||
|
||||
// Better Auth's getSession swallows infrastructure failures internally and returns null
|
||||
// for them exactly like a genuinely missing, expired, or malformed session — it never
|
||||
// throws. A request with no Cookie header at all is unambiguous: Better Auth never queries
|
||||
// the database for that case, so it is always genuinely anonymous. Only when a cookie was
|
||||
// actually presented is the null result ambiguous enough to require disambiguation.
|
||||
if (headers.has("cookie")) {
|
||||
const reachable = await probeIdentityDatabase(options.db);
|
||||
if (!reachable) {
|
||||
// Better Auth's getSession swallows infrastructure failures internally and returns null for
|
||||
// them exactly like a genuinely missing, expired, revoked, or malformed session — it never
|
||||
// throws. Only a request that actually presented *the configured session cookie* is
|
||||
// ambiguous enough to be worth disambiguating: with no cookie at all, or with only unrelated
|
||||
// cookies, Better Auth never resolved an Ekklesia session in the first place, so `null` is
|
||||
// unambiguous and no session-store work is performed. This is what keeps an arbitrary
|
||||
// anonymous cookie from causing identity database work on every request.
|
||||
if (hasConfiguredSessionCookie(headers, sessionCookieName)) {
|
||||
const sessionStoreUsable = await probeSessionStore(options.db);
|
||||
if (!sessionStoreUsable) {
|
||||
throw new IdentityRuntimeError("PRINCIPAL_RESOLUTION_FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
|
||||
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
|
||||
|
||||
import type { IdentityConfig } from "../src/identity/config.js";
|
||||
import type { IdentityDrizzleDatabase } from "../src/identity/database.js";
|
||||
import {
|
||||
createIdentityRuntime,
|
||||
IdentityRuntimeError,
|
||||
@@ -26,8 +27,43 @@ function validConfig(overrides: Partial<IdentityConfig> = {}): IdentityConfig {
|
||||
});
|
||||
}
|
||||
|
||||
function fakeDb(overrides: Partial<{ execute: ReturnType<typeof vi.fn> }> = {}) {
|
||||
return { __fakeDb: true, execute: vi.fn().mockResolvedValue(undefined), ...overrides } as never;
|
||||
interface FakeDbOptions {
|
||||
/** 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", () => {
|
||||
@@ -327,65 +363,152 @@ describe("Core identity runtime — resolvePrincipal", () => {
|
||||
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({
|
||||
session: { id: "sess_1", token: "tok" },
|
||||
user: { email: "[email protected]", id: "user_123", name: "Person" },
|
||||
});
|
||||
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(
|
||||
new Headers({ cookie: "better-auth.session_token=tok" }),
|
||||
new Headers({ cookie: `${productionSessionCookie}=tok` }),
|
||||
);
|
||||
|
||||
expect(principal).toEqual({ kind: "authenticated", userId: "user_123" });
|
||||
expect(Object.keys(principal)).toEqual(["kind", "userId"]);
|
||||
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 execute = vi.fn().mockResolvedValue(undefined);
|
||||
betterAuthMock.mockReturnValue({ api: { getSession }, handler: vi.fn() });
|
||||
|
||||
const runtime = createIdentityRuntime({ config: validConfig(), db: fakeDb({ execute }) });
|
||||
const principal = await runtime.resolvePrincipal(new Headers());
|
||||
for (const cookie of [
|
||||
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(execute).not.toHaveBeenCalled();
|
||||
expect(principal).toEqual({ kind: "anonymous" });
|
||||
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 execute = vi.fn().mockResolvedValue(undefined);
|
||||
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(
|
||||
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(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 execute = vi.fn().mockRejectedValue(new Error("connection terminated unexpectedly"));
|
||||
betterAuthMock.mockReturnValue({ api: { getSession }, handler: vi.fn() });
|
||||
|
||||
const runtime = createIdentityRuntime({ config: validConfig(), db: fakeDb({ execute }) });
|
||||
|
||||
let captured: unknown;
|
||||
try {
|
||||
await runtime.resolvePrincipal(new Headers({ cookie: "better-auth.session_token=x" }));
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
for (const cookie of [
|
||||
`a=1; ${productionSessionCookie}=tok; b=2`,
|
||||
` ${productionSessionCookie}=tok `,
|
||||
`a=1;${productionSessionCookie}=tok`,
|
||||
// A duplicated session cookie is ambiguous and must fail safe *towards* checking the store.
|
||||
`${productionSessionCookie}=one; ${productionSessionCookie}=two`,
|
||||
// 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);
|
||||
expect(captured).toMatchObject({ code: "PRINCIPAL_RESOLUTION_FAILED" });
|
||||
it("uses the unprefixed development cookie name when the configured base URL is http", async () => {
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user