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-auth.ts

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-auth.tsBlame536 lines · 1 contributor
055ebc4Claude1/**
2 * Block K11 — Cross-product identity.
3 *
4 * gluecron is the identity provider (IdP) for its sibling products:
5 * * Crontech — runtime/hosting
6 * * Gatetest — testing platform
7 *
8 * A single gluecron credential (session cookie, `glc_` PAT, or `glct_` OAuth
9 * access token) can be exchanged at `POST /api/v1/cross-product/token` for a
10 * short-lived HS256 JWT bound to a specific audience. The sibling products
11 * verify these tokens either by calling `GET /api/v1/cross-product/verify` or
12 * by verifying the HMAC signature themselves with the shared secret.
13 *
14 * This module is the pure crypto + persistence layer. It:
15 * * Mints signed JWTs using HMAC-SHA256 (crypto.subtle, no new deps).
16 * * Records every mint in `cross_product_tokens` for revocation + audit.
17 * * Verifies tokens (signature, exp, and the revocation list).
18 *
19 * Rules:
20 * * Secret is `process.env.CROSS_PRODUCT_SIGNING_SECRET`. In non-prod we fall
21 * back to a deterministic dev secret so tests run offline; in prod we
22 * refuse to boot.
23 * * Tokens are 15 minutes by default. Callers may NOT extend this.
24 * * Scopes are a fixed allowlist — anything unknown is silently dropped.
25 * * Audiences are a fixed allowlist — signing for an unknown audience throws.
26 * * JTI is a v4 UUID. The `cross_product_tokens` row is the source of truth
27 * for revocation; the JWT itself is not self-revoking.
28 */
29
30import { sql } from "drizzle-orm";
31import { db } from "../db";
32
33// ---------------------------------------------------------------------------
34// Config: audiences, scopes, TTL.
35// ---------------------------------------------------------------------------
36
37export type Audience = "crontech" | "gatetest";
38
39export const ALLOWED_AUDIENCES: readonly Audience[] = [
40 "crontech",
41 "gatetest",
42] as const;
43
44/**
45 * The universe of scopes gluecron will sign across all sibling products.
46 * Keep this tight — unknown scopes get dropped, not rejected, so new sibling
47 * products must add their scope here before clients can request it.
48 */
49export const ALLOWED_SCOPES: readonly string[] = [
50 "deploy:read",
51 "deploy:write",
52 "test:run",
53 "test:heal",
54 "signals:write",
55 "signals:read",
56 "identity:read",
57] as const;
58
59export const DEFAULT_TTL_SECONDS: number = 15 * 60;
60
61/** Identity claim issuer — sibling products check this. */
62export const ISSUER = "gluecron" as const;
63
64// ---------------------------------------------------------------------------
65// Types.
66// ---------------------------------------------------------------------------
67
68export interface CrossProductClaims {
69 /** Subject — gluecron user id. */
70 sub: string;
71 /** User's gluecron email (for sibling audit logs). */
72 email: string;
73 /** Always `"gluecron"`. */
74 iss: typeof ISSUER;
75 /** `"crontech"` or `"gatetest"`. */
76 aud: Audience;
77 /** Expiry, seconds since epoch. */
78 exp: number;
79 /** Issued-at, seconds since epoch. */
80 iat: number;
81 /** JWT ID (UUID v4). */
82 jti: string;
83 /** Filtered to ALLOWED_SCOPES. */
84 scopes: string[];
85}
86
87export interface SignInput {
88 userId: string;
89 email: string;
90 audience: Audience;
91 scopes?: string[];
92 ttlSeconds?: number;
93}
94
95export interface SignResult {
96 token: string;
97 jti: string;
98 expiresAt: Date;
99 scopes: string[];
100}
101
102export type VerifyResult =
103 | {
104 valid: true;
105 sub: string;
106 email: string;
107 audience: Audience;
108 scopes: string[];
109 jti: string;
110 expiresAt: Date;
111 }
112 | { valid: false; reason: VerifyFailureReason };
113
114export type VerifyFailureReason =
115 | "malformed"
116 | "bad_algorithm"
117 | "bad_signature"
118 | "expired"
119 | "unknown_audience"
120 | "revoked"
121 | "unknown_jti";
122
123export interface ActiveCrossProductToken {
124 jti: string;
125 userId: string;
126 audience: Audience;
127 scopes: string[];
128 issuedAt: Date;
129 expiresAt: Date;
130 revokedAt: Date | null;
131}
132
133// ---------------------------------------------------------------------------
134// Secret loading.
135// ---------------------------------------------------------------------------
136
137const DEV_FALLBACK_SEED = "gluecron-dev-secret-do-not-use-in-prod";
138
139let cachedKey: Promise<CryptoKey> | null = null;
140
141function resolveSecret(): string {
142 const envVar = process.env.CROSS_PRODUCT_SIGNING_SECRET;
143 if (envVar && envVar.length >= 16) return envVar;
144 if (process.env.NODE_ENV === "production") {
145 throw new Error(
146 "CROSS_PRODUCT_SIGNING_SECRET must be set (>=16 chars) in production"
147 );
148 }
149 return DEV_FALLBACK_SEED;
150}
151
152async function getSigningKey(): Promise<CryptoKey> {
153 if (!cachedKey) {
154 cachedKey = (async () => {
155 const secret = resolveSecret();
156 const raw = new TextEncoder().encode(secret);
157 return await crypto.subtle.importKey(
158 "raw",
159 raw,
160 { name: "HMAC", hash: "SHA-256" },
161 false,
162 ["sign", "verify"]
163 );
164 })();
165 }
166 return cachedKey;
167}
168
169/**
170 * Test-only: forget the cached key so a test can rotate the secret mid-run.
171 * Not exported from the package root — call via `__test`.
172 */
173function resetSigningKeyCache(): void {
174 cachedKey = null;
175}
176
177// ---------------------------------------------------------------------------
178// Base64url (JWT-safe).
179// ---------------------------------------------------------------------------
180
181function b64urlEncode(bytes: Uint8Array): string {
182 let bin = "";
183 for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
184 return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
185}
186
187function b64urlEncodeString(str: string): string {
188 return b64urlEncode(new TextEncoder().encode(str));
189}
190
191function b64urlDecode(input: string): Uint8Array {
192 // Re-pad to a multiple of 4.
193 const pad = input.length % 4 === 0 ? "" : "=".repeat(4 - (input.length % 4));
194 const b64 = input.replace(/-/g, "+").replace(/_/g, "/") + pad;
195 const bin = atob(b64);
196 const out = new Uint8Array(bin.length);
197 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
198 return out;
199}
200
201function b64urlDecodeString(input: string): string {
202 return new TextDecoder().decode(b64urlDecode(input));
203}
204
205// ---------------------------------------------------------------------------
206// UUID v4 (no deps).
207// ---------------------------------------------------------------------------
208
209function uuidV4(): string {
210 const b = crypto.getRandomValues(new Uint8Array(16));
211 b[6] = (b[6] & 0x0f) | 0x40;
212 b[8] = (b[8] & 0x3f) | 0x80;
213 const hex = Array.from(b).map((x) => x.toString(16).padStart(2, "0"));
214 return (
215 hex.slice(0, 4).join("") +
216 "-" +
217 hex.slice(4, 6).join("") +
218 "-" +
219 hex.slice(6, 8).join("") +
220 "-" +
221 hex.slice(8, 10).join("") +
222 "-" +
223 hex.slice(10, 16).join("")
224 );
225}
226
227// ---------------------------------------------------------------------------
228// Validation helpers.
229// ---------------------------------------------------------------------------
230
231export function validateScopes(requested: readonly string[] | undefined): string[] {
232 if (!requested || !Array.isArray(requested)) return [];
233 const seen = new Set<string>();
234 const out: string[] = [];
235 const allow = new Set<string>(ALLOWED_SCOPES);
236 for (const raw of requested) {
237 if (typeof raw !== "string") continue;
238 const s = raw.trim();
239 if (!s || seen.has(s)) continue;
240 if (allow.has(s)) {
241 out.push(s);
242 seen.add(s);
243 }
244 }
245 return out;
246}
247
248export function isAllowedAudience(value: unknown): value is Audience {
249 return (
250 typeof value === "string" &&
251 (ALLOWED_AUDIENCES as readonly string[]).includes(value)
252 );
253}
254
255// ---------------------------------------------------------------------------
256// Sign / verify.
257// ---------------------------------------------------------------------------
258
259async function hmacSign(signingInput: string): Promise<string> {
260 const key = await getSigningKey();
261 const sig = await crypto.subtle.sign(
262 "HMAC",
263 key,
264 new TextEncoder().encode(signingInput)
265 );
266 return b64urlEncode(new Uint8Array(sig));
267}
268
269async function hmacVerify(
270 signingInput: string,
271 signatureB64: string
272): Promise<boolean> {
273 const key = await getSigningKey();
274 const sig = b64urlDecode(signatureB64);
275 return await crypto.subtle.verify(
276 "HMAC",
277 key,
278 sig,
279 new TextEncoder().encode(signingInput)
280 );
281}
282
283/**
284 * Mint a cross-product JWT and persist the jti row.
285 *
286 * Throws on programmer errors (unknown audience, missing user id). Swallows
287 * only DB failures on the insert — the token is still returned, but downstream
288 * revocation will fail open. Deployed code should monitor the log.
289 */
290export async function signCrossProductToken(
291 input: SignInput
292): Promise<SignResult> {
293 if (!input.userId || typeof input.userId !== "string") {
294 throw new Error("userId is required");
295 }
296 if (!isAllowedAudience(input.audience)) {
297 throw new Error(`unknown audience: ${String(input.audience)}`);
298 }
299 const ttl = Math.max(
300 60,
301 Math.min(input.ttlSeconds ?? DEFAULT_TTL_SECONDS, DEFAULT_TTL_SECONDS)
302 );
303 const scopes = validateScopes(input.scopes ?? []);
304 const jti = uuidV4();
305 const iat = Math.floor(Date.now() / 1000);
306 const exp = iat + ttl;
307 const expiresAt = new Date(exp * 1000);
308
309 const header = { alg: "HS256", typ: "JWT" } as const;
310 const payload: CrossProductClaims = {
311 sub: input.userId,
312 email: input.email,
313 iss: ISSUER,
314 aud: input.audience,
315 exp,
316 iat,
317 jti,
318 scopes,
319 };
320
321 const headerB = b64urlEncodeString(JSON.stringify(header));
322 const payloadB = b64urlEncodeString(JSON.stringify(payload));
323 const signingInput = `${headerB}.${payloadB}`;
324 const sigB = await hmacSign(signingInput);
325 const token = `${signingInput}.${sigB}`;
326
327 try {
328 await db.execute(sql`
329 INSERT INTO cross_product_tokens (jti, user_id, audience, scopes, issued_at, expires_at)
330 VALUES (
331 ${jti},
332 ${input.userId},
333 ${input.audience},
334 ${JSON.stringify(scopes)},
335 to_timestamp(${iat}),
336 to_timestamp(${exp})
337 )
338 `);
339 } catch (err) {
340 console.error("[cross-product-auth] failed to persist jti:", err);
341 }
342
343 return { token, jti, expiresAt, scopes };
344}
345
346/**
347 * Verify a cross-product JWT.
348 *
349 * * Checks the structural shape: 3 base64url parts.
350 * * Rejects anything that isn't `alg: "HS256", typ: "JWT"`.
351 * * Verifies HMAC.
352 * * Checks `exp` vs. now.
353 * * Checks the `cross_product_tokens` row for revocation.
354 *
355 * If the DB lookup fails (connection error), we fail open on the revocation
356 * check — the signature + exp + audience checks have already passed, so
357 * callers still get a well-formed identity; we log the DB error for ops.
358 */
359export async function verifyCrossProductToken(
360 token: string
361): Promise<VerifyResult> {
362 if (!token || typeof token !== "string") {
363 return { valid: false, reason: "malformed" };
364 }
365 const parts = token.split(".");
366 if (parts.length !== 3) return { valid: false, reason: "malformed" };
367 const [headerB, payloadB, sigB] = parts;
368
369 let header: { alg?: unknown; typ?: unknown };
370 let payload: Partial<CrossProductClaims> & Record<string, unknown>;
371 try {
372 header = JSON.parse(b64urlDecodeString(headerB));
373 payload = JSON.parse(b64urlDecodeString(payloadB));
374 } catch {
375 return { valid: false, reason: "malformed" };
376 }
377
378 if (header.alg !== "HS256" || header.typ !== "JWT") {
379 return { valid: false, reason: "bad_algorithm" };
380 }
381
382 const signingInput = `${headerB}.${payloadB}`;
383 let sigOk = false;
384 try {
385 sigOk = await hmacVerify(signingInput, sigB);
386 } catch {
387 sigOk = false;
388 }
389 if (!sigOk) return { valid: false, reason: "bad_signature" };
390
391 if (typeof payload.exp !== "number") {
392 return { valid: false, reason: "malformed" };
393 }
394 const now = Math.floor(Date.now() / 1000);
395 if (payload.exp <= now) return { valid: false, reason: "expired" };
396
397 if (!isAllowedAudience(payload.aud)) {
398 return { valid: false, reason: "unknown_audience" };
399 }
400 if (typeof payload.sub !== "string" || !payload.sub) {
401 return { valid: false, reason: "malformed" };
402 }
403 if (typeof payload.jti !== "string" || !payload.jti) {
404 return { valid: false, reason: "malformed" };
405 }
406
407 // Revocation + jti existence check.
408 try {
409 const rows = (await db.execute(sql`
410 SELECT revoked_at FROM cross_product_tokens
411 WHERE jti = ${payload.jti}
412 LIMIT 1
413 `)) as unknown as Array<{ revoked_at: string | null }>;
414 const row = Array.isArray(rows) ? rows[0] : undefined;
415 if (row && row.revoked_at) {
416 return { valid: false, reason: "revoked" };
417 }
418 // Note: a missing row is not a hard fail; dev/test paths where the insert
419 // was swallowed still surface the token. Ops can flip this to strict by
420 // setting CROSS_PRODUCT_STRICT_JTI=1.
421 if (!row && process.env.CROSS_PRODUCT_STRICT_JTI === "1") {
422 return { valid: false, reason: "unknown_jti" };
423 }
424 } catch (err) {
425 console.error("[cross-product-auth] revocation lookup failed:", err);
426 }
427
428 const scopes = Array.isArray(payload.scopes)
429 ? (payload.scopes as unknown[]).filter(
430 (s): s is string => typeof s === "string"
431 )
432 : [];
433
434 return {
435 valid: true,
436 sub: payload.sub,
437 email: typeof payload.email === "string" ? payload.email : "",
438 audience: payload.aud,
439 scopes,
440 jti: payload.jti,
441 expiresAt: new Date(payload.exp * 1000),
442 };
443}
444
445/**
446 * Revoke a previously-minted cross-product token. Only the owning user's id
447 * is accepted — callers must enforce this from the session / PAT / OAuth.
448 * Returns `true` if a row was updated.
449 */
450export async function revokeCrossProductToken(
451 jti: string,
452 userId: string
453): Promise<boolean> {
454 if (!jti || !userId) return false;
455 try {
456 const rows = (await db.execute(sql`
457 UPDATE cross_product_tokens
458 SET revoked_at = now()
459 WHERE jti = ${jti}
460 AND user_id = ${userId}
461 AND revoked_at IS NULL
462 RETURNING jti
463 `)) as unknown as Array<{ jti: string }>;
464 return Array.isArray(rows) && rows.length > 0;
465 } catch (err) {
466 console.error("[cross-product-auth] revoke failed:", err);
467 return false;
468 }
469}
470
471/**
472 * List the user's currently-usable tokens (non-revoked, non-expired).
473 * Ordered newest first. Cap at 50 to keep the settings page responsive.
474 */
475export async function listActiveCrossProductTokens(
476 userId: string
477): Promise<ActiveCrossProductToken[]> {
478 if (!userId) return [];
479 try {
480 const rows = (await db.execute(sql`
481 SELECT jti, user_id, audience, scopes, issued_at, expires_at, revoked_at
482 FROM cross_product_tokens
483 WHERE user_id = ${userId}
484 AND revoked_at IS NULL
485 AND expires_at > now()
486 ORDER BY issued_at DESC
487 LIMIT 50
488 `)) as unknown as Array<Record<string, unknown>>;
489 return (rows || []).map(rowToActive).filter(
490 (r): r is ActiveCrossProductToken => r !== null
491 );
492 } catch (err) {
493 console.error("[cross-product-auth] list failed:", err);
494 return [];
495 }
496}
497
498function rowToActive(row: Record<string, unknown>): ActiveCrossProductToken | null {
499 if (!row) return null;
500 const jti = row.jti;
501 const userId = row.user_id;
502 const audience = row.audience;
503 if (typeof jti !== "string" || typeof userId !== "string") return null;
504 if (!isAllowedAudience(audience)) return null;
505 let scopes: string[] = [];
506 if (typeof row.scopes === "string") {
507 try {
508 const parsed = JSON.parse(row.scopes);
509 if (Array.isArray(parsed)) {
510 scopes = parsed.filter((s): s is string => typeof s === "string");
511 }
512 } catch {
513 scopes = [];
514 }
515 }
516 const issuedAt = row.issued_at ? new Date(String(row.issued_at)) : new Date();
517 const expiresAt = row.expires_at
518 ? new Date(String(row.expires_at))
519 : new Date();
520 const revokedAt = row.revoked_at ? new Date(String(row.revoked_at)) : null;
521 return { jti, userId, audience, scopes, issuedAt, expiresAt, revokedAt };
522}
523
524// ---------------------------------------------------------------------------
525// Test hooks — do not import from production code.
526// ---------------------------------------------------------------------------
527
528export const __test = {
529 resetSigningKeyCache,
530 b64urlEncode,
531 b64urlEncodeString,
532 b64urlDecode,
533 b64urlDecodeString,
534 uuidV4,
535 resolveSecret,
536};