Skip to content
File

Blob: src/worker/middleware/auth.ts

typescript191 lines
1import { eq } from "drizzle-orm";
2import { createMiddleware } from "hono/factory";
3import { ADMIN_ACCESS_DISABLED_ERROR_CODE, ErrorResponse } from "@/shared/contracts";
4import { inboxes } from "@/worker/db/schema";
5import { ADMIN_ACCESS_UNAVAILABLE_MESSAGE, decodeSessionRecord } from "@/worker/security";
6import { getAdminCookie } from "@/worker/services/cookies";
7import { sameOriginViolation } from "@/worker/middleware/origin";
8import { loadOidcConfig } from "@/worker/services/oidc";
9import { createLogger } from "@/worker/logger";
10import type { AppBindings, AppContext } from "@/worker/types";
11 
12const logger = createLogger("auth");
13 
14function jsonError(message: string, status: number, code?: typeof ADMIN_ACCESS_DISABLED_ERROR_CODE) {
15 return new Response(
16 JSON.stringify(
17 ErrorResponse.create({
18 ...(code ? { code } : {}),
19 error: message,
20 }),
21 ),
22 {
23 status,
24 headers: {
25 "content-type": "application/json; charset=utf-8",
26 },
27 },
28 );
29}
30 
31function adminAccessUnavailable(c: AppContext, reason: string) {
32 logger.warn("admin_access_disabled", "Blocked admin request because tessera OIDC is not configured", {
33 method: c.req.method,
34 path: c.req.path,
35 reason,
36 });
37 return jsonError(ADMIN_ACCESS_UNAVAILABLE_MESSAGE, 503, ADMIN_ACCESS_DISABLED_ERROR_CODE);
38}
39 
40async function readKvSession(env: Env, token: string | null | undefined) {
41 if (!token) {
42 return null;
43 }
44 const raw = await env.SESSIONS.get(`token:${token}`);
45 if (!raw) {
46 return null;
47 }
48 return decodeSessionRecord(raw);
49}
50 
51export async function readUserBearerSession(c: AppContext) {
52 const header = c.req.header("authorization") ?? "";
53 const token = header.startsWith("Bearer ") ? header.slice(7).trim() : "";
54 if (!token) {
55 return null;
56 }
57 const session = await readKvSession(c.env, token);
58 if (!session || session.type !== "user") {
59 return null;
60 }
61 return { session, token };
62}
63 
64export async function readAdminCookieSession(c: AppContext) {
65 const token = getAdminCookie(c);
66 if (!token) {
67 return null;
68 }
69 const session = await readKvSession(c.env, token);
70 if (!session || session.type !== "admin") {
71 return null;
72 }
73 return { session, token };
74}
75 
76async function loadInboxForAddress(c: AppContext): Promise<Response | null> {
77 const addressParam = c.req.param("address");
78 if (!addressParam) {
79 return jsonError("Inbox address is required", 400);
80 }
81 
82 const address = decodeURIComponent(addressParam);
83 const db = c.get("db");
84 const inbox = await db.query.inboxes.findFirst({
85 where: eq(inboxes.fullAddress, address),
86 });
87 
88 if (!inbox) {
89 return jsonError("Inbox not found", 404);
90 }
91 
92 if (!inbox.isPermanent && inbox.expiresAt && inbox.expiresAt.getTime() < Date.now()) {
93 return jsonError("Inbox has expired", 410);
94 }
95 
96 c.set("inbox", inbox);
97 return null;
98}
99 
100async function authorizeAdminOrFail(c: AppContext): Promise<Response | null> {
101 const config = loadOidcConfig(c.env);
102 if (!config.ok) {
103 return adminAccessUnavailable(c, config.reason);
104 }
105 const auth = await readAdminCookieSession(c);
106 if (!auth) {
107 return jsonError("Unauthorized", 401);
108 }
109 // Re-check the allowlist on every request so demoting an operator in
110 // TESSERA_OPERATOR_SUBS revokes their existing session immediately
111 // instead of waiting for the KV TTL to roll off.
112 if (!config.config.operatorSubs.includes(auth.session.sub)) {
113 return jsonError("Forbidden", 403);
114 }
115 c.set("session", auth.session);
116 return null;
117}
118 
119// Admin API class: cookie-only, OIDC config + allowlist re-checked.
120export const requireAdmin = createMiddleware<AppBindings>(async (c, next) => {
121 const failure = await authorizeAdminOrFail(c);
122 if (failure) {
123 return failure;
124 }
125 await next();
126});
127 
128// User class on /inbox routes (no `?admin=1`): bearer-only matching the
129// requested address. Stale bearer-admin tokens fail the type === "user"
130// check inside readUserBearerSession.
131export const requireInboxAccess = createMiddleware<AppBindings>(async (c, next) => {
132 const auth = await readUserBearerSession(c);
133 if (!auth) {
134 return jsonError("Unauthorized", 401);
135 }
136 
137 const addressParam = c.req.param("address");
138 if (!addressParam) {
139 return jsonError("Inbox address is required", 400);
140 }
141 const address = decodeURIComponent(addressParam);
142 if (auth.session.address !== address) {
143 return jsonError("Forbidden", 403);
144 }
145 
146 c.set("session", auth.session);
147 c.set("token", auth.token);
148 
149 const errorResponse = await loadInboxForAddress(c);
150 if (errorResponse) {
151 return errorResponse;
152 }
153 
154 await next();
155});
156 
157// Admin-inspect class: cookie-only admin session (OIDC config +
158// allowlist re-checked) reading any inbox.
159// Same 404/410 semantics as requireInboxAccess via the shared loader.
160export const requireAdminInspect = createMiddleware<AppBindings>(async (c, next) => {
161 const failure = await authorizeAdminOrFail(c);
162 if (failure) {
163 return failure;
164 }
165 
166 const errorResponse = await loadInboxForAddress(c);
167 if (errorResponse) {
168 return errorResponse;
169 }
170 
171 await next();
172});
173 
174// Selector for inbox/email routes. `?admin=1` selects the cookie-only
175// admin-inspect chain (with same-origin enforced for non-safe methods);
176// otherwise the bearer-only user chain runs. The chosen credential
177// middleware runs once per request.
178export const requireInboxRouteAccess = createMiddleware<AppBindings>(async (c, next) => {
179 const isAdminInspect = c.req.query("admin") === "1";
180 if (!isAdminInspect) {
181 return requireInboxAccess(c, next);
182 }
183 
184 const violation = sameOriginViolation(c);
185 if (violation) {
186 return violation;
187 }
188 
189 return requireAdminInspect(c, next);
190});