Skip to content
File

Blob: src/worker/auth/index.ts

typescript427 lines
1import { createAuthMiddleware } from "@better-auth/core/api";
2import { drizzleAdapter } from "@better-auth/drizzle-adapter";
3import { oauthProvider } from "@better-auth/oauth-provider";
4import { APIError, betterAuth } from "better-auth";
5import { getSessionFromCtx } from "better-auth/api";
6import { admin, jwt } from "better-auth/plugins";
7import { defaultRoles } from "better-auth/plugins/admin/access";
8import { sql } from "drizzle-orm";
9 
10import { hasAdminRole } from "@/shared/role";
11import { hashArgon2id, verifyArgon2id } from "@/worker/auth/argon2";
12import { type BannableUser, isActiveBan } from "@/worker/auth/ban";
13import { makeDb } from "@/worker/db";
14import { users } from "@/worker/db/schema";
15 
16interface AdminBody {
17 userId?: string;
18 role?: string;
19}
20 
21const TESSERA_CLIENT_REFERENCE = "tessera";
22 
23export type Auth = ReturnType<typeof makeAuth>;
24 
25export interface AuthOverrides {
26 baseURL: string;
27 issuer: string;
28}
29 
30// OIDC `preferred_username`: a slug-safe handle. Spec says RPs MUST NOT rely
31// on it being unique, so we don't enforce uniqueness — derive a stable value
32// from the user's name (or email local-part) and let collisions resolve at
33// the RP level (e.g. git-on-cloudflare's `:owner` URL segment).
34const slugifyHandle = (s: string): string =>
35 s
36 .toLowerCase()
37 .normalize("NFKD")
38 .replace(/[^a-z0-9]+/g, "-")
39 .replace(/^-+|-+$/g, "");
40 
41const handleForUser = (u: { name?: string | null; email: string; preferredUsername?: string | null }): string => {
42 const explicit = u.preferredUsername?.trim();
43 if (explicit) return explicit;
44 if (u.name?.trim()) {
45 const fromName = slugifyHandle(u.name);
46 if (fromName) return fromName;
47 }
48 const local = u.email.split("@")[0] ?? "";
49 return slugifyHandle(local) || "user";
50};
51 
52// Single point of truth for the FORBIDDEN response shape used by
53// every Better Auth extension point that loads a user object —
54// `hooks.before` (cookie sessions), `customTokenResponseFields` (token
55// issuance), `customAccessTokenClaims` (token validation, gates
56// /oauth2/userinfo + /oauth2/introspect), and `customUserInfoClaims`
57// (defense-in-depth at the userinfo claim layer). Keeps the kill-switch
58// error code consistent across surfaces.
59// Accepts the minimal ban fields because callers hand us different Better
60// Auth user shapes: cookie sessions, OAuth hook users, and token-validation
61// users all include the same D1 ban columns even when their exported types
62// disagree about the full user object.
63const assertNotBanned = (user: BannableUser | null | undefined): void => {
64 if (user && isActiveBan(user)) {
65 throw new APIError("FORBIDDEN", {
66 code: "BANNED_USER",
67 message: "Account is banned.",
68 });
69 }
70};
71 
72export const makeAuth = (env: Env, overrides: AuthOverrides) => {
73 // BETTER_AUTH_SECRET is the master key for session HMAC, JWKS private
74 // bytes, and OAuth provider token wrapping. OPERATOR.md mandates 64
75 // hex chars (`openssl rand -hex 32`); enforce that here so a missing
76 // or malformed binding fails closed at the worker boundary instead of
77 // falling through to Better Auth's permissive default.
78 const secret = env.BETTER_AUTH_SECRET?.trim() ?? "";
79 if (!/^[0-9a-f]{64}$/i.test(secret)) {
80 throw new Error("BETTER_AUTH_SECRET must be 64 hex characters (`openssl rand -hex 32`).");
81 }
82 // OIDC discovery advertises the issuer; the JWKS endpoint and Better
83 // Auth cookie domain derive from baseURL. A misconfigured pair would
84 // hand RPs an issuer URL whose JWKS lives on a different host. Fail
85 // loudly at boot rather than letting RPs fetch from a host that does
86 // not serve them.
87 if (overrides.issuer && overrides.baseURL) {
88 const issuerOrigin = new URL(overrides.issuer).origin;
89 const baseOrigin = new URL(overrides.baseURL).origin;
90 if (issuerOrigin !== baseOrigin) {
91 throw new Error(
92 `OIDC issuer (${issuerOrigin}) and baseURL (${baseOrigin}) must share an origin; tessera serves JWKS at baseURL/api/auth/jwks.`,
93 );
94 }
95 }
96 const db = makeDb(env);
97 const bootstrapAdminEmail = env.BOOTSTRAP_ADMIN_EMAIL?.trim().toLowerCase();
98 return betterAuth({
99 experimental: {
100 joins: true,
101 },
102 secret,
103 baseURL: overrides.baseURL,
104 database: drizzleAdapter(db, { provider: "sqlite", usePlural: true }),
105 // Rate limiting is owned by tessera, not Better Auth. The
106 // `rateLimitAuthSurface` middleware in
107 // src/worker/middleware/rate-limit.ts gates every /api/auth/* path
108 // through the RL_AUTH / RL_API Cloudflare bindings; tessera-owned
109 // wrappers (sign-in, sign-in/social, invite) do their own
110 // enforcement before calling Better Auth. Better Auth's built-in
111 // limiter is also disabled-by-default on Workers because
112 // `process.env.NODE_ENV` is undefined; this assignment makes the
113 // intent explicit and self-documenting rather than relying on the
114 // silent default.
115 rateLimit: { enabled: false },
116 advanced: {
117 database: {
118 // OIDC `sub` claim is required to be opaque, stable, and never an
119 // email. UUID v4 is mandated by the phase-1 design doc; without this
120 // override Better Auth defaults to nanoid, which is technically
121 // spec-compliant but breaks our cross-app contract with anvil and
122 // ccccocc that already read `payload.sub` as a UUID.
123 generateId: () => crypto.randomUUID(),
124 },
125 ipAddress: { ipAddressHeaders: ["cf-connecting-ip"] },
126 },
127 onAPIError: {
128 // Redirect API errors to our React `/auth-error` page instead of
129 // Better Auth's built-in HTML error page. The plugin appends `error`
130 // and `error_description` query params automatically.
131 errorURL: "/auth-error",
132 },
133 hooks: {
134 // Hook covers three cross-boundary invariants:
135 // 1. Admin self-demote via /admin/set-role. Without this gate an
136 // admin can lock themselves out by demoting their own session.
137 // 2. Stateless JWT access tokens via /oauth2/token `resource`. The
138 // OAuth provider's resource-indicator path mints JWT access
139 // tokens that survive ban cleanup, which only deletes D1 token
140 // rows. Reject `resource` because tessera does not implement a
141 // stateless revocation/introspection layer; the ban kill-switch
142 // assumes every issued token has a D1 row. Keep this restriction
143 // even with Better Auth 1.7's resource-indicator hardening.
144 // 3. Ban kill-switch on every cookie-session path. Better Auth
145 // enforces bans only at session-create time (admin plugin's
146 // databaseHooks.session.create.before); session reads, OAuth
147 // provider routes, and OIDC token paths skip the predicate.
148 // getSessionFromCtx returns null for anonymous routes (no
149 // cookie) so jwks / well-known / sign-in initiation pass
150 // through unchanged. Bearer-token surfaces (/oauth2/userinfo,
151 // /oauth2/introspect) gate via customAccessTokenClaims; new
152 // token issuance gates via customTokenResponseFields.
153 before: createAuthMiddleware(async (ctx) => {
154 if (ctx.path === "/oauth2/token") {
155 const body = ctx.body as { resource?: unknown } | undefined;
156 const resource = body?.resource;
157 const hasResource =
158 (typeof resource === "string" && resource.length > 0) || (Array.isArray(resource) && resource.length > 0);
159 if (hasResource) {
160 throw new APIError("BAD_REQUEST", {
161 error: "invalid_request",
162 error_description: "Resource indicators are not supported by this OAuth provider.",
163 code: "RESOURCE_NOT_SUPPORTED",
164 message: "Resource indicators are not supported.",
165 });
166 }
167 return;
168 }
169 
170 // getSessionFromCtx parses the cookie via the same path the session
171 // middleware uses; calling internalAdapter.findSession with the raw
172 // Cookie header returns null because findSession expects a bare
173 // token. Cookie cache is not enabled in tessera, so each call
174 // reads fresh `banned` / `banExpires` columns from D1. The
175 // generic param narrows the otherwise loose helper user shape to
176 // the ban fields this hook actually reads.
177 const session = await getSessionFromCtx<BannableUser>(ctx).catch(() => null);
178 assertNotBanned(session?.user);
179 
180 if (ctx.path !== "/admin/set-role") return;
181 
182 const sessionUserId = session?.user.id;
183 const targetUserId = (ctx.body as AdminBody | undefined)?.userId;
184 if (!sessionUserId || !targetUserId || targetUserId !== sessionUserId) return;
185 
186 // Better Auth's set-role body schema accepts `role: string | string[]`;
187 // normalize so an admin POSTing `{ role: ["user"] }` against their own
188 // user id still hits the self-demote guard.
189 const rawRole = (ctx.body as AdminBody).role;
190 const roles = Array.isArray(rawRole) ? rawRole : rawRole !== undefined ? [rawRole] : [];
191 if (!roles.includes("admin")) {
192 throw new APIError("FORBIDDEN", {
193 code: "CANNOT_SELF_DEMOTE",
194 message: "You can't demote yourself. Promote another admin first.",
195 });
196 }
197 }),
198 },
199 databaseHooks: {
200 user: {
201 create: {
202 before: async (newUser) => {
203 // Stamp a stable `preferredUsername` slug at creation so the
204 // OIDC `preferred_username` claim is consistent across sessions
205 // even if the user later edits their display name. Mark
206 // `emailVerified: true` because every path into this hook is
207 // gated on email ownership — invite signups round-trip an
208 // invite link sent to the address, and the bootstrap operator
209 // signs up directly on the same machine that holds the secret.
210 const stamped = {
211 ...newUser,
212 preferredUsername: handleForUser(newUser),
213 emailVerified: true,
214 };
215 // Promote the bootstrap operator only on the very first signup.
216 // The admin plugin's own create.before hook spreads the user
217 // object after its `role: defaultRole` default, so our
218 // `role: "admin"` survives the merge. The userCount gate makes
219 // a stale BOOTSTRAP_ADMIN_EMAIL safe to leave configured —
220 // re-creating the operator account or accepting a new invite
221 // for that email will not silently mint another admin.
222 if (bootstrapAdminEmail && newUser.email.toLowerCase() === bootstrapAdminEmail) {
223 const [{ count: existingUsers }] = await db.select({ count: sql<number>`count(*)` }).from(users);
224 if (existingUsers === 0) {
225 return { data: { ...stamped, role: "admin" } };
226 }
227 }
228 return { data: stamped };
229 },
230 },
231 },
232 },
233 user: {
234 additionalFields: {
235 // `preferred_username` claim source. Slug-safe handle (e.g.
236 // `rachel-chen`) derived from `name` (or email local-part) at user
237 // creation; users can edit it later via the account page.
238 preferredUsername: { type: "string", required: false, input: false },
239 },
240 },
241 emailAndPassword: {
242 enabled: true,
243 revokeSessionsOnPasswordReset: true,
244 autoSignIn: true,
245 // Server-side floor matching the UI's 12-character minimum on
246 // /sign-in and /account/password. Better Auth defaults to 8.
247 minPasswordLength: 12,
248 password: {
249 hash: hashArgon2id,
250 verify: verifyArgon2id,
251 },
252 },
253 socialProviders: {
254 github: env.GITHUB_OAUTH_CLIENT_ID
255 ? {
256 clientId: env.GITHUB_OAUTH_CLIENT_ID,
257 clientSecret: env.GITHUB_OAUTH_CLIENT_SECRET,
258 // tessera registration is invite-only. `disableSignUp` blocks
259 // signup unconditionally; `disableImplicitSignUp` alone is
260 // bypassable by a caller setting `requestSignUp: true` in the
261 // sign-in body.
262 disableImplicitSignUp: true,
263 disableSignUp: true,
264 }
265 : undefined,
266 google: env.GOOGLE_OAUTH_CLIENT_ID
267 ? {
268 clientId: env.GOOGLE_OAUTH_CLIENT_ID,
269 clientSecret: env.GOOGLE_OAUTH_CLIENT_SECRET,
270 disableImplicitSignUp: true,
271 disableSignUp: true,
272 }
273 : undefined,
274 },
275 account: {
276 // Wraps GitHub/Google access/refresh/id tokens with BETTER_AUTH_SECRET
277 // before they hit D1. Better Auth's `isLikelyEncrypted` read heuristic
278 // lets plaintext rows decode without rewrap; writes are always encrypted.
279 encryptOAuthTokens: true,
280 accountLinking: {
281 enabled: true,
282 // tessera registration is invite-only — never auto-create from a
283 // social-provider sign-in for an unknown email. Users must sign in
284 // via email+password first to link a social identity.
285 trustedProviders: [],
286 allowDifferentEmails: false,
287 // Block Better Auth's implicit "verified email matches existing
288 // user → silently link" path. Account linking must be initiated
289 // explicitly from the signed-in /account page.
290 disableImplicitLinking: true,
291 },
292 },
293 plugins: [
294 // Pinning the role set restricts /admin/set-role to "user" and
295 // "admin"; arbitrary role strings ("Admin", "moderator") get
296 // rejected by Better Auth's plugin before reaching tessera state.
297 admin({ roles: defaultRoles }),
298 jwt({
299 jwks: {
300 keyPairConfig: { alg: "RS256", modulusLength: 2048 },
301 // Private key bytes stay encrypted at rest by the plugin (default).
302 // Resolves design-doc Open Question #6: no extra AES-GCM wrap needed
303 // for the jwks table — Better Auth handles it.
304 rotationInterval: 60 * 60 * 24 * 30,
305 },
306 jwt: {
307 issuer: overrides.issuer,
308 },
309 // The plugin's after-hook on /get-session signs an RS256 JWT of the
310 // session payload and writes it to a `set-auth-jwt` response header
311 // that nothing in tessera reads. The OIDC provider plugin owns ID
312 // token issuance via the same JWKS, so no caller depends on this
313 // surface. Per the plugin's own type docs: recommended when paired
314 // with an OAuth provider plugin where session payloads should not
315 // be signed.
316 disableSettingJwtHeader: true,
317 }),
318 oauthProvider({
319 loginPage: "/sign-in",
320 consentPage: "/oauth/consent",
321 // Dynamic client registration is admin-gated; the public OAuth 2.0
322 // /oauth2/register endpoint stays disabled. Operators register new RPs
323 // via the admin UI (`/admin/clients`) which calls Better Auth's
324 // server-side create-client API directly.
325 allowDynamicClientRegistration: false,
326 allowUnauthenticatedClientRegistration: false,
327 allowPublicClientPrelogin: true,
328 scopes: ["openid", "profile", "email"],
329 // Authorization-code only. Excluding `client_credentials` blocks
330 // confidential clients from minting non-user bearer tokens via
331 // their secret. Excluding `refresh_token` keeps tessera's token
332 // surface to the documented SSO flow; RPs re-auth via the session
333 // cookie. Add `refresh_token` here only if a specific RP needs
334 // offline access.
335 grantTypes: ["authorization_code"],
336 // Gate every OAuth client management endpoint on admin role.
337 // Without this, /api/auth/oauth2/create-client is reachable by any
338 // signed-in user (the route only requires sessionMiddleware).
339 // `user` is typed optional but is always defined at the callsite —
340 // assertClientPrivileges throws UNAUTHORIZED before invoking us if
341 // there is no session. Treat absence as a deny anyway.
342 clientPrivileges: async ({ user }) => hasAdminRole(user?.role),
343 // Put every tessera-managed client in a shared reference namespace
344 // so multi-admin operations work; otherwise Better Auth's per-row
345 // ownership check falls back to userId equality and an admin can
346 // only manage clients they personally created. A constant value
347 // works because tessera does not have multi-tenant client ownership.
348 clientReference: () => TESSERA_CLIENT_REFERENCE,
349 advertisedMetadata: {
350 claims_supported: [
351 "sub",
352 "iss",
353 "aud",
354 "exp",
355 "iat",
356 "name",
357 "given_name",
358 "family_name",
359 "preferred_username",
360 "picture",
361 "email",
362 "email_verified",
363 // tessera_sub mirrors `sub` (user.id) so apps reached via
364 // Cloudflare Access — which replaces the standard `sub` with
365 // its own user id and exposes upstream OIDC claims under
366 // `custom` — can still key on tessera's stable UUID.
367 "tessera_sub",
368 ],
369 },
370 // tessera_sub: emitted unconditionally — it is the Access-preserved
371 // equivalent of the OIDC subject, not a profile attribute, so gating
372 // it behind the `profile` scope would defeat the purpose. Same UUID
373 // already exposed as `sub`, same audience, same `openid` gate.
374 // preferred_username is OIDC profile-scope; handleForUser derives
375 // a value when the column is null.
376 customIdTokenClaims: ({ user, scopes }) => {
377 const claims: Record<string, unknown> = { tessera_sub: user.id };
378 // Better Auth 1.7 no longer adds these scope claims to ID tokens.
379 // Preserve tessera's existing contract with its relying parties.
380 if (scopes.includes("profile")) {
381 const name = user.name?.split(" ") ?? [];
382 claims.name = user.name;
383 claims.picture = user.image ?? undefined;
384 claims.given_name = name.length > 1 ? name.slice(0, -1).join(" ") : undefined;
385 claims.family_name = name.length > 1 ? name.at(-1) : undefined;
386 claims.preferred_username = handleForUser(user);
387 }
388 if (scopes.includes("email")) {
389 claims.email = user.email;
390 claims.email_verified = user.emailVerified ?? false;
391 }
392 return claims;
393 },
394 customUserInfoClaims: ({ user, scopes }) => {
395 // Defense-in-depth: customAccessTokenClaims throws upstream
396 // during userinfo (via validateOpaqueAccessToken). This second
397 // gate stays fail-closed if a future plugin refactor changes
398 // the call order.
399 assertNotBanned(user);
400 const claims: Record<string, unknown> = { tessera_sub: user.id };
401 if (scopes.includes("profile")) {
402 claims.preferred_username = handleForUser(user);
403 }
404 return claims;
405 },
406 // Ban kill-switch on token issuance. Runs before token rows are
407 // created, so a banned user with a valid authorization code can't
408 // mint new tokens. tessera issues opaque access tokens and
409 // customAccessTokenClaims is not called on the opaque-issuance
410 // path; this is the mint-time gate.
411 customTokenResponseFields: ({ user }) => {
412 assertNotBanned(user);
413 return {};
414 },
415 // Ban kill-switch on opaque token validation. This gates
416 // /oauth2/userinfo and /oauth2/introspect so a banned user
417 // holding a still-unexpired access token fails closed at both
418 // endpoints.
419 customAccessTokenClaims: ({ user }) => {
420 assertNotBanned(user);
421 return {};
422 },
423 }),
424 ],
425 });
426};