File
Blob: src/worker/index.ts
| 1 | import { oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata } from "@better-auth/oauth-provider"; |
| 2 | import { isAPIError } from "better-auth/api"; |
| 3 | import { Hono } from "hono"; |
| 4 | |
| 5 | import { handleUpdateHandle } from "@/worker/api/account"; |
| 6 | import { handleListConnectedApps, handleRevokeConnectedApp } from "@/worker/api/connected-apps"; |
| 7 | import { handleListLauncherApps } from "@/worker/api/launcher"; |
| 8 | import { |
| 9 | handleCreateClient, |
| 10 | handleDeleteClient, |
| 11 | handleListClients, |
| 12 | handleRotateClientSecret, |
| 13 | handleUpdateClient, |
| 14 | } from "@/worker/api/admin/clients"; |
| 15 | import { handleCreateInvite, handleListInvites, handleRevokeInvite } from "@/worker/api/admin/invites"; |
| 16 | import { |
| 17 | handleCreateLauncherApp, |
| 18 | handleDeleteLauncherApp, |
| 19 | handleListLauncherApps as handleListLauncherAppsAdmin, |
| 20 | handleUpdateLauncherApp, |
| 21 | } from "@/worker/api/admin/launcher-apps"; |
| 22 | import { handleBanUser } from "@/worker/api/admin/users"; |
| 23 | import { handleConfig } from "@/worker/api/config"; |
| 24 | import { handleInviteAccept, handleInviteLookup } from "@/worker/api/invites"; |
| 25 | import { handleSignIn } from "@/worker/api/sign-in"; |
| 26 | import { handleSignInSocial } from "@/worker/api/sign-in-social"; |
| 27 | import { makeAuth } from "@/worker/auth"; |
| 28 | import { resolveBaseUrl, resolveIssuer } from "@/worker/config"; |
| 29 | import { makeDb } from "@/worker/db"; |
| 30 | import type { AppBindings } from "@/worker/hono"; |
| 31 | import { HttpError } from "@/worker/http"; |
| 32 | import { createLogger, errorContext, parseLogLevel } from "@/worker/logger"; |
| 33 | import { adminRouteAllowlist } from "@/worker/middleware/admin-allowlist"; |
| 34 | import { requireAdmin, requireUser } from "@/worker/middleware/auth"; |
| 35 | import { securityHeaders } from "@/worker/middleware/headers"; |
| 36 | import { requireSameOriginForMutations } from "@/worker/middleware/origin"; |
| 37 | import { rateLimitAuthSurface } from "@/worker/middleware/rate-limit"; |
| 38 | import { stripTrailingSlash } from "@/worker/services/url"; |
| 39 | |
| 40 | const app = new Hono<AppBindings>(); |
| 41 | |
| 42 | app.use("*", securityHeaders); |
| 43 | app.use("*", async (c, next) => { |
| 44 | // The logger sits at the top of the middleware so any subsequent |
| 45 | // construction failure (e.g. makeAuth's issuer/baseURL origin |
| 46 | // invariant) reaches onError with `c.var.log` and `c.var.logLevel` |
| 47 | // already populated; otherwise the error handler dereferences |
| 48 | // undefined and the worker returns a bare 500 with no structured |
| 49 | // log line. |
| 50 | const logLevel = parseLogLevel(c.env.LOG_LEVEL); |
| 51 | c.set("logLevel", logLevel); |
| 52 | c.set("log", createLogger(logLevel)); |
| 53 | const baseURL = resolveBaseUrl(c.env, c.req.url); |
| 54 | const issuer = resolveIssuer(c.env, c.req.url); |
| 55 | c.set("baseURL", baseURL); |
| 56 | c.set("issuer", issuer); |
| 57 | c.set("db", makeDb(c.env)); |
| 58 | c.set("auth", makeAuth(c.env, { baseURL, issuer })); |
| 59 | await next(); |
| 60 | }); |
| 61 | |
| 62 | app.get("/healthz", (c) => c.json({ ok: true, service: "tessera" })); |
| 63 | |
| 64 | app.get("/api/config", handleConfig); |
| 65 | |
| 66 | const accountApi = new Hono<AppBindings>(); |
| 67 | // Origin runs before requireUser so an unauthenticated foreign-origin POST |
| 68 | // short-circuits at 403 before any session lookup runs. |
| 69 | accountApi.use("*", requireSameOriginForMutations); |
| 70 | accountApi.use("*", requireUser); |
| 71 | |
| 72 | // Self-service updates to fields not covered by Better Auth's update-user |
| 73 | // endpoint (preferredUsername is gated by `input: false` so client-supplied |
| 74 | // values can't bypass our slug validation). |
| 75 | accountApi.post("/handle", handleUpdateHandle); |
| 76 | |
| 77 | // User-facing list of OAuth clients the user has consented to. |
| 78 | // First-party `skip_consent` clients never appear here (they bypass the |
| 79 | // consent endpoint entirely) — those live in the app launcher instead. |
| 80 | accountApi.get("/connected-apps", handleListConnectedApps); |
| 81 | accountApi.delete("/connected-apps/:id", handleRevokeConnectedApp); |
| 82 | |
| 83 | const adminApi = new Hono<AppBindings>(); |
| 84 | adminApi.use("*", requireSameOriginForMutations); |
| 85 | adminApi.use("*", requireAdmin); |
| 86 | adminApi.get("/invites", handleListInvites); |
| 87 | adminApi.post("/invites", handleCreateInvite); |
| 88 | adminApi.delete("/invites/:id", handleRevokeInvite); |
| 89 | adminApi.get("/clients", handleListClients); |
| 90 | adminApi.post("/clients", handleCreateClient); |
| 91 | adminApi.patch("/clients/:id", handleUpdateClient); |
| 92 | adminApi.post("/clients/:id/rotate", handleRotateClientSecret); |
| 93 | adminApi.delete("/clients/:id", handleDeleteClient); |
| 94 | adminApi.get("/launcher-apps", handleListLauncherAppsAdmin); |
| 95 | adminApi.post("/launcher-apps", handleCreateLauncherApp); |
| 96 | adminApi.patch("/launcher-apps/:id", handleUpdateLauncherApp); |
| 97 | adminApi.delete("/launcher-apps/:id", handleDeleteLauncherApp); |
| 98 | |
| 99 | // Custom Turnstile-gated + rate-limited sign-in. The frontend POSTs here |
| 100 | // rather than directly to /api/auth/sign-in/email so the captcha and rate |
| 101 | // limit run before Better Auth touches the credential. |
| 102 | app.post("/api/sign-in", requireSameOriginForMutations, handleSignIn); |
| 103 | // Social sign-in wrapper enforces the same Turnstile + rate-limit |
| 104 | // boundary as the email path before delegating to Better Auth's |
| 105 | // /api/auth/sign-in/social. |
| 106 | app.post("/api/sign-in/social", requireSameOriginForMutations, handleSignInSocial); |
| 107 | app.get("/api/invite/:token", handleInviteLookup); |
| 108 | app.post("/api/invite/:token", requireSameOriginForMutations, handleInviteAccept); |
| 109 | |
| 110 | // Launcher tiles for the signed-in landing page. Reads from |
| 111 | // `launcher_apps`. |
| 112 | app.get("/api/launcher", requireUser, handleListLauncherApps); |
| 113 | |
| 114 | app.route("/api/account", accountApi); |
| 115 | app.route("/api/admin", adminApi); |
| 116 | |
| 117 | // Block raw Better Auth endpoints that tessera intentionally re-fronts |
| 118 | // or does not expose: |
| 119 | // |
| 120 | // - sign-in/email, sign-up/email, sign-in/social: tessera owns each |
| 121 | // ingress through `/api/sign-in`, `/api/sign-in/social`, and the |
| 122 | // invite flow so Turnstile + rate-limit run before any credential or |
| 123 | // provider initiation reaches Better Auth. Reaching the raw paths |
| 124 | // bypasses Turnstile and the invite-only invariant. |
| 125 | // - token: Better Auth's jwt() plugin mints an RS256 JWT signed with |
| 126 | // the OIDC ID-token key for any authenticated user. Downstream |
| 127 | // verifiers that trust tessera's JWKS by issuer alone could confuse |
| 128 | // such a token with a provider-issued one. |
| 129 | // - oauth2/{create,update,delete}-client and oauth2/client/rotate-secret: |
| 130 | // OAuth client management goes through tessera's `/api/admin/clients` |
| 131 | // wrapper so rotation/delete run the token-cleanup batch and every |
| 132 | // mutation lands in tessera's structured logs. The raw routes bypass |
| 133 | // that wrapper. |
| 134 | // - oauth2/{delete,update}-consent: user-facing revocation goes through |
| 135 | // `DELETE /api/account/connected-apps/:id` so consent removal runs |
| 136 | // atomically with `oauth_access_tokens` / `oauth_refresh_tokens` |
| 137 | // cleanup. The raw plugin endpoints delete only the consent row |
| 138 | // leaving already-issued tokens valid until expiry while removing the |
| 139 | // UI affordance that would clean them up. |
| 140 | // |
| 141 | // Hono does exact-string path matching on `app.all`, so an exact stub |
| 142 | // at `/api/auth/sign-in/email` would miss `/api/auth/sign-in/email/` |
| 143 | // and fall through to the `/api/auth/*` catchall — Better Auth's |
| 144 | // router normalizes the trailing slash and dispatches to the same |
| 145 | // plugin endpoint. Use middleware that strips trailing slashes before |
| 146 | // comparing against the blocked set so all variants 404 uniformly. |
| 147 | // Server-side `auth.api.*` calls used by tessera's wrappers still work; |
| 148 | // this only closes the HTTP surface. |
| 149 | const BLOCKED_AUTH_PATHS = new Set([ |
| 150 | "/api/auth/sign-in/email", |
| 151 | "/api/auth/sign-up/email", |
| 152 | "/api/auth/sign-in/social", |
| 153 | "/api/auth/token", |
| 154 | "/api/auth/oauth2/create-client", |
| 155 | "/api/auth/oauth2/update-client", |
| 156 | "/api/auth/oauth2/delete-client", |
| 157 | "/api/auth/oauth2/client/rotate-secret", |
| 158 | "/api/auth/oauth2/delete-consent", |
| 159 | "/api/auth/oauth2/update-consent", |
| 160 | ]); |
| 161 | app.use("/api/auth/*", async (c, next) => { |
| 162 | if (BLOCKED_AUTH_PATHS.has(stripTrailingSlash(c.req.path))) { |
| 163 | return new Response("Not Found", { status: 404 }); |
| 164 | } |
| 165 | return next(); |
| 166 | }); |
| 167 | |
| 168 | // tessera-owned ban handler runs before the catchall so OAuth-token |
| 169 | // cleanup happens atomically with the ban flag flip. Better Auth's own |
| 170 | // /admin/ban-user only deletes sessions; an unmodified passthrough leaves |
| 171 | // oauth_access_tokens / oauth_refresh_tokens rows behind and defeats the |
| 172 | // kill-switch. Origin runs before requireAdmin per the same ordering as |
| 173 | // the route groups above. |
| 174 | app.post("/api/auth/admin/ban-user", requireSameOriginForMutations, requireAdmin, handleBanUser); |
| 175 | |
| 176 | // Allowlist gate every OTHER /api/auth/admin/* route. tessera does not use |
| 177 | // create-user, set-user-password, impersonate-user, remove-user, or |
| 178 | // session-revocation routes; allowing them would let an admin bypass |
| 179 | // the invite-only invariant or take destructive actions that the UI |
| 180 | // never surfaces. |
| 181 | app.use("/api/auth/admin/*", adminRouteAllowlist); |
| 182 | |
| 183 | // Rate limit every /api/auth/* path before it reaches Better Auth's |
| 184 | // catchall. Mounted after `BLOCKED_AUTH_PATHS` and `adminRouteAllowlist` |
| 185 | // so denied paths 404 without burning budget. Better Auth's own |
| 186 | // rate limiter is intentionally disabled in `makeAuth`. |
| 187 | app.use("/api/auth/*", rateLimitAuthSurface); |
| 188 | |
| 189 | app.on(["GET", "POST"], "/api/auth/*", async (c) => { |
| 190 | return c.var.auth.handler(c.req.raw); |
| 191 | }); |
| 192 | |
| 193 | // The oauth-provider plugin emits OIDC discovery / OAuth server metadata |
| 194 | // on the auth instance's `api.*` rather than on a public Better Auth route, |
| 195 | // so we mount the well-known endpoints at the issuer root manually. |
| 196 | app.get("/.well-known/openid-configuration", (c) => oauthProviderOpenIdConfigMetadata(c.var.auth)(c.req.raw)); |
| 197 | app.get("/.well-known/oauth-authorization-server", (c) => oauthProviderAuthServerMetadata(c.var.auth)(c.req.raw)); |
| 198 | |
| 199 | app.onError((err, c) => { |
| 200 | if (err instanceof HttpError) { |
| 201 | return err.toResponse(); |
| 202 | } |
| 203 | // Server-side `auth.api.*` calls (used by tessera wrappers like |
| 204 | // `loadSession`, `handleBanUser`, the admin client handlers) bypass |
| 205 | // Better Auth's HTTP error handler, so an APIError thrown inside one |
| 206 | // of them — including the BANNED_USER predicate in `hooks.before` — |
| 207 | // escapes to here. Reshape it as our HttpError so the client gets |
| 208 | // the correct 4xx with a structured body instead of a generic 500. |
| 209 | // Raw `/api/auth/*` paths handle their own APIError responses inside |
| 210 | // `c.var.auth.handler` and never reach this branch. |
| 211 | if (isAPIError(err)) { |
| 212 | // OAuth-shaped APIError bodies (tessera's own throws plus Better Auth's |
| 213 | // oauth-provider plugin internals) carry RFC 6749 `error` and |
| 214 | // `error_description`. Spread the body through HttpError's `detail` arg — |
| 215 | // toResponse spreads detail after the synthesized fields, so an OAuth |
| 216 | // body's `error` overrides the synthesized one and RPs see the spec |
| 217 | // values. Non-OAuth APIErrors (banned-user etc.) keep today's |
| 218 | // `{error: <code>, message}` shape unchanged. |
| 219 | const body = err.body ?? {}; |
| 220 | return new HttpError( |
| 221 | err.statusCode, |
| 222 | body.code ?? body.error ?? "auth_error", |
| 223 | body.message ?? body.error_description ?? "Authentication error.", |
| 224 | body.error || body.error_description ? body : undefined, |
| 225 | ).toResponse(); |
| 226 | } |
| 227 | c.var.log.child({ component: "worker" }).error("unhandled_error", { |
| 228 | method: c.req.method, |
| 229 | path: c.req.path, |
| 230 | ...errorContext(err, c.var.logLevel), |
| 231 | }); |
| 232 | return c.json({ error: "internal_error", message: "Unexpected server error." }, 500); |
| 233 | }); |
| 234 | |
| 235 | export default app; |