Skip to content
File

Blob: src/worker/api/admin-oidc.ts

typescript216 lines
1import type { Hono } from "hono";
2import { ADMIN_ACCESS_DISABLED_ERROR_CODE, OkResponse } from "@/shared/contracts";
3import { readAdminCookieSession } from "@/worker/middleware/auth";
4import { requireSameOriginForCookieMutations } from "@/worker/middleware/origin";
5import { createLogger, errorContext } from "@/worker/logger";
6import { ADMIN_ACCESS_UNAVAILABLE_MESSAGE } from "@/worker/security";
7import {
8 clearAdminCookie,
9 clearOidcTransactionCookie,
10 getOidcTransactionCookie,
11 setAdminCookie,
12 setOidcTransactionCookie,
13} from "@/worker/services/cookies";
14import {
15 buildAuthorizeUrl,
16 buildCallbackUrl,
17 decodeTransactionPayload,
18 deriveTransactionCookieSecret,
19 discoverOidcProvider,
20 encodeTransactionPayload,
21 exchangeAuthorizationCode,
22 generateNonce,
23 generatePkcePair,
24 generateState,
25 loadOidcConfig,
26 verifyIdTokenClaims,
27} from "@/worker/services/oidc";
28import { ADMIN_SESSION_TTL_MS, createSessionToken, revokeAdminSessionToken } from "@/worker/services/inbox";
29import type { AppBindings, AppContext } from "@/worker/types";
30 
31const logger = createLogger("admin-oidc");
32 
33function adminUnavailableRedirect(c: AppContext) {
34 // The admin sign-in card surfaces the unavailable banner via this code.
35 // Mirrors the pre-OIDC ADMIN_ACCESS_DISABLED contract so the React route
36 // does not need a new error path.
37 return c.redirect(`/admin?error=${encodeURIComponent(ADMIN_ACCESS_DISABLED_ERROR_CODE)}`);
38}
39 
40function callbackErrorRedirect(c: AppContext, code: string) {
41 clearOidcTransactionCookie(c);
42 return c.redirect(`/admin?error=${encodeURIComponent(code)}`);
43}
44 
45export function registerAdminOidcRoutes(app: Hono<AppBindings>) {
46 app.get("/api/public/admin/start", async (c) => {
47 const result = loadOidcConfig(c.env);
48 if (!result.ok) {
49 logger.warn("oidc_start_disabled", "OIDC start blocked because tessera config is incomplete", {
50 reason: result.reason,
51 });
52 return adminUnavailableRedirect(c);
53 }
54 
55 const config = result.config;
56 const providerResult = await discoverOidcProvider(config);
57 if (!providerResult.ok) {
58 logger.warn("oidc_start_discovery_failed", "OIDC start blocked because tessera discovery failed", {
59 reason: providerResult.reason,
60 });
61 return adminUnavailableRedirect(c);
62 }
63 
64 const state = generateState();
65 const nonce = generateNonce();
66 const pkce = await generatePkcePair();
67 const redirectUri = buildCallbackUrl(c.req.url);
68 
69 const encodedTransaction = encodeTransactionPayload({
70 state,
71 nonce,
72 codeVerifier: pkce.verifier,
73 redirectUri,
74 createdAt: Date.now(),
75 });
76 
77 try {
78 const transactionCookieSecret = await deriveTransactionCookieSecret(config.clientSecret);
79 await setOidcTransactionCookie(c, encodedTransaction, transactionCookieSecret);
80 } catch (error) {
81 logger.error("oidc_start_cookie_sign_failed", "Could not sign OIDC transaction cookie", errorContext(error));
82 return adminUnavailableRedirect(c);
83 }
84 
85 const authorizeUrl = buildAuthorizeUrl(providerResult.provider, {
86 redirectUri,
87 state,
88 nonce,
89 codeChallenge: pkce.challenge,
90 });
91 
92 return c.redirect(authorizeUrl);
93 });
94 
95 app.get("/api/public/admin/callback", async (c) => {
96 const result = loadOidcConfig(c.env);
97 if (!result.ok) {
98 logger.warn("oidc_callback_disabled", "OIDC callback blocked because tessera config is incomplete", {
99 reason: result.reason,
100 });
101 clearOidcTransactionCookie(c);
102 return adminUnavailableRedirect(c);
103 }
104 
105 const config = result.config;
106 const code = c.req.query("code") ?? "";
107 const state = c.req.query("state") ?? "";
108 if (!code || !state) {
109 logger.warn("oidc_callback_missing_params", "Rejected callback with missing code/state");
110 return callbackErrorRedirect(c, "invalid_request");
111 }
112 
113 let transactionCookieSecret: Uint8Array<ArrayBuffer>;
114 try {
115 transactionCookieSecret = await deriveTransactionCookieSecret(config.clientSecret);
116 } catch (error) {
117 logger.error(
118 "oidc_callback_cookie_secret_derive_failed",
119 "Could not derive OIDC transaction cookie signing secret",
120 errorContext(error),
121 );
122 clearOidcTransactionCookie(c);
123 return adminUnavailableRedirect(c);
124 }
125 
126 const transactionCookie = await getOidcTransactionCookie(c, transactionCookieSecret);
127 if (transactionCookie.kind === "missing") {
128 logger.warn("oidc_callback_missing_cookie", "Rejected callback with missing transaction cookie");
129 return callbackErrorRedirect(c, "missing_state");
130 }
131 if (transactionCookie.kind === "invalid_signature") {
132 logger.warn("oidc_callback_cookie_signature_failed", "Rejected callback with bad transaction cookie signature");
133 return callbackErrorRedirect(c, "invalid_state");
134 }
135 
136 const transaction = decodeTransactionPayload(transactionCookie.value);
137 if (!transaction.ok) {
138 logger.warn("oidc_callback_transaction_invalid", "Rejected callback with bad transaction cookie payload", {
139 reason: transaction.reason,
140 });
141 return callbackErrorRedirect(c, "invalid_state");
142 }
143 
144 if (transaction.payload.state !== state) {
145 logger.warn("oidc_callback_state_mismatch", "Rejected callback with mismatched state");
146 return callbackErrorRedirect(c, "invalid_state");
147 }
148 
149 if (transaction.payload.redirectUri !== buildCallbackUrl(c.req.url)) {
150 logger.warn("oidc_callback_redirect_uri_mismatch", "Rejected callback with mismatched redirect_uri");
151 return callbackErrorRedirect(c, "invalid_state");
152 }
153 
154 const providerResult = await discoverOidcProvider(config);
155 if (!providerResult.ok) {
156 logger.warn("oidc_callback_discovery_failed", "OIDC callback blocked because tessera discovery failed", {
157 reason: providerResult.reason,
158 });
159 clearOidcTransactionCookie(c);
160 return adminUnavailableRedirect(c);
161 }
162 
163 const tokenResult = await exchangeAuthorizationCode(providerResult.provider, {
164 callbackUrl: c.req.url,
165 codeVerifier: transaction.payload.codeVerifier,
166 nonce: transaction.payload.nonce,
167 state,
168 });
169 if (!tokenResult.ok) {
170 logger.warn("oidc_callback_token_exchange_failed", "Token exchange with tessera failed", {
171 reason: tokenResult.reason,
172 });
173 const errorCode = tokenResult.reason === "invalid_id_token" ? "invalid_id_token" : "token_exchange_failed";
174 return callbackErrorRedirect(c, errorCode);
175 }
176 
177 const verified = verifyIdTokenClaims(config, tokenResult.tokens.claims);
178 if (!verified.ok) {
179 logger.warn("oidc_callback_verify_failed", "ID token verification failed", {
180 reason: verified.reason,
181 });
182 const errorCode = verified.reason === "sub_not_allowed" ? "not_operator" : "invalid_id_token";
183 return callbackErrorRedirect(c, errorCode);
184 }
185 
186 let token: string;
187 try {
188 token = await createSessionToken(c.env, { type: "admin", sub: verified.verified.sub }, ADMIN_SESSION_TTL_MS);
189 } catch (error) {
190 logger.error("oidc_callback_session_create_failed", "Could not create admin session", errorContext(error));
191 return callbackErrorRedirect(c, "session_create_failed");
192 }
193 
194 setAdminCookie(c, token, Math.floor(ADMIN_SESSION_TTL_MS / 1000));
195 clearOidcTransactionCookie(c);
196 
197 logger.info("admin_login_succeeded", "Created admin session via tessera OIDC");
198 return c.redirect("/admin");
199 });
200 
201 app.post("/api/public/admin/logout", requireSameOriginForCookieMutations, async (c) => {
202 const auth = await readAdminCookieSession(c);
203 if (auth) {
204 try {
205 await revokeAdminSessionToken(c.env, auth.token);
206 } catch (error) {
207 logger.warn("admin_logout_revoke_failed", "Could not revoke admin session token", errorContext(error));
208 }
209 }
210 clearAdminCookie(c);
211 return c.json(OkResponse.create({ ok: true }));
212 });
213}
214 
215export { ADMIN_ACCESS_UNAVAILABLE_MESSAGE };