Skip to content
File

Blob: src/worker/index.ts

typescript236 lines
1import { oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata } from "@better-auth/oauth-provider";
2import { isAPIError } from "better-auth/api";
3import { Hono } from "hono";
4 
5import { handleUpdateHandle } from "@/worker/api/account";
6import { handleListConnectedApps, handleRevokeConnectedApp } from "@/worker/api/connected-apps";
7import { handleListLauncherApps } from "@/worker/api/launcher";
8import {
9 handleCreateClient,
10 handleDeleteClient,
11 handleListClients,
12 handleRotateClientSecret,
13 handleUpdateClient,
14} from "@/worker/api/admin/clients";
15import { handleCreateInvite, handleListInvites, handleRevokeInvite } from "@/worker/api/admin/invites";
16import {
17 handleCreateLauncherApp,
18 handleDeleteLauncherApp,
19 handleListLauncherApps as handleListLauncherAppsAdmin,
20 handleUpdateLauncherApp,
21} from "@/worker/api/admin/launcher-apps";
22import { handleBanUser } from "@/worker/api/admin/users";
23import { handleConfig } from "@/worker/api/config";
24import { handleInviteAccept, handleInviteLookup } from "@/worker/api/invites";
25import { handleSignIn } from "@/worker/api/sign-in";
26import { handleSignInSocial } from "@/worker/api/sign-in-social";
27import { makeAuth } from "@/worker/auth";
28import { resolveBaseUrl, resolveIssuer } from "@/worker/config";
29import { makeDb } from "@/worker/db";
30import type { AppBindings } from "@/worker/hono";
31import { HttpError } from "@/worker/http";
32import { createLogger, errorContext, parseLogLevel } from "@/worker/logger";
33import { adminRouteAllowlist } from "@/worker/middleware/admin-allowlist";
34import { requireAdmin, requireUser } from "@/worker/middleware/auth";
35import { securityHeaders } from "@/worker/middleware/headers";
36import { requireSameOriginForMutations } from "@/worker/middleware/origin";
37import { rateLimitAuthSurface } from "@/worker/middleware/rate-limit";
38import { stripTrailingSlash } from "@/worker/services/url";
39 
40const app = new Hono<AppBindings>();
41 
42app.use("*", securityHeaders);
43app.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 
62app.get("/healthz", (c) => c.json({ ok: true, service: "tessera" }));
63 
64app.get("/api/config", handleConfig);
65 
66const 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.
69accountApi.use("*", requireSameOriginForMutations);
70accountApi.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).
75accountApi.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.
80accountApi.get("/connected-apps", handleListConnectedApps);
81accountApi.delete("/connected-apps/:id", handleRevokeConnectedApp);
82 
83const adminApi = new Hono<AppBindings>();
84adminApi.use("*", requireSameOriginForMutations);
85adminApi.use("*", requireAdmin);
86adminApi.get("/invites", handleListInvites);
87adminApi.post("/invites", handleCreateInvite);
88adminApi.delete("/invites/:id", handleRevokeInvite);
89adminApi.get("/clients", handleListClients);
90adminApi.post("/clients", handleCreateClient);
91adminApi.patch("/clients/:id", handleUpdateClient);
92adminApi.post("/clients/:id/rotate", handleRotateClientSecret);
93adminApi.delete("/clients/:id", handleDeleteClient);
94adminApi.get("/launcher-apps", handleListLauncherAppsAdmin);
95adminApi.post("/launcher-apps", handleCreateLauncherApp);
96adminApi.patch("/launcher-apps/:id", handleUpdateLauncherApp);
97adminApi.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.
102app.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.
106app.post("/api/sign-in/social", requireSameOriginForMutations, handleSignInSocial);
107app.get("/api/invite/:token", handleInviteLookup);
108app.post("/api/invite/:token", requireSameOriginForMutations, handleInviteAccept);
109 
110// Launcher tiles for the signed-in landing page. Reads from
111// `launcher_apps`.
112app.get("/api/launcher", requireUser, handleListLauncherApps);
113 
114app.route("/api/account", accountApi);
115app.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.
149const 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]);
161app.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.
174app.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.
181app.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`.
187app.use("/api/auth/*", rateLimitAuthSurface);
188 
189app.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.
196app.get("/.well-known/openid-configuration", (c) => oauthProviderOpenIdConfigMetadata(c.var.auth)(c.req.raw));
197app.get("/.well-known/oauth-authorization-server", (c) => oauthProviderAuthServerMetadata(c.var.auth)(c.req.raw));
198 
199app.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 
235export default app;