import { createAuthMiddleware } from "@better-auth/core/api"; import { drizzleAdapter } from "@better-auth/drizzle-adapter"; import { oauthProvider } from "@better-auth/oauth-provider"; import { APIError, betterAuth } from "better-auth"; import { getSessionFromCtx } from "better-auth/api"; import { admin, jwt } from "better-auth/plugins"; import { defaultRoles } from "better-auth/plugins/admin/access"; import { sql } from "drizzle-orm"; import { hasAdminRole } from "@/shared/role"; import { hashArgon2id, verifyArgon2id } from "@/worker/auth/argon2"; import { type BannableUser, isActiveBan } from "@/worker/auth/ban"; import { makeDb } from "@/worker/db"; import { users } from "@/worker/db/schema"; interface AdminBody { userId?: string; role?: string; } const TESSERA_CLIENT_REFERENCE = "tessera"; export type Auth = ReturnType; export interface AuthOverrides { baseURL: string; issuer: string; } // OIDC `preferred_username`: a slug-safe handle. Spec says RPs MUST NOT rely // on it being unique, so we don't enforce uniqueness — derive a stable value // from the user's name (or email local-part) and let collisions resolve at // the RP level (e.g. git-on-cloudflare's `:owner` URL segment). const slugifyHandle = (s: string): string => s .toLowerCase() .normalize("NFKD") .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); const handleForUser = (u: { name?: string | null; email: string; preferredUsername?: string | null }): string => { const explicit = u.preferredUsername?.trim(); if (explicit) return explicit; if (u.name?.trim()) { const fromName = slugifyHandle(u.name); if (fromName) return fromName; } const local = u.email.split("@")[0] ?? ""; return slugifyHandle(local) || "user"; }; // Single point of truth for the FORBIDDEN response shape used by // every Better Auth extension point that loads a user object — // `hooks.before` (cookie sessions), `customTokenResponseFields` (token // issuance), `customAccessTokenClaims` (token validation, gates // /oauth2/userinfo + /oauth2/introspect), and `customUserInfoClaims` // (defense-in-depth at the userinfo claim layer). Keeps the kill-switch // error code consistent across surfaces. // Accepts the minimal ban fields because callers hand us different Better // Auth user shapes: cookie sessions, OAuth hook users, and token-validation // users all include the same D1 ban columns even when their exported types // disagree about the full user object. const assertNotBanned = (user: BannableUser | null | undefined): void => { if (user && isActiveBan(user)) { throw new APIError("FORBIDDEN", { code: "BANNED_USER", message: "Account is banned.", }); } }; export const makeAuth = (env: Env, overrides: AuthOverrides) => { // BETTER_AUTH_SECRET is the master key for session HMAC, JWKS private // bytes, and OAuth provider token wrapping. OPERATOR.md mandates 64 // hex chars (`openssl rand -hex 32`); enforce that here so a missing // or malformed binding fails closed at the worker boundary instead of // falling through to Better Auth's permissive default. const secret = env.BETTER_AUTH_SECRET?.trim() ?? ""; if (!/^[0-9a-f]{64}$/i.test(secret)) { throw new Error("BETTER_AUTH_SECRET must be 64 hex characters (`openssl rand -hex 32`)."); } // OIDC discovery advertises the issuer; the JWKS endpoint and Better // Auth cookie domain derive from baseURL. A misconfigured pair would // hand RPs an issuer URL whose JWKS lives on a different host. Fail // loudly at boot rather than letting RPs fetch from a host that does // not serve them. if (overrides.issuer && overrides.baseURL) { const issuerOrigin = new URL(overrides.issuer).origin; const baseOrigin = new URL(overrides.baseURL).origin; if (issuerOrigin !== baseOrigin) { throw new Error( `OIDC issuer (${issuerOrigin}) and baseURL (${baseOrigin}) must share an origin; tessera serves JWKS at baseURL/api/auth/jwks.`, ); } } const db = makeDb(env); const bootstrapAdminEmail = env.BOOTSTRAP_ADMIN_EMAIL?.trim().toLowerCase(); return betterAuth({ experimental: { joins: true, }, secret, baseURL: overrides.baseURL, database: drizzleAdapter(db, { provider: "sqlite", usePlural: true }), // Rate limiting is owned by tessera, not Better Auth. The // `rateLimitAuthSurface` middleware in // src/worker/middleware/rate-limit.ts gates every /api/auth/* path // through the RL_AUTH / RL_API Cloudflare bindings; tessera-owned // wrappers (sign-in, sign-in/social, invite) do their own // enforcement before calling Better Auth. Better Auth's built-in // limiter is also disabled-by-default on Workers because // `process.env.NODE_ENV` is undefined; this assignment makes the // intent explicit and self-documenting rather than relying on the // silent default. rateLimit: { enabled: false }, advanced: { database: { // OIDC `sub` claim is required to be opaque, stable, and never an // email. UUID v4 is mandated by the phase-1 design doc; without this // override Better Auth defaults to nanoid, which is technically // spec-compliant but breaks our cross-app contract with anvil and // ccccocc that already read `payload.sub` as a UUID. generateId: () => crypto.randomUUID(), }, ipAddress: { ipAddressHeaders: ["cf-connecting-ip"] }, }, onAPIError: { // Redirect API errors to our React `/auth-error` page instead of // Better Auth's built-in HTML error page. The plugin appends `error` // and `error_description` query params automatically. errorURL: "/auth-error", }, hooks: { // Hook covers three cross-boundary invariants: // 1. Admin self-demote via /admin/set-role. Without this gate an // admin can lock themselves out by demoting their own session. // 2. Stateless JWT access tokens via /oauth2/token `resource`. The // OAuth provider's resource-indicator path mints JWT access // tokens that survive ban cleanup, which only deletes D1 token // rows. Reject `resource` because tessera does not implement a // stateless revocation/introspection layer; the ban kill-switch // assumes every issued token has a D1 row. Keep this restriction // even with Better Auth 1.7's resource-indicator hardening. // 3. Ban kill-switch on every cookie-session path. Better Auth // enforces bans only at session-create time (admin plugin's // databaseHooks.session.create.before); session reads, OAuth // provider routes, and OIDC token paths skip the predicate. // getSessionFromCtx returns null for anonymous routes (no // cookie) so jwks / well-known / sign-in initiation pass // through unchanged. Bearer-token surfaces (/oauth2/userinfo, // /oauth2/introspect) gate via customAccessTokenClaims; new // token issuance gates via customTokenResponseFields. before: createAuthMiddleware(async (ctx) => { if (ctx.path === "/oauth2/token") { const body = ctx.body as { resource?: unknown } | undefined; const resource = body?.resource; const hasResource = (typeof resource === "string" && resource.length > 0) || (Array.isArray(resource) && resource.length > 0); if (hasResource) { throw new APIError("BAD_REQUEST", { error: "invalid_request", error_description: "Resource indicators are not supported by this OAuth provider.", code: "RESOURCE_NOT_SUPPORTED", message: "Resource indicators are not supported.", }); } return; } // getSessionFromCtx parses the cookie via the same path the session // middleware uses; calling internalAdapter.findSession with the raw // Cookie header returns null because findSession expects a bare // token. Cookie cache is not enabled in tessera, so each call // reads fresh `banned` / `banExpires` columns from D1. The // generic param narrows the otherwise loose helper user shape to // the ban fields this hook actually reads. const session = await getSessionFromCtx(ctx).catch(() => null); assertNotBanned(session?.user); if (ctx.path !== "/admin/set-role") return; const sessionUserId = session?.user.id; const targetUserId = (ctx.body as AdminBody | undefined)?.userId; if (!sessionUserId || !targetUserId || targetUserId !== sessionUserId) return; // Better Auth's set-role body schema accepts `role: string | string[]`; // normalize so an admin POSTing `{ role: ["user"] }` against their own // user id still hits the self-demote guard. const rawRole = (ctx.body as AdminBody).role; const roles = Array.isArray(rawRole) ? rawRole : rawRole !== undefined ? [rawRole] : []; if (!roles.includes("admin")) { throw new APIError("FORBIDDEN", { code: "CANNOT_SELF_DEMOTE", message: "You can't demote yourself. Promote another admin first.", }); } }), }, databaseHooks: { user: { create: { before: async (newUser) => { // Stamp a stable `preferredUsername` slug at creation so the // OIDC `preferred_username` claim is consistent across sessions // even if the user later edits their display name. Mark // `emailVerified: true` because every path into this hook is // gated on email ownership — invite signups round-trip an // invite link sent to the address, and the bootstrap operator // signs up directly on the same machine that holds the secret. const stamped = { ...newUser, preferredUsername: handleForUser(newUser), emailVerified: true, }; // Promote the bootstrap operator only on the very first signup. // The admin plugin's own create.before hook spreads the user // object after its `role: defaultRole` default, so our // `role: "admin"` survives the merge. The userCount gate makes // a stale BOOTSTRAP_ADMIN_EMAIL safe to leave configured — // re-creating the operator account or accepting a new invite // for that email will not silently mint another admin. if (bootstrapAdminEmail && newUser.email.toLowerCase() === bootstrapAdminEmail) { const [{ count: existingUsers }] = await db.select({ count: sql`count(*)` }).from(users); if (existingUsers === 0) { return { data: { ...stamped, role: "admin" } }; } } return { data: stamped }; }, }, }, }, user: { additionalFields: { // `preferred_username` claim source. Slug-safe handle (e.g. // `rachel-chen`) derived from `name` (or email local-part) at user // creation; users can edit it later via the account page. preferredUsername: { type: "string", required: false, input: false }, }, }, emailAndPassword: { enabled: true, revokeSessionsOnPasswordReset: true, autoSignIn: true, // Server-side floor matching the UI's 12-character minimum on // /sign-in and /account/password. Better Auth defaults to 8. minPasswordLength: 12, password: { hash: hashArgon2id, verify: verifyArgon2id, }, }, socialProviders: { github: env.GITHUB_OAUTH_CLIENT_ID ? { clientId: env.GITHUB_OAUTH_CLIENT_ID, clientSecret: env.GITHUB_OAUTH_CLIENT_SECRET, // tessera registration is invite-only. `disableSignUp` blocks // signup unconditionally; `disableImplicitSignUp` alone is // bypassable by a caller setting `requestSignUp: true` in the // sign-in body. disableImplicitSignUp: true, disableSignUp: true, } : undefined, google: env.GOOGLE_OAUTH_CLIENT_ID ? { clientId: env.GOOGLE_OAUTH_CLIENT_ID, clientSecret: env.GOOGLE_OAUTH_CLIENT_SECRET, disableImplicitSignUp: true, disableSignUp: true, } : undefined, }, account: { // Wraps GitHub/Google access/refresh/id tokens with BETTER_AUTH_SECRET // before they hit D1. Better Auth's `isLikelyEncrypted` read heuristic // lets plaintext rows decode without rewrap; writes are always encrypted. encryptOAuthTokens: true, accountLinking: { enabled: true, // tessera registration is invite-only — never auto-create from a // social-provider sign-in for an unknown email. Users must sign in // via email+password first to link a social identity. trustedProviders: [], allowDifferentEmails: false, // Block Better Auth's implicit "verified email matches existing // user → silently link" path. Account linking must be initiated // explicitly from the signed-in /account page. disableImplicitLinking: true, }, }, plugins: [ // Pinning the role set restricts /admin/set-role to "user" and // "admin"; arbitrary role strings ("Admin", "moderator") get // rejected by Better Auth's plugin before reaching tessera state. admin({ roles: defaultRoles }), jwt({ jwks: { keyPairConfig: { alg: "RS256", modulusLength: 2048 }, // Private key bytes stay encrypted at rest by the plugin (default). // Resolves design-doc Open Question #6: no extra AES-GCM wrap needed // for the jwks table — Better Auth handles it. rotationInterval: 60 * 60 * 24 * 30, }, jwt: { issuer: overrides.issuer, }, // The plugin's after-hook on /get-session signs an RS256 JWT of the // session payload and writes it to a `set-auth-jwt` response header // that nothing in tessera reads. The OIDC provider plugin owns ID // token issuance via the same JWKS, so no caller depends on this // surface. Per the plugin's own type docs: recommended when paired // with an OAuth provider plugin where session payloads should not // be signed. disableSettingJwtHeader: true, }), oauthProvider({ loginPage: "/sign-in", consentPage: "/oauth/consent", // Dynamic client registration is admin-gated; the public OAuth 2.0 // /oauth2/register endpoint stays disabled. Operators register new RPs // via the admin UI (`/admin/clients`) which calls Better Auth's // server-side create-client API directly. allowDynamicClientRegistration: false, allowUnauthenticatedClientRegistration: false, allowPublicClientPrelogin: true, scopes: ["openid", "profile", "email"], // Authorization-code only. Excluding `client_credentials` blocks // confidential clients from minting non-user bearer tokens via // their secret. Excluding `refresh_token` keeps tessera's token // surface to the documented SSO flow; RPs re-auth via the session // cookie. Add `refresh_token` here only if a specific RP needs // offline access. grantTypes: ["authorization_code"], // Gate every OAuth client management endpoint on admin role. // Without this, /api/auth/oauth2/create-client is reachable by any // signed-in user (the route only requires sessionMiddleware). // `user` is typed optional but is always defined at the callsite — // assertClientPrivileges throws UNAUTHORIZED before invoking us if // there is no session. Treat absence as a deny anyway. clientPrivileges: async ({ user }) => hasAdminRole(user?.role), // Put every tessera-managed client in a shared reference namespace // so multi-admin operations work; otherwise Better Auth's per-row // ownership check falls back to userId equality and an admin can // only manage clients they personally created. A constant value // works because tessera does not have multi-tenant client ownership. clientReference: () => TESSERA_CLIENT_REFERENCE, advertisedMetadata: { claims_supported: [ "sub", "iss", "aud", "exp", "iat", "name", "given_name", "family_name", "preferred_username", "picture", "email", "email_verified", // tessera_sub mirrors `sub` (user.id) so apps reached via // Cloudflare Access — which replaces the standard `sub` with // its own user id and exposes upstream OIDC claims under // `custom` — can still key on tessera's stable UUID. "tessera_sub", ], }, // tessera_sub: emitted unconditionally — it is the Access-preserved // equivalent of the OIDC subject, not a profile attribute, so gating // it behind the `profile` scope would defeat the purpose. Same UUID // already exposed as `sub`, same audience, same `openid` gate. // preferred_username is OIDC profile-scope; handleForUser derives // a value when the column is null. customIdTokenClaims: ({ user, scopes }) => { const claims: Record = { tessera_sub: user.id }; // Better Auth 1.7 no longer adds these scope claims to ID tokens. // Preserve tessera's existing contract with its relying parties. if (scopes.includes("profile")) { const name = user.name?.split(" ") ?? []; claims.name = user.name; claims.picture = user.image ?? undefined; claims.given_name = name.length > 1 ? name.slice(0, -1).join(" ") : undefined; claims.family_name = name.length > 1 ? name.at(-1) : undefined; claims.preferred_username = handleForUser(user); } if (scopes.includes("email")) { claims.email = user.email; claims.email_verified = user.emailVerified ?? false; } return claims; }, customUserInfoClaims: ({ user, scopes }) => { // Defense-in-depth: customAccessTokenClaims throws upstream // during userinfo (via validateOpaqueAccessToken). This second // gate stays fail-closed if a future plugin refactor changes // the call order. assertNotBanned(user); const claims: Record = { tessera_sub: user.id }; if (scopes.includes("profile")) { claims.preferred_username = handleForUser(user); } return claims; }, // Ban kill-switch on token issuance. Runs before token rows are // created, so a banned user with a valid authorization code can't // mint new tokens. tessera issues opaque access tokens and // customAccessTokenClaims is not called on the opaque-issuance // path; this is the mint-time gate. customTokenResponseFields: ({ user }) => { assertNotBanned(user); return {}; }, // Ban kill-switch on opaque token validation. This gates // /oauth2/userinfo and /oauth2/introspect so a banned user // holding a still-unexpired access token fails closed at both // endpoints. customAccessTokenClaims: ({ user }) => { assertNotBanned(user); return {}; }, }), ], }); };