Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

cross-product.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

cross-product.tsxBlame339 lines · 1 contributor
055ebc4Claude1/**
2 * Block K11 — Cross-product identity routes.
3 *
4 * POST /api/v1/cross-product/token — exchange any gluecron credential
5 * (session / PAT / OAuth) for a
6 * short-lived JWT bound to a sibling
7 * product audience.
8 * GET /api/v1/cross-product/verify — no-auth verifier used by siblings.
9 * POST /api/v1/cross-product/revoke — owner revocation.
10 * GET /settings/cross-product — web UI listing active tokens.
11 *
12 * Every mint writes an `audit_log` row with action `cross_product.token_mint`.
13 */
14
15import { Hono } from "hono";
16import { sql } from "drizzle-orm";
17import { db } from "../db";
18import { auditLog } from "../db/schema";
19import { Layout } from "../views/layout";
20import { softAuth, requireAuth } from "../middleware/auth";
21import type { AuthEnv } from "../middleware/auth";
22import {
23 ALLOWED_AUDIENCES,
24 ALLOWED_SCOPES,
25 isAllowedAudience,
26 validateScopes,
27 signCrossProductToken,
28 verifyCrossProductToken,
29 revokeCrossProductToken,
30 listActiveCrossProductTokens,
31 type Audience,
32} from "../lib/cross-product-auth";
33
34const cp = new Hono<AuthEnv>();
35
36// softAuth is enough for the exchange endpoint — it runs session/PAT/OAuth
37// resolution. We then gate on `c.get("user")` so we can return JSON 401
38// rather than the redirect requireAuth emits for HTML paths.
39cp.use("/api/v1/cross-product/token", softAuth);
40cp.use("/api/v1/cross-product/revoke", softAuth);
41cp.use("/settings/cross-product", softAuth, requireAuth);
42cp.use("/settings/cross-product/*", softAuth, requireAuth);
43
44// ---------------------------------------------------------------------------
45// Helpers
46// ---------------------------------------------------------------------------
47
48function parseBearer(c: {
49 req: { header: (k: string) => string | undefined };
50}): string | null {
51 const h = c.req.header("authorization") || "";
52 if (!h.toLowerCase().startsWith("bearer ")) return null;
53 const tok = h.slice(7).trim();
54 return tok || null;
55}
56
57async function recordMintAudit(
58 userId: string,
59 audience: Audience,
60 scopes: string[],
61 jti: string,
62 ip: string | undefined,
63 userAgent: string | undefined
64): Promise<void> {
65 try {
66 await db.insert(auditLog).values({
67 userId,
68 repositoryId: null,
69 action: "cross_product.token_mint",
70 targetType: "cross_product_token",
71 targetId: jti,
72 ip: ip ?? null,
73 userAgent: userAgent ?? null,
74 metadata: JSON.stringify({ audience, scopes }),
75 });
76 } catch (err) {
77 console.error("[cross-product] audit write failed:", err);
78 }
79}
80
81// ---------------------------------------------------------------------------
82// POST /api/v1/cross-product/token
83// ---------------------------------------------------------------------------
84
85cp.post("/api/v1/cross-product/token", async (c) => {
86 const user = c.get("user");
87 if (!user) {
88 return c.json({ error: "unauthenticated" }, 401);
89 }
90
91 let body: Record<string, unknown>;
92 try {
93 body = (await c.req.json()) as Record<string, unknown>;
94 } catch {
95 return c.json({ error: "invalid_json" }, 400);
96 }
97
98 const audience = body.audience;
99 if (!isAllowedAudience(audience)) {
100 return c.json(
101 {
102 error: "unknown_audience",
103 allowed: ALLOWED_AUDIENCES,
104 },
105 400
106 );
107 }
108
109 let requestedScopes: string[] = [];
110 if (Array.isArray(body.scope)) {
111 requestedScopes = body.scope.filter(
112 (s): s is string => typeof s === "string"
113 );
114 } else if (Array.isArray(body.scopes)) {
115 requestedScopes = body.scopes.filter(
116 (s): s is string => typeof s === "string"
117 );
118 }
119 const scopes = validateScopes(requestedScopes);
120
121 const ip =
122 c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
123 c.req.header("x-real-ip") ||
124 undefined;
125 const userAgent = c.req.header("user-agent") || undefined;
126
127 try {
128 const result = await signCrossProductToken({
129 userId: user.id,
130 email: user.email,
131 audience,
132 scopes,
133 });
134 await recordMintAudit(user.id, audience, scopes, result.jti, ip, userAgent);
135 return c.json({
136 token: result.token,
137 expires_at: result.expiresAt.toISOString(),
138 audience,
139 scopes: result.scopes,
140 jti: result.jti,
141 issuer: "gluecron",
142 });
143 } catch (err) {
144 console.error("[cross-product] mint failed:", err);
145 return c.json({ error: "mint_failed" }, 500);
146 }
147});
148
149// ---------------------------------------------------------------------------
150// GET /api/v1/cross-product/verify (no auth; sibling products call this)
151// ---------------------------------------------------------------------------
152
153async function handleVerify(c: {
154 req: {
155 header: (k: string) => string | undefined;
156 json: () => Promise<unknown>;
157 };
158 json: (body: unknown, status?: number) => Response;
159}): Promise<Response> {
160 let token = parseBearer(c);
161 if (!token) {
162 // Fall back to body token for clients that prefer POST.
163 try {
164 const body = (await c.req.json()) as Record<string, unknown>;
165 if (body && typeof body.token === "string") token = body.token;
166 } catch {
167 // no body — that's fine
168 }
169 }
170 if (!token) {
171 return c.json({ valid: false, error: "missing_token" }, 401);
172 }
173
174 const result = await verifyCrossProductToken(token);
175 if (!result.valid) {
176 return c.json({ valid: false, error: result.reason }, 401);
177 }
178 return c.json({
179 valid: true,
180 sub: result.sub,
181 email: result.email,
182 audience: result.audience,
183 scopes: result.scopes,
184 jti: result.jti,
185 expires_at: result.expiresAt.toISOString(),
186 issuer: "gluecron",
187 });
188}
189
190cp.get("/api/v1/cross-product/verify", (c) => handleVerify(c));
191cp.post("/api/v1/cross-product/verify", (c) => handleVerify(c));
192
193// ---------------------------------------------------------------------------
194// POST /api/v1/cross-product/revoke
195// ---------------------------------------------------------------------------
196
197cp.post("/api/v1/cross-product/revoke", async (c) => {
198 const user = c.get("user");
199 if (!user) return c.json({ error: "unauthenticated" }, 401);
200
201 let body: Record<string, unknown>;
202 try {
203 body = (await c.req.json()) as Record<string, unknown>;
204 } catch {
205 return c.json({ error: "invalid_json" }, 400);
206 }
207
208 const jti = typeof body.jti === "string" ? body.jti : "";
209 if (!jti) return c.json({ error: "jti_required" }, 400);
210
211 const ok = await revokeCrossProductToken(jti, user.id);
212 if (!ok) return c.json({ error: "not_found_or_not_owner" }, 404);
213
214 // Audit the revoke (separate action from mint).
215 try {
216 await db.insert(auditLog).values({
217 userId: user.id,
218 repositoryId: null,
219 action: "cross_product.token_revoke",
220 targetType: "cross_product_token",
221 targetId: jti,
222 metadata: null,
223 });
224 } catch (err) {
225 console.error("[cross-product] revoke audit failed:", err);
226 }
227 return c.json({ ok: true });
228});
229
230// ---------------------------------------------------------------------------
231// GET /settings/cross-product (web UI)
232// ---------------------------------------------------------------------------
233
234cp.get("/settings/cross-product", async (c) => {
235 const user = c.get("user")!;
236 let active: Awaited<ReturnType<typeof listActiveCrossProductTokens>> = [];
237 try {
238 active = await listActiveCrossProductTokens(user.id);
239 } catch {
240 active = [];
241 }
242
243 return c.html(
244 <Layout title="Cross-product identity" user={user}>
245 <div class="settings-container">
246 <h2>Cross-product identity</h2>
247 <p style="color: var(--text-muted); max-width: 640px">
248 One gluecron account signs into Crontech and Gatetest. Active
249 short-lived tokens issued for those sibling products are listed
250 below. Tokens expire in {String(15)} minutes; revoke anything
251 suspicious.
252 </p>
253
254 <h3 style="margin-top: 24px">Active tokens</h3>
255 {active.length === 0 ? (
256 <p style="color: var(--text-muted)">
257 No active cross-product tokens. They are minted on-demand by the
258 sibling products when you sign in.
259 </p>
260 ) : (
261 <div>
262 {active.map((tok) => (
263 <div class="ssh-key-item">
264 <div>
265 <strong>{tok.audience}</strong>
266 <div class="ssh-key-meta">
267 <code>{tok.jti.slice(0, 8)}...</code>
268 <span style="margin-left: 8px">
269 Scopes:{" "}
270 {tok.scopes.length > 0 ? tok.scopes.join(", ") : "none"}
271 </span>
272 <span style="margin-left: 8px">
273 Expires{" "}
274 {new Date(tok.expiresAt).toLocaleTimeString()}
275 </span>
276 </div>
277 </div>
278 <form
279 method="POST"
280 action={`/settings/cross-product/${tok.jti}/revoke`}
281 >
282 <button type="submit" class="btn btn-danger btn-sm">
283 Revoke
284 </button>
285 </form>
286 </div>
287 ))}
288 </div>
289 )}
290
291 <h3 style="margin-top: 24px">Audiences</h3>
292 <ul style="color: var(--text-muted)">
293 {ALLOWED_AUDIENCES.map((a) => (
294 <li>
295 <code>{a}</code>
296 </li>
297 ))}
298 </ul>
299
300 <h3 style="margin-top: 24px">Supported scopes</h3>
301 <ul style="color: var(--text-muted)">
302 {ALLOWED_SCOPES.map((s) => (
303 <li>
304 <code>{s}</code>
305 </li>
306 ))}
307 </ul>
308 </div>
309 </Layout>
310 );
311});
312
313// Form POST for the web UI revoke button (keeps it cookie-based).
314cp.post("/settings/cross-product/:jti/revoke", async (c) => {
315 const user = c.get("user")!;
316 const jti = c.req.param("jti");
317 const ok = await revokeCrossProductToken(jti, user.id);
318 if (ok) {
319 try {
320 await db.insert(auditLog).values({
321 userId: user.id,
322 repositoryId: null,
323 action: "cross_product.token_revoke",
324 targetType: "cross_product_token",
325 targetId: jti,
326 metadata: null,
327 });
328 } catch (err) {
329 console.error("[cross-product] revoke audit failed:", err);
330 }
331 }
332 return c.redirect("/settings/cross-product");
333});
334
335// Stub so TS narrows `sql` as imported (keeps the compiler happy if this
336// file grows helpers later — stripped by the bundler).
337void sql;
338
339export default cp;