import { oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata } from "@better-auth/oauth-provider"; import { isAPIError } from "better-auth/api"; import { Hono } from "hono"; import { handleUpdateHandle } from "@/worker/api/account"; import { handleListConnectedApps, handleRevokeConnectedApp } from "@/worker/api/connected-apps"; import { handleListLauncherApps } from "@/worker/api/launcher"; import { handleCreateClient, handleDeleteClient, handleListClients, handleRotateClientSecret, handleUpdateClient, } from "@/worker/api/admin/clients"; import { handleCreateInvite, handleListInvites, handleRevokeInvite } from "@/worker/api/admin/invites"; import { handleCreateLauncherApp, handleDeleteLauncherApp, handleListLauncherApps as handleListLauncherAppsAdmin, handleUpdateLauncherApp, } from "@/worker/api/admin/launcher-apps"; import { handleBanUser } from "@/worker/api/admin/users"; import { handleConfig } from "@/worker/api/config"; import { handleInviteAccept, handleInviteLookup } from "@/worker/api/invites"; import { handleSignIn } from "@/worker/api/sign-in"; import { handleSignInSocial } from "@/worker/api/sign-in-social"; import { makeAuth } from "@/worker/auth"; import { resolveBaseUrl, resolveIssuer } from "@/worker/config"; import { makeDb } from "@/worker/db"; import type { AppBindings } from "@/worker/hono"; import { HttpError } from "@/worker/http"; import { createLogger, errorContext, parseLogLevel } from "@/worker/logger"; import { adminRouteAllowlist } from "@/worker/middleware/admin-allowlist"; import { requireAdmin, requireUser } from "@/worker/middleware/auth"; import { securityHeaders } from "@/worker/middleware/headers"; import { requireSameOriginForMutations } from "@/worker/middleware/origin"; import { rateLimitAuthSurface } from "@/worker/middleware/rate-limit"; import { stripTrailingSlash } from "@/worker/services/url"; const app = new Hono(); app.use("*", securityHeaders); app.use("*", async (c, next) => { // The logger sits at the top of the middleware so any subsequent // construction failure (e.g. makeAuth's issuer/baseURL origin // invariant) reaches onError with `c.var.log` and `c.var.logLevel` // already populated; otherwise the error handler dereferences // undefined and the worker returns a bare 500 with no structured // log line. const logLevel = parseLogLevel(c.env.LOG_LEVEL); c.set("logLevel", logLevel); c.set("log", createLogger(logLevel)); const baseURL = resolveBaseUrl(c.env, c.req.url); const issuer = resolveIssuer(c.env, c.req.url); c.set("baseURL", baseURL); c.set("issuer", issuer); c.set("db", makeDb(c.env)); c.set("auth", makeAuth(c.env, { baseURL, issuer })); await next(); }); app.get("/healthz", (c) => c.json({ ok: true, service: "tessera" })); app.get("/api/config", handleConfig); const accountApi = new Hono(); // Origin runs before requireUser so an unauthenticated foreign-origin POST // short-circuits at 403 before any session lookup runs. accountApi.use("*", requireSameOriginForMutations); accountApi.use("*", requireUser); // Self-service updates to fields not covered by Better Auth's update-user // endpoint (preferredUsername is gated by `input: false` so client-supplied // values can't bypass our slug validation). accountApi.post("/handle", handleUpdateHandle); // User-facing list of OAuth clients the user has consented to. // First-party `skip_consent` clients never appear here (they bypass the // consent endpoint entirely) — those live in the app launcher instead. accountApi.get("/connected-apps", handleListConnectedApps); accountApi.delete("/connected-apps/:id", handleRevokeConnectedApp); const adminApi = new Hono(); adminApi.use("*", requireSameOriginForMutations); adminApi.use("*", requireAdmin); adminApi.get("/invites", handleListInvites); adminApi.post("/invites", handleCreateInvite); adminApi.delete("/invites/:id", handleRevokeInvite); adminApi.get("/clients", handleListClients); adminApi.post("/clients", handleCreateClient); adminApi.patch("/clients/:id", handleUpdateClient); adminApi.post("/clients/:id/rotate", handleRotateClientSecret); adminApi.delete("/clients/:id", handleDeleteClient); adminApi.get("/launcher-apps", handleListLauncherAppsAdmin); adminApi.post("/launcher-apps", handleCreateLauncherApp); adminApi.patch("/launcher-apps/:id", handleUpdateLauncherApp); adminApi.delete("/launcher-apps/:id", handleDeleteLauncherApp); // Custom Turnstile-gated + rate-limited sign-in. The frontend POSTs here // rather than directly to /api/auth/sign-in/email so the captcha and rate // limit run before Better Auth touches the credential. app.post("/api/sign-in", requireSameOriginForMutations, handleSignIn); // Social sign-in wrapper enforces the same Turnstile + rate-limit // boundary as the email path before delegating to Better Auth's // /api/auth/sign-in/social. app.post("/api/sign-in/social", requireSameOriginForMutations, handleSignInSocial); app.get("/api/invite/:token", handleInviteLookup); app.post("/api/invite/:token", requireSameOriginForMutations, handleInviteAccept); // Launcher tiles for the signed-in landing page. Reads from // `launcher_apps`. app.get("/api/launcher", requireUser, handleListLauncherApps); app.route("/api/account", accountApi); app.route("/api/admin", adminApi); // Block raw Better Auth endpoints that tessera intentionally re-fronts // or does not expose: // // - sign-in/email, sign-up/email, sign-in/social: tessera owns each // ingress through `/api/sign-in`, `/api/sign-in/social`, and the // invite flow so Turnstile + rate-limit run before any credential or // provider initiation reaches Better Auth. Reaching the raw paths // bypasses Turnstile and the invite-only invariant. // - token: Better Auth's jwt() plugin mints an RS256 JWT signed with // the OIDC ID-token key for any authenticated user. Downstream // verifiers that trust tessera's JWKS by issuer alone could confuse // such a token with a provider-issued one. // - oauth2/{create,update,delete}-client and oauth2/client/rotate-secret: // OAuth client management goes through tessera's `/api/admin/clients` // wrapper so rotation/delete run the token-cleanup batch and every // mutation lands in tessera's structured logs. The raw routes bypass // that wrapper. // - oauth2/{delete,update}-consent: user-facing revocation goes through // `DELETE /api/account/connected-apps/:id` so consent removal runs // atomically with `oauth_access_tokens` / `oauth_refresh_tokens` // cleanup. The raw plugin endpoints delete only the consent row // leaving already-issued tokens valid until expiry while removing the // UI affordance that would clean them up. // // Hono does exact-string path matching on `app.all`, so an exact stub // at `/api/auth/sign-in/email` would miss `/api/auth/sign-in/email/` // and fall through to the `/api/auth/*` catchall — Better Auth's // router normalizes the trailing slash and dispatches to the same // plugin endpoint. Use middleware that strips trailing slashes before // comparing against the blocked set so all variants 404 uniformly. // Server-side `auth.api.*` calls used by tessera's wrappers still work; // this only closes the HTTP surface. const BLOCKED_AUTH_PATHS = new Set([ "/api/auth/sign-in/email", "/api/auth/sign-up/email", "/api/auth/sign-in/social", "/api/auth/token", "/api/auth/oauth2/create-client", "/api/auth/oauth2/update-client", "/api/auth/oauth2/delete-client", "/api/auth/oauth2/client/rotate-secret", "/api/auth/oauth2/delete-consent", "/api/auth/oauth2/update-consent", ]); app.use("/api/auth/*", async (c, next) => { if (BLOCKED_AUTH_PATHS.has(stripTrailingSlash(c.req.path))) { return new Response("Not Found", { status: 404 }); } return next(); }); // tessera-owned ban handler runs before the catchall so OAuth-token // cleanup happens atomically with the ban flag flip. Better Auth's own // /admin/ban-user only deletes sessions; an unmodified passthrough leaves // oauth_access_tokens / oauth_refresh_tokens rows behind and defeats the // kill-switch. Origin runs before requireAdmin per the same ordering as // the route groups above. app.post("/api/auth/admin/ban-user", requireSameOriginForMutations, requireAdmin, handleBanUser); // Allowlist gate every OTHER /api/auth/admin/* route. tessera does not use // create-user, set-user-password, impersonate-user, remove-user, or // session-revocation routes; allowing them would let an admin bypass // the invite-only invariant or take destructive actions that the UI // never surfaces. app.use("/api/auth/admin/*", adminRouteAllowlist); // Rate limit every /api/auth/* path before it reaches Better Auth's // catchall. Mounted after `BLOCKED_AUTH_PATHS` and `adminRouteAllowlist` // so denied paths 404 without burning budget. Better Auth's own // rate limiter is intentionally disabled in `makeAuth`. app.use("/api/auth/*", rateLimitAuthSurface); app.on(["GET", "POST"], "/api/auth/*", async (c) => { return c.var.auth.handler(c.req.raw); }); // The oauth-provider plugin emits OIDC discovery / OAuth server metadata // on the auth instance's `api.*` rather than on a public Better Auth route, // so we mount the well-known endpoints at the issuer root manually. app.get("/.well-known/openid-configuration", (c) => oauthProviderOpenIdConfigMetadata(c.var.auth)(c.req.raw)); app.get("/.well-known/oauth-authorization-server", (c) => oauthProviderAuthServerMetadata(c.var.auth)(c.req.raw)); app.onError((err, c) => { if (err instanceof HttpError) { return err.toResponse(); } // Server-side `auth.api.*` calls (used by tessera wrappers like // `loadSession`, `handleBanUser`, the admin client handlers) bypass // Better Auth's HTTP error handler, so an APIError thrown inside one // of them — including the BANNED_USER predicate in `hooks.before` — // escapes to here. Reshape it as our HttpError so the client gets // the correct 4xx with a structured body instead of a generic 500. // Raw `/api/auth/*` paths handle their own APIError responses inside // `c.var.auth.handler` and never reach this branch. if (isAPIError(err)) { // OAuth-shaped APIError bodies (tessera's own throws plus Better Auth's // oauth-provider plugin internals) carry RFC 6749 `error` and // `error_description`. Spread the body through HttpError's `detail` arg — // toResponse spreads detail after the synthesized fields, so an OAuth // body's `error` overrides the synthesized one and RPs see the spec // values. Non-OAuth APIErrors (banned-user etc.) keep today's // `{error: , message}` shape unchanged. const body = err.body ?? {}; return new HttpError( err.statusCode, body.code ?? body.error ?? "auth_error", body.message ?? body.error_description ?? "Authentication error.", body.error || body.error_description ? body : undefined, ).toResponse(); } c.var.log.child({ component: "worker" }).error("unhandled_error", { method: c.req.method, path: c.req.path, ...errorContext(err, c.var.logLevel), }); return c.json({ error: "internal_error", message: "Unexpected server error." }, 500); }); export default app;