feat: expose core identity transport
This commit is contained in:
@@ -22,6 +22,10 @@
|
||||
"./diagnostics": {
|
||||
"types": "./dist/diagnostics/index.d.ts",
|
||||
"import": "./dist/diagnostics/index.js"
|
||||
},
|
||||
"./identity": {
|
||||
"types": "./dist/identity/index.d.ts",
|
||||
"import": "./dist/identity/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -60,6 +60,12 @@ export function createIdentityDrizzleDatabase(
|
||||
}
|
||||
|
||||
const pool = new Pool(poolConfig);
|
||||
// pg's own documentation requires an error listener on the pool: an idle client can be
|
||||
// terminated by the server (for example, its backend is killed, or the network drops) after
|
||||
// being returned to the pool, and the pool surfaces that as an 'error' event on itself rather
|
||||
// than on any in-flight query. Without a listener, that event is an unhandled Node.js error and
|
||||
// can crash the process even though no caller is doing anything wrong at the time.
|
||||
pool.on("error", () => undefined);
|
||||
const db = drizzle(pool, { schema: identityAdapterSchema });
|
||||
|
||||
return Object.freeze({
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import type { IdentityConfig } from "./config.js";
|
||||
import type { IdentityDrizzleDatabase } from "./database.js";
|
||||
import { identityAdapterSchema } from "./schema.js";
|
||||
|
||||
export type IdentityConstructionErrorCode = "IDENTITY_CONSTRUCTION_FAILED";
|
||||
|
||||
/** Never carries the underlying Better Auth error, cause, stack, secret, or URL. */
|
||||
export class IdentityConstructionError extends Error {
|
||||
readonly code: IdentityConstructionErrorCode;
|
||||
|
||||
constructor() {
|
||||
super("Identity could not be constructed.");
|
||||
this.name = "IdentityConstructionError";
|
||||
this.code = "IDENTITY_CONSTRUCTION_FAILED";
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
export interface CreateIdentityAuthOptions {
|
||||
readonly config: IdentityConfig;
|
||||
readonly db: IdentityDrizzleDatabase;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliberately opaque: Phase 4A never exposes the Better Auth instance, its HTTP handler, the
|
||||
* injected database, or the Drizzle adapter beyond this module. A later HTTP transport slice
|
||||
* decides what (if anything) becomes reachable outside core/src/identity.
|
||||
*/
|
||||
export interface EkklesiaIdentityInstance {
|
||||
readonly constructed: true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core's Better Auth composition boundary. Constructs Better Auth explicitly from injected,
|
||||
* already-validated configuration and an injected Drizzle database — it never reads environment
|
||||
* variables, never resolves a connection string, and enables no provider or plugin beyond the
|
||||
* four core models required for the identity schema itself.
|
||||
*/
|
||||
export function createIdentityAuth(options: CreateIdentityAuthOptions): EkklesiaIdentityInstance {
|
||||
try {
|
||||
betterAuth({
|
||||
baseURL: options.config.baseUrl,
|
||||
database: drizzleAdapter(options.db, {
|
||||
provider: "pg",
|
||||
schema: identityAdapterSchema,
|
||||
}),
|
||||
secret: options.config.secret,
|
||||
trustedOrigins: [...options.config.trustedOrigins],
|
||||
});
|
||||
} catch {
|
||||
throw new IdentityConstructionError();
|
||||
}
|
||||
|
||||
return Object.freeze({ constructed: true });
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./config.js";
|
||||
export * from "./database.js";
|
||||
export * from "./runtime.js";
|
||||
@@ -0,0 +1,187 @@
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createAuthenticatedPrincipal,
|
||||
type EkklesiaPrincipal,
|
||||
} from "@ekklesia/contracts";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { sql } from "drizzle-orm";
|
||||
import type { IdentityConfig } from "./config.js";
|
||||
import type { IdentityDrizzleDatabase } from "./database.js";
|
||||
import { identityAdapterSchema } from "./schema.js";
|
||||
|
||||
export type IdentityRuntimeErrorCode =
|
||||
| "AUTH_REQUEST_FAILED"
|
||||
| "IDENTITY_CONSTRUCTION_FAILED"
|
||||
| "PRINCIPAL_RESOLUTION_FAILED";
|
||||
|
||||
const runtimeErrorMessages: Readonly<Record<IdentityRuntimeErrorCode, string>> = Object.freeze({
|
||||
AUTH_REQUEST_FAILED: "The identity authentication request could not be handled.",
|
||||
IDENTITY_CONSTRUCTION_FAILED: "The identity runtime could not be constructed.",
|
||||
PRINCIPAL_RESOLUTION_FAILED: "The request principal could not be resolved.",
|
||||
});
|
||||
|
||||
const identityDatabaseProbeTimeoutMs = 3_000;
|
||||
|
||||
/** Never carries the underlying Better Auth error, database error, cause, stack, secret, or URL. */
|
||||
export class IdentityRuntimeError extends Error {
|
||||
readonly code: IdentityRuntimeErrorCode;
|
||||
|
||||
constructor(code: IdentityRuntimeErrorCode) {
|
||||
super(runtimeErrorMessages[code]);
|
||||
this.name = "IdentityRuntimeError";
|
||||
this.code = code;
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
export interface IdentityRateLimitOverride {
|
||||
readonly max?: number;
|
||||
readonly window?: number;
|
||||
}
|
||||
|
||||
export interface CreateIdentityRuntimeOptions {
|
||||
readonly config: IdentityConfig;
|
||||
readonly db: IdentityDrizzleDatabase;
|
||||
/**
|
||||
* A narrow, explicit override for the sign-in/sign-up rate-limit window used only by test
|
||||
* composition that issues many such requests quickly. Rate limiting itself is always enabled;
|
||||
* production composition must leave this unset so Better Auth's own strict built-in default
|
||||
* (3 requests / 10 seconds on sign-in and sign-up) applies unmodified.
|
||||
*/
|
||||
readonly rateLimitOverride?: IdentityRateLimitOverride;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliberately narrow: exposes exactly two Fetch-standard capabilities and nothing else. It never
|
||||
* returns the Better Auth instance, `auth.api`, inferred user/session types, the Drizzle adapter,
|
||||
* the database, a session token, cookie contents, a password hash, or Better Auth configuration.
|
||||
*/
|
||||
export interface EkklesiaIdentityRuntime {
|
||||
/** Delegates directly to Better Auth's Fetch handler. Normal Better Auth HTTP responses
|
||||
* (including its own 4xx error responses) pass through unmodified; only a genuinely unexpected
|
||||
* failure is converted to a sanitized `IdentityRuntimeError`. */
|
||||
handleAuthRequest(request: Request): Promise<Response>;
|
||||
/** Resolves the caller's Ekklesia principal from request headers. Returns the anonymous
|
||||
* principal for a missing, expired, revoked, or malformed session — never for an infrastructure
|
||||
* failure, which instead throws a sanitized `IdentityRuntimeError`. */
|
||||
resolvePrincipal(headers: Headers): Promise<EkklesiaPrincipal>;
|
||||
}
|
||||
|
||||
async function withTimeout<T>(operation: Promise<T>, timeoutMs: number): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
operation,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new Error("timed out")), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function probeIdentityDatabase(db: IdentityDrizzleDatabase): Promise<boolean> {
|
||||
try {
|
||||
await withTimeout(db.execute(sql`select 1`), identityDatabaseProbeTimeoutMs);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function constructBetterAuth<T>(build: () => T): T {
|
||||
try {
|
||||
return build();
|
||||
} catch {
|
||||
throw new IdentityRuntimeError("IDENTITY_CONSTRUCTION_FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Core's Better Auth composition boundary and Fetch-compatible identity transport. Constructs
|
||||
* Better Auth explicitly from injected, already-validated configuration and an injected Drizzle
|
||||
* database — it never reads environment variables and enables no provider or plugin beyond the
|
||||
* four core models and email/password sign-up/sign-in required for this slice.
|
||||
*/
|
||||
export function createIdentityRuntime(
|
||||
options: CreateIdentityRuntimeOptions,
|
||||
): EkklesiaIdentityRuntime {
|
||||
const rateLimitOverride = options.rateLimitOverride;
|
||||
|
||||
const auth = constructBetterAuth(() =>
|
||||
betterAuth({
|
||||
baseURL: options.config.baseUrl,
|
||||
database: drizzleAdapter(options.db, {
|
||||
provider: "pg",
|
||||
schema: identityAdapterSchema,
|
||||
}),
|
||||
emailAndPassword: {
|
||||
autoSignIn: false,
|
||||
disableSignUp: false,
|
||||
enabled: 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],
|
||||
}),
|
||||
);
|
||||
|
||||
return Object.freeze({
|
||||
handleAuthRequest: async (request: Request): Promise<Response> => {
|
||||
try {
|
||||
return await auth.handler(request);
|
||||
} catch {
|
||||
throw new IdentityRuntimeError("AUTH_REQUEST_FAILED");
|
||||
}
|
||||
},
|
||||
resolvePrincipal: async (headers: Headers): Promise<EkklesiaPrincipal> => {
|
||||
let session: Awaited<ReturnType<typeof auth.api.getSession>>;
|
||||
try {
|
||||
session = await auth.api.getSession({ headers });
|
||||
} catch {
|
||||
// Defense in depth: the pinned Better Auth release already catches internal failures and
|
||||
// resolves to null instead of throwing here, but a future release is not guaranteed to.
|
||||
throw new IdentityRuntimeError("PRINCIPAL_RESOLUTION_FAILED");
|
||||
}
|
||||
|
||||
const userId = session?.user?.id;
|
||||
if (typeof userId === "string") {
|
||||
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) {
|
||||
throw new IdentityRuntimeError("PRINCIPAL_RESOLUTION_FAILED");
|
||||
}
|
||||
}
|
||||
return ANONYMOUS_PRINCIPAL;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./diagnostics/index.js";
|
||||
export * from "./identity/index.js";
|
||||
export * from "./modules/index.js";
|
||||
export * from "./migrations/index.js";
|
||||
|
||||
@@ -37,7 +37,7 @@ describe("Core identity Drizzle database composition", () => {
|
||||
|
||||
it("explicitly constructs the pool only when called, with the identity schema attached", () => {
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: async () => undefined };
|
||||
return { end: async () => undefined, on: vi.fn() };
|
||||
});
|
||||
|
||||
const handle = createIdentityDrizzleDatabase(
|
||||
@@ -52,7 +52,7 @@ describe("Core identity Drizzle database composition", () => {
|
||||
|
||||
it("passes the connection timeout through to the pool configuration", () => {
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: async () => undefined };
|
||||
return { end: async () => undefined, on: vi.fn() };
|
||||
});
|
||||
poolConstructor.mockClear();
|
||||
|
||||
@@ -64,4 +64,21 @@ describe("Core identity Drizzle database composition", () => {
|
||||
expect.objectContaining({ connectionTimeoutMillis: 5_000 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("registers a pool-level error listener so an idle client failure never becomes an uncaught exception", () => {
|
||||
const on = vi.fn();
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: async () => undefined, on };
|
||||
});
|
||||
|
||||
createIdentityDrizzleDatabase("postgresql://example.test/ekklesia");
|
||||
|
||||
expect(on).toHaveBeenCalledWith("error", expect.any(Function));
|
||||
const handler = on.mock.calls.find((call) => call[0] === "error")?.[1] as
|
||||
| ((error: unknown) => void)
|
||||
| undefined;
|
||||
expect(() =>
|
||||
handler?.(new Error("terminating connection due to administrator command")),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const betterAuthMock = vi.hoisted(() => vi.fn());
|
||||
const drizzleAdapterMock = vi.hoisted(() =>
|
||||
vi.fn((db: unknown, options: unknown) => ({ __adapterOptions: options, db })),
|
||||
);
|
||||
|
||||
vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
|
||||
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
|
||||
|
||||
import type { IdentityConfig } from "../src/identity/config.js";
|
||||
import { createIdentityAuth, IdentityConstructionError } from "../src/identity/factory.js";
|
||||
import { identityAdapterSchema } from "../src/identity/schema.js";
|
||||
|
||||
function validConfig(): IdentityConfig {
|
||||
return Object.freeze({
|
||||
baseUrl: "https://identity.example.test",
|
||||
secret: "correct-horse-battery-staple-0123456789",
|
||||
trustedOrigins: Object.freeze(["https://identity.example.test"]),
|
||||
});
|
||||
}
|
||||
|
||||
describe("Core identity Better Auth construction boundary", () => {
|
||||
beforeEach(() => {
|
||||
betterAuthMock.mockReset();
|
||||
drizzleAdapterMock.mockClear();
|
||||
});
|
||||
|
||||
it("constructs explicitly with narrow injected dependencies and returns only a success marker", () => {
|
||||
betterAuthMock.mockReturnValue({ api: {}, handler: () => undefined });
|
||||
const fakeDb = { __fakeDb: true };
|
||||
|
||||
const result = createIdentityAuth({ config: validConfig(), db: fakeDb as never });
|
||||
|
||||
expect(result).toEqual({ constructed: true });
|
||||
expect(Object.keys(result)).toEqual(["constructed"]);
|
||||
expect(Object.isFrozen(result)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not expose the raw database, adapter, or Better Auth instance on the returned value", () => {
|
||||
betterAuthMock.mockReturnValue({ api: {}, handler: () => undefined });
|
||||
const fakeDb = { __fakeDb: true };
|
||||
|
||||
const result = createIdentityAuth({ config: validConfig(), db: fakeDb as never });
|
||||
|
||||
expect(JSON.stringify(result)).not.toContain("__fakeDb");
|
||||
expect(JSON.stringify(result)).not.toContain("handler");
|
||||
});
|
||||
|
||||
it("passes the explicit ekklesia_core_* schema mapping to the Drizzle adapter", () => {
|
||||
betterAuthMock.mockReturnValue({});
|
||||
const fakeDb = { __fakeDb: true };
|
||||
|
||||
createIdentityAuth({ config: validConfig(), db: fakeDb as never });
|
||||
|
||||
expect(drizzleAdapterMock).toHaveBeenCalledTimes(1);
|
||||
const [db, options] = drizzleAdapterMock.mock.calls[0] as [unknown, unknown];
|
||||
expect(db).toBe(fakeDb);
|
||||
expect(options).toEqual({ provider: "pg", schema: identityAdapterSchema });
|
||||
});
|
||||
|
||||
it("enables no provider or plugin beyond the four core models", () => {
|
||||
betterAuthMock.mockReturnValue({});
|
||||
|
||||
createIdentityAuth({ config: validConfig(), db: {} as never });
|
||||
|
||||
expect(betterAuthMock).toHaveBeenCalledTimes(1);
|
||||
const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>];
|
||||
expect(Object.keys(options).sort()).toEqual([
|
||||
"baseURL",
|
||||
"database",
|
||||
"secret",
|
||||
"trustedOrigins",
|
||||
]);
|
||||
expect(options.baseURL).toBe(validConfig().baseUrl);
|
||||
expect(options.secret).toBe(validConfig().secret);
|
||||
expect(options.trustedOrigins).toEqual([...validConfig().trustedOrigins]);
|
||||
});
|
||||
|
||||
it("sanitizes a construction failure without leaking the underlying error, secret, or URL", () => {
|
||||
const hostileSecret = validConfig().secret;
|
||||
betterAuthMock.mockImplementation(() => {
|
||||
throw new Error(
|
||||
`Better Auth failed for secret ${hostileSecret} at https://internal.example.test`,
|
||||
);
|
||||
});
|
||||
|
||||
let captured: unknown;
|
||||
try {
|
||||
createIdentityAuth({ config: validConfig(), db: {} as never });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
expect(captured).toBeInstanceOf(IdentityConstructionError);
|
||||
expect(captured).toMatchObject({ code: "IDENTITY_CONSTRUCTION_FAILED" });
|
||||
const publicText = `${String(captured)}\n${
|
||||
captured instanceof Error ? (captured.stack ?? "") : ""
|
||||
}`;
|
||||
expect(publicText).not.toContain(hostileSecret);
|
||||
expect(publicText).not.toContain("internal.example.test");
|
||||
});
|
||||
|
||||
it("sanitizes an adapter construction failure the same way", () => {
|
||||
drizzleAdapterMock.mockImplementation(() => {
|
||||
throw new Error("adapter exploded with postgresql://admin:[email protected]/ekklesia");
|
||||
});
|
||||
|
||||
let captured: unknown;
|
||||
try {
|
||||
createIdentityAuth({ config: validConfig(), db: {} as never });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
expect(captured).toBeInstanceOf(IdentityConstructionError);
|
||||
const publicText = `${String(captured)}\n${
|
||||
captured instanceof Error ? (captured.stack ?? "") : ""
|
||||
}`;
|
||||
expect(publicText).not.toContain("db.internal");
|
||||
expect(publicText).not.toContain("admin");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const betterAuthMock = vi.hoisted(() => vi.fn());
|
||||
const drizzleAdapterMock = vi.hoisted(() =>
|
||||
vi.fn((db: unknown, options: unknown) => ({ __adapterOptions: options, db })),
|
||||
);
|
||||
|
||||
vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
|
||||
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
|
||||
|
||||
import type { IdentityConfig } from "../src/identity/config.js";
|
||||
import { createIdentityRuntime, IdentityRuntimeError } from "../src/identity/runtime.js";
|
||||
import { identityAdapterSchema } from "../src/identity/schema.js";
|
||||
|
||||
function validConfig(): IdentityConfig {
|
||||
return Object.freeze({
|
||||
baseUrl: "https://identity.example.test",
|
||||
secret: "correct-horse-battery-staple-0123456789",
|
||||
trustedOrigins: Object.freeze(["https://identity.example.test"]),
|
||||
});
|
||||
}
|
||||
|
||||
function fakeDb(overrides: Partial<{ execute: ReturnType<typeof vi.fn> }> = {}) {
|
||||
return { __fakeDb: true, execute: vi.fn().mockResolvedValue(undefined), ...overrides } as never;
|
||||
}
|
||||
|
||||
describe("Core identity runtime construction boundary", () => {
|
||||
beforeEach(() => {
|
||||
betterAuthMock.mockReset();
|
||||
drizzleAdapterMock.mockClear();
|
||||
});
|
||||
|
||||
it("does not construct Better Auth or the Drizzle adapter merely because the module is imported", () => {
|
||||
expect(betterAuthMock).not.toHaveBeenCalled();
|
||||
expect(drizzleAdapterMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("constructs explicitly and returns only the two narrow transport capabilities", () => {
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
|
||||
const runtime = createIdentityRuntime({ config: validConfig(), db: fakeDb() });
|
||||
|
||||
expect(Object.keys(runtime).sort()).toEqual(["handleAuthRequest", "resolvePrincipal"]);
|
||||
expect(typeof runtime.handleAuthRequest).toBe("function");
|
||||
expect(typeof runtime.resolvePrincipal).toBe("function");
|
||||
expect(Object.isFrozen(runtime)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not expose the raw database, adapter, or Better Auth instance on the returned value", () => {
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
const db = fakeDb();
|
||||
|
||||
const runtime = createIdentityRuntime({ config: validConfig(), db });
|
||||
|
||||
expect(JSON.stringify(runtime)).toBe("{}");
|
||||
});
|
||||
|
||||
it("passes the explicit ekklesia_core_* schema mapping to the Drizzle adapter", () => {
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
const db = fakeDb();
|
||||
|
||||
createIdentityRuntime({ config: validConfig(), db });
|
||||
|
||||
expect(drizzleAdapterMock).toHaveBeenCalledTimes(1);
|
||||
const [passedDb, options] = drizzleAdapterMock.mock.calls[0] as [unknown, unknown];
|
||||
expect(passedDb).toBe(db);
|
||||
expect(options).toEqual({ provider: "pg", schema: identityAdapterSchema });
|
||||
});
|
||||
|
||||
it("enables exactly email/password (autoSignIn disabled) and rate limiting, nothing else", () => {
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
|
||||
createIdentityRuntime({ config: validConfig(), db: fakeDb() });
|
||||
|
||||
expect(betterAuthMock).toHaveBeenCalledTimes(1);
|
||||
const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>];
|
||||
expect(Object.keys(options).sort()).toEqual([
|
||||
"baseURL",
|
||||
"database",
|
||||
"emailAndPassword",
|
||||
"rateLimit",
|
||||
"secret",
|
||||
"trustedOrigins",
|
||||
]);
|
||||
expect(options.emailAndPassword).toEqual({
|
||||
autoSignIn: false,
|
||||
disableSignUp: false,
|
||||
enabled: true,
|
||||
});
|
||||
expect(options.rateLimit).toEqual({ enabled: true, max: 100, window: 10 });
|
||||
expect(options.baseURL).toBe(validConfig().baseUrl);
|
||||
expect(options.secret).toBe(validConfig().secret);
|
||||
expect(options.trustedOrigins).toEqual([...validConfig().trustedOrigins]);
|
||||
});
|
||||
|
||||
it("only widens the sign-in/sign-up rate-limit window when a narrow override is explicitly injected", () => {
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
|
||||
createIdentityRuntime({
|
||||
config: validConfig(),
|
||||
db: fakeDb(),
|
||||
rateLimitOverride: { max: 1000, window: 1 },
|
||||
});
|
||||
|
||||
const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>];
|
||||
expect(options.rateLimit).toEqual({
|
||||
customRules: {
|
||||
"/sign-in/email": { max: 1000, window: 1 },
|
||||
"/sign-up/email": { max: 1000, window: 1 },
|
||||
},
|
||||
enabled: true,
|
||||
max: 100,
|
||||
window: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it("sanitizes a construction failure without leaking the underlying error, secret, or URL", () => {
|
||||
const hostileSecret = validConfig().secret;
|
||||
betterAuthMock.mockImplementation(() => {
|
||||
throw new Error(
|
||||
`Better Auth failed for secret ${hostileSecret} at https://internal.example.test`,
|
||||
);
|
||||
});
|
||||
|
||||
let captured: unknown;
|
||||
try {
|
||||
createIdentityRuntime({ config: validConfig(), db: fakeDb() });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
expect(captured).toBeInstanceOf(IdentityRuntimeError);
|
||||
expect(captured).toMatchObject({ code: "IDENTITY_CONSTRUCTION_FAILED" });
|
||||
const publicText = `${String(captured)}\n${
|
||||
captured instanceof Error ? (captured.stack ?? "") : ""
|
||||
}`;
|
||||
expect(publicText).not.toContain(hostileSecret);
|
||||
expect(publicText).not.toContain("internal.example.test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Core identity runtime — handleAuthRequest", () => {
|
||||
beforeEach(() => {
|
||||
betterAuthMock.mockReset();
|
||||
drizzleAdapterMock.mockClear();
|
||||
});
|
||||
|
||||
it("delegates directly to Better Auth's Fetch handler and passes the response through unmodified", async () => {
|
||||
const expectedResponse = new Response("ok", { status: 200 });
|
||||
const handler = vi.fn().mockResolvedValue(expectedResponse);
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler });
|
||||
|
||||
const runtime = createIdentityRuntime({ config: validConfig(), db: fakeDb() });
|
||||
const request = new Request("https://identity.example.test/api/auth/sign-in/email");
|
||||
const response = await runtime.handleAuthRequest(request);
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(request);
|
||||
expect(response).toBe(expectedResponse);
|
||||
});
|
||||
|
||||
it("passes through Better Auth's own error responses (e.g. 401, 403, 422) unmodified", async () => {
|
||||
const errorResponse = new Response(JSON.stringify({ code: "INVALID_EMAIL_OR_PASSWORD" }), {
|
||||
status: 401,
|
||||
});
|
||||
const handler = vi.fn().mockResolvedValue(errorResponse);
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler });
|
||||
|
||||
const runtime = createIdentityRuntime({ config: validConfig(), db: fakeDb() });
|
||||
const response = await runtime.handleAuthRequest(
|
||||
new Request("https://identity.example.test/x"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("sanitizes a genuinely unexpected handler failure", async () => {
|
||||
const handler = vi.fn().mockRejectedValue(new Error("postgresql://admin:[email protected]/x"));
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler });
|
||||
|
||||
const runtime = createIdentityRuntime({ config: validConfig(), db: fakeDb() });
|
||||
|
||||
let captured: unknown;
|
||||
try {
|
||||
await runtime.handleAuthRequest(new Request("https://identity.example.test/x"));
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
expect(captured).toBeInstanceOf(IdentityRuntimeError);
|
||||
expect(captured).toMatchObject({ code: "AUTH_REQUEST_FAILED" });
|
||||
const publicText = `${String(captured)}\n${
|
||||
captured instanceof Error ? (captured.stack ?? "") : ""
|
||||
}`;
|
||||
expect(publicText).not.toContain("db.internal");
|
||||
expect(publicText).not.toContain("admin");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Core identity runtime — resolvePrincipal", () => {
|
||||
beforeEach(() => {
|
||||
betterAuthMock.mockReset();
|
||||
drizzleAdapterMock.mockClear();
|
||||
});
|
||||
|
||||
it("maps a valid session to an authenticated principal containing only the opaque user id", 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 runtime = createIdentityRuntime({ config: validConfig(), db: fakeDb() });
|
||||
const principal = await runtime.resolvePrincipal(
|
||||
new Headers({ cookie: "better-auth.session_token=tok" }),
|
||||
);
|
||||
|
||||
expect(principal).toEqual({ kind: "authenticated", userId: "user_123" });
|
||||
expect(Object.keys(principal)).toEqual(["kind", "userId"]);
|
||||
expect(Object.isFrozen(principal)).toBe(true);
|
||||
});
|
||||
|
||||
it("maps a missing session with no cookie header to anonymous without probing the database", 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());
|
||||
|
||||
expect(principal).toEqual({ kind: "anonymous" });
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps an expired/revoked/malformed session (cookie present, null session) to anonymous when the database is reachable", 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({ cookie: "better-auth.session_token=stale-or-garbage" }),
|
||||
);
|
||||
|
||||
expect(principal).toEqual({ kind: "anonymous" });
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("never downgrades an infrastructure failure to anonymous: throws when the database is unreachable", 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;
|
||||
}
|
||||
|
||||
expect(captured).toBeInstanceOf(IdentityRuntimeError);
|
||||
expect(captured).toMatchObject({ code: "PRINCIPAL_RESOLUTION_FAILED" });
|
||||
});
|
||||
|
||||
it("treats an unexpected throw from getSession itself as a sanitized infrastructure failure", async () => {
|
||||
const hostile = "password=hunter2 host=db.internal.example port=5432";
|
||||
const getSession = vi.fn().mockRejectedValue(new Error(hostile));
|
||||
betterAuthMock.mockReturnValue({ api: { getSession }, handler: vi.fn() });
|
||||
|
||||
const runtime = createIdentityRuntime({ config: validConfig(), db: fakeDb() });
|
||||
|
||||
let captured: unknown;
|
||||
try {
|
||||
await runtime.resolvePrincipal(new Headers({ cookie: "better-auth.session_token=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 ?? "") : ""
|
||||
}`;
|
||||
expect(publicText).not.toContain("hunter2");
|
||||
expect(publicText).not.toContain("db.internal.example");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user