188 lines
6.8 KiB
TypeScript
188 lines
6.8 KiB
TypeScript
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;
|
|
},
|
|
});
|
|
}
|