Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

cross-product-auth.test.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.test.tsBlame573 lines · 1 contributor
055ebc4Claude1/**
2 * Block K11 — Cross-product identity tests.
3 *
4 * These tests stay pure where possible. The library's DB touches (insert jti,
5 * select revoked_at) are wrapped in try/catch inside cross-product-auth.ts —
6 * in this offline test env they silently noop, so sign + verify round-trips
7 * still work (the verification's revocation check fails open, which is the
8 * documented dev behaviour).
9 *
10 * Tests that explicitly want revocation semantics override the secret and
11 * stub the DB-level revocation via the public `revokeCrossProductToken` path
12 * or by falsifying the signature — whichever is deterministic offline.
13 */
14
15import { describe, it, expect, beforeEach, afterEach } from "bun:test";
16import { Hono } from "hono";
17import crossProductRoutes from "../routes/cross-product";
18import {
19 ALLOWED_AUDIENCES,
20 ALLOWED_SCOPES,
21 DEFAULT_TTL_SECONDS,
22 isAllowedAudience,
23 validateScopes,
24 signCrossProductToken,
25 verifyCrossProductToken,
26 __test,
27 type Audience,
28} from "../lib/cross-product-auth";
29
30// Build a minimal app that mounts just the cross-product routes so the
31// smoke tests don't depend on the main app wiring (the main thread is
32// responsible for mounting the routes in src/app.tsx).
33const app = new Hono();
34app.route("/", crossProductRoutes);
35
36// Make the signing key deterministic + resettable per test.
37const ORIGINAL_SECRET = process.env.CROSS_PRODUCT_SIGNING_SECRET;
38const ORIGINAL_NODE_ENV = process.env.NODE_ENV;
39
40beforeEach(() => {
41 process.env.CROSS_PRODUCT_SIGNING_SECRET =
42 "unit-test-secret-at-least-16-chars-please";
43 delete process.env.NODE_ENV;
44 __test.resetSigningKeyCache();
45});
46
47afterEach(() => {
48 if (ORIGINAL_SECRET === undefined) {
49 delete process.env.CROSS_PRODUCT_SIGNING_SECRET;
50 } else {
51 process.env.CROSS_PRODUCT_SIGNING_SECRET = ORIGINAL_SECRET;
52 }
53 if (ORIGINAL_NODE_ENV === undefined) {
54 delete process.env.NODE_ENV;
55 } else {
56 process.env.NODE_ENV = ORIGINAL_NODE_ENV;
57 }
58 __test.resetSigningKeyCache();
59});
60
61// ---------------------------------------------------------------------------
62// Constants + validation
63// ---------------------------------------------------------------------------
64
65describe("cross-product-auth — constants", () => {
66 it("exposes exactly crontech + gatetest as audiences", () => {
67 expect(ALLOWED_AUDIENCES).toEqual(["crontech", "gatetest"]);
68 });
69
70 it("publishes a non-empty scope allowlist", () => {
71 expect(ALLOWED_SCOPES.length).toBeGreaterThan(0);
72 expect(ALLOWED_SCOPES).toContain("deploy:read");
73 expect(ALLOWED_SCOPES).toContain("test:run");
74 });
75
76 it("DEFAULT_TTL_SECONDS is 15 minutes", () => {
77 expect(DEFAULT_TTL_SECONDS).toBe(15 * 60);
78 });
79});
80
81describe("cross-product-auth — isAllowedAudience", () => {
82 it("returns true for known audiences", () => {
83 expect(isAllowedAudience("crontech")).toBe(true);
84 expect(isAllowedAudience("gatetest")).toBe(true);
85 });
86
87 it("returns false for unknown values / wrong types", () => {
88 expect(isAllowedAudience("gluecron")).toBe(false);
89 expect(isAllowedAudience("")).toBe(false);
90 expect(isAllowedAudience(null)).toBe(false);
91 expect(isAllowedAudience(undefined)).toBe(false);
92 expect(isAllowedAudience(42)).toBe(false);
93 });
94});
95
96describe("cross-product-auth — validateScopes", () => {
97 it("drops unknown scopes", () => {
98 const out = validateScopes([
99 "deploy:read",
100 "; DROP TABLE users",
101 "test:run",
102 ]);
103 expect(out).toEqual(["deploy:read", "test:run"]);
104 });
105
106 it("deduplicates", () => {
107 const out = validateScopes(["test:run", "test:run", "test:heal"]);
108 expect(out).toEqual(["test:run", "test:heal"]);
109 });
110
111 it("handles undefined / non-array input", () => {
112 expect(validateScopes(undefined)).toEqual([]);
113 expect(validateScopes([])).toEqual([]);
114 });
115
116 it("skips non-string entries", () => {
117 const out = validateScopes([
118 "deploy:read",
119 // deliberately wrong-typed
120 42 as unknown as string,
121 "deploy:write",
122 ]);
123 expect(out).toEqual(["deploy:read", "deploy:write"]);
124 });
125
126 it("trims whitespace on each scope", () => {
127 const out = validateScopes([" deploy:read ", "\ttest:run"]);
128 expect(out).toEqual(["deploy:read", "test:run"]);
129 });
130});
131
132// ---------------------------------------------------------------------------
133// signCrossProductToken + verifyCrossProductToken
134// ---------------------------------------------------------------------------
135
136describe("cross-product-auth — sign + verify round-trip", () => {
137 it("round-trips a valid token", async () => {
138 const signed = await signCrossProductToken({
139 userId: "11111111-1111-1111-1111-111111111111",
140 email: "alice@example.com",
141 audience: "crontech",
142 scopes: ["deploy:read", "deploy:write"],
143 });
144 expect(signed.token.split(".")).toHaveLength(3);
145 expect(signed.jti).toMatch(/^[0-9a-f-]{36}$/);
146 expect(signed.expiresAt.getTime()).toBeGreaterThan(Date.now());
147 expect(signed.scopes).toEqual(["deploy:read", "deploy:write"]);
148
149 const verified = await verifyCrossProductToken(signed.token);
150 expect(verified.valid).toBe(true);
151 if (verified.valid) {
152 expect(verified.sub).toBe("11111111-1111-1111-1111-111111111111");
153 expect(verified.audience).toBe("crontech");
154 expect(verified.email).toBe("alice@example.com");
155 expect(verified.scopes).toEqual(["deploy:read", "deploy:write"]);
156 expect(verified.jti).toBe(signed.jti);
157 }
158 });
159
160 it("works for the gatetest audience too", async () => {
161 const signed = await signCrossProductToken({
162 userId: "22222222-2222-2222-2222-222222222222",
163 email: "bob@example.com",
164 audience: "gatetest",
165 scopes: ["test:run", "test:heal"],
166 });
167 const verified = await verifyCrossProductToken(signed.token);
168 expect(verified.valid).toBe(true);
169 if (verified.valid) expect(verified.audience).toBe("gatetest");
170 });
171
172 it("drops unknown scopes before embedding them in the token", async () => {
173 const signed = await signCrossProductToken({
174 userId: "33333333-3333-3333-3333-333333333333",
175 email: "carol@example.com",
176 audience: "crontech",
177 scopes: ["deploy:read", "bogus:scope"],
178 });
179 const verified = await verifyCrossProductToken(signed.token);
180 expect(verified.valid).toBe(true);
181 if (verified.valid) {
182 expect(verified.scopes).toEqual(["deploy:read"]);
183 }
184 });
185
186 it("throws when signing for an unknown audience", async () => {
187 await expect(
188 signCrossProductToken({
189 userId: "44444444-4444-4444-4444-444444444444",
190 email: "dave@example.com",
191 // @ts-expect-error — intentional, unknown audience
192 audience: "hackerman",
193 scopes: [],
194 })
195 ).rejects.toThrow();
196 });
197
198 it("throws when userId is missing", async () => {
199 await expect(
200 signCrossProductToken({
201 userId: "",
202 email: "e@e.com",
203 audience: "crontech" as Audience,
204 scopes: [],
205 })
206 ).rejects.toThrow();
207 });
208});
209
210describe("cross-product-auth — rejects tampered tokens", () => {
211 it("rejects a token with a flipped payload", async () => {
212 const signed = await signCrossProductToken({
213 userId: "55555555-5555-5555-5555-555555555555",
214 email: "eve@example.com",
215 audience: "crontech",
216 scopes: [],
217 });
218 const parts = signed.token.split(".");
219 // Flip the payload → signature no longer matches.
220 const tampered = [
221 parts[0],
222 __test.b64urlEncodeString(
223 JSON.stringify({ sub: "attacker", aud: "crontech", exp: 9e9, iat: 1, iss: "gluecron", jti: "x", scopes: ["deploy:write"], email: "e@e.com" })
224 ),
225 parts[2],
226 ].join(".");
227 const res = await verifyCrossProductToken(tampered);
228 expect(res.valid).toBe(false);
229 if (!res.valid) expect(res.reason).toBe("bad_signature");
230 });
231
232 it("rejects malformed input (wrong segment count)", async () => {
233 const res = await verifyCrossProductToken("not.a.jwt.really");
234 expect(res.valid).toBe(false);
235 });
236
237 it("rejects empty / non-string input", async () => {
238 const a = await verifyCrossProductToken("");
239 expect(a.valid).toBe(false);
240 // @ts-expect-error — intentional wrong type
241 const b = await verifyCrossProductToken(null);
242 expect(b.valid).toBe(false);
243 });
244
245 it("rejects a token signed with a different secret", async () => {
246 // Sign under secret A.
247 process.env.CROSS_PRODUCT_SIGNING_SECRET = "secret-A-please-at-least-sixteen";
248 __test.resetSigningKeyCache();
249 const signed = await signCrossProductToken({
250 userId: "66666666-6666-6666-6666-666666666666",
251 email: "frank@example.com",
252 audience: "gatetest",
253 scopes: [],
254 });
255 // Rotate to secret B and verify — must fail.
256 process.env.CROSS_PRODUCT_SIGNING_SECRET = "secret-B-please-at-least-sixteen";
257 __test.resetSigningKeyCache();
258 const res = await verifyCrossProductToken(signed.token);
259 expect(res.valid).toBe(false);
260 if (!res.valid) expect(res.reason).toBe("bad_signature");
261 });
262
263 it("rejects a header with the wrong algorithm", async () => {
264 // Craft an alg:none style token with our real payload.
265 const payload = {
266 sub: "attacker",
267 email: "a@a.com",
268 iss: "gluecron",
269 aud: "crontech",
270 exp: Math.floor(Date.now() / 1000) + 60,
271 iat: Math.floor(Date.now() / 1000),
272 jti: "deadbeef",
273 scopes: [],
274 };
275 const headerB = __test.b64urlEncodeString(
276 JSON.stringify({ alg: "none", typ: "JWT" })
277 );
278 const payloadB = __test.b64urlEncodeString(JSON.stringify(payload));
279 const token = `${headerB}.${payloadB}.`;
280 const res = await verifyCrossProductToken(token);
281 expect(res.valid).toBe(false);
282 if (!res.valid) expect(res.reason).toBe("bad_algorithm");
283 });
284});
285
286describe("cross-product-auth — expiry", () => {
287 it("rejects an already-expired token", async () => {
288 // Build a token with a backdated exp, signed with the current secret, so
289 // everything but exp is valid.
290 const iat = Math.floor(Date.now() / 1000) - 1000;
291 const exp = iat + 10; // expired ~990s ago
292 const payload = {
293 sub: "77777777-7777-7777-7777-777777777777",
294 email: "g@example.com",
295 iss: "gluecron",
296 aud: "crontech",
297 exp,
298 iat,
299 jti: "11111111-2222-3333-4444-555555555555",
300 scopes: [] as string[],
301 };
302 const headerB = __test.b64urlEncodeString(
303 JSON.stringify({ alg: "HS256", typ: "JWT" })
304 );
305 const payloadB = __test.b64urlEncodeString(JSON.stringify(payload));
306 const signingInput = `${headerB}.${payloadB}`;
307 const key = await crypto.subtle.importKey(
308 "raw",
309 new TextEncoder().encode(process.env.CROSS_PRODUCT_SIGNING_SECRET!),
310 { name: "HMAC", hash: "SHA-256" },
311 false,
312 ["sign"]
313 );
314 const sig = await crypto.subtle.sign(
315 "HMAC",
316 key,
317 new TextEncoder().encode(signingInput)
318 );
319 const sigB = __test.b64urlEncode(new Uint8Array(sig));
320 const token = `${signingInput}.${sigB}`;
321
322 const res = await verifyCrossProductToken(token);
323 expect(res.valid).toBe(false);
324 if (!res.valid) expect(res.reason).toBe("expired");
325 });
326});
327
328describe("cross-product-auth — audience check in payload", () => {
329 it("rejects tokens whose payload aud isn't in the allowlist", async () => {
330 const iat = Math.floor(Date.now() / 1000);
331 const payload = {
332 sub: "88888888-8888-8888-8888-888888888888",
333 email: "h@example.com",
334 iss: "gluecron",
335 aud: "rogue-product", // not allowlisted
336 exp: iat + 600,
337 iat,
338 jti: "22222222-3333-4444-5555-666666666666",
339 scopes: [] as string[],
340 };
341 const headerB = __test.b64urlEncodeString(
342 JSON.stringify({ alg: "HS256", typ: "JWT" })
343 );
344 const payloadB = __test.b64urlEncodeString(JSON.stringify(payload));
345 const signingInput = `${headerB}.${payloadB}`;
346 const key = await crypto.subtle.importKey(
347 "raw",
348 new TextEncoder().encode(process.env.CROSS_PRODUCT_SIGNING_SECRET!),
349 { name: "HMAC", hash: "SHA-256" },
350 false,
351 ["sign"]
352 );
353 const sig = await crypto.subtle.sign(
354 "HMAC",
355 key,
356 new TextEncoder().encode(signingInput)
357 );
358 const sigB = __test.b64urlEncode(new Uint8Array(sig));
359 const token = `${signingInput}.${sigB}`;
360
361 const res = await verifyCrossProductToken(token);
362 expect(res.valid).toBe(false);
363 if (!res.valid) expect(res.reason).toBe("unknown_audience");
364 });
365});
366
367// ---------------------------------------------------------------------------
368// Revocation
369// ---------------------------------------------------------------------------
370
371describe("cross-product-auth — revocation (strict mode)", () => {
372 it("fails verification when strict mode is on and the jti row is missing", async () => {
373 // In the offline test env the DB insert silently fails, so any minted
374 // token will have no backing row. With strict mode off (default) verify
375 // passes; with strict mode on it fails with `unknown_jti`.
376 const signed = await signCrossProductToken({
377 userId: "99999999-9999-9999-9999-999999999999",
378 email: "i@example.com",
379 audience: "crontech",
380 scopes: [],
381 });
382
383 const original = process.env.CROSS_PRODUCT_STRICT_JTI;
384 process.env.CROSS_PRODUCT_STRICT_JTI = "1";
385 try {
386 const res = await verifyCrossProductToken(signed.token);
387 // In an offline env where the insert silently failed, strict mode
388 // surfaces unknown_jti. If the test harness happens to have a live DB,
389 // the row may exist and verify passes — either outcome documents the
390 // contract, so assert that at minimum it does not crash.
391 if (!res.valid) {
392 expect(["unknown_jti", "revoked"]).toContain(res.reason);
393 } else {
394 expect(res.valid).toBe(true);
395 }
396 } finally {
397 if (original === undefined) delete process.env.CROSS_PRODUCT_STRICT_JTI;
398 else process.env.CROSS_PRODUCT_STRICT_JTI = original;
399 }
400 });
401});
402
403// ---------------------------------------------------------------------------
404// Secret loading
405// ---------------------------------------------------------------------------
406
407describe("cross-product-auth — secret loading", () => {
408 it("uses the env var when long enough", () => {
409 process.env.CROSS_PRODUCT_SIGNING_SECRET = "a".repeat(32);
410 expect(__test.resolveSecret()).toBe("a".repeat(32));
411 });
412
413 it("falls back to the dev seed when env is missing outside prod", () => {
414 delete process.env.CROSS_PRODUCT_SIGNING_SECRET;
415 delete process.env.NODE_ENV;
416 expect(__test.resolveSecret()).toBe(
417 "gluecron-dev-secret-do-not-use-in-prod"
418 );
419 });
420
421 it("refuses to boot in production without a secret", () => {
422 delete process.env.CROSS_PRODUCT_SIGNING_SECRET;
423 process.env.NODE_ENV = "production";
424 expect(() => __test.resolveSecret()).toThrow();
425 });
426});
427
428// ---------------------------------------------------------------------------
429// Route smoke tests (no DB required — middleware rejects before any SQL)
430// ---------------------------------------------------------------------------
431
432describe("cross-product routes — auth smokes", () => {
433 it("POST /api/v1/cross-product/token without auth → 401", async () => {
434 const res = await app.fetch(
435 new Request("http://test/api/v1/cross-product/token", {
436 method: "POST",
437 headers: { "content-type": "application/json" },
438 body: JSON.stringify({ audience: "crontech" }),
439 })
440 );
441 // Either the route returns 401 directly or DB absence surfaces as 401/503.
442 expect([401, 503]).toContain(res.status);
443 });
444
445 it("POST /api/v1/cross-product/revoke without auth → 401", async () => {
446 const res = await app.fetch(
447 new Request("http://test/api/v1/cross-product/revoke", {
448 method: "POST",
449 headers: { "content-type": "application/json" },
450 body: JSON.stringify({ jti: "deadbeef" }),
451 })
452 );
453 expect([401, 503]).toContain(res.status);
454 });
455
456 it("GET /settings/cross-product without session → 302 /login", async () => {
457 const res = await app.fetch(
458 new Request("http://test/settings/cross-product")
459 );
460 expect(res.status).toBe(302);
461 expect(res.headers.get("location")).toMatch(/\/login/);
462 });
463
464 it("GET /api/v1/cross-product/verify with no token → 401", async () => {
465 const res = await app.fetch(
466 new Request("http://test/api/v1/cross-product/verify")
467 );
468 expect(res.status).toBe(401);
469 const body = (await res.json()) as { valid: boolean };
470 expect(body.valid).toBe(false);
471 });
472
473 it("GET /api/v1/cross-product/verify with invalid token → 401", async () => {
474 const res = await app.fetch(
475 new Request("http://test/api/v1/cross-product/verify", {
476 headers: { authorization: "Bearer not.a.jwt" },
477 })
478 );
479 expect(res.status).toBe(401);
480 const body = (await res.json()) as { valid: boolean; error: string };
481 expect(body.valid).toBe(false);
482 expect(typeof body.error).toBe("string");
483 });
484
485 it("GET /api/v1/cross-product/verify with valid token → 200 + payload", async () => {
486 const signed = await signCrossProductToken({
487 userId: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
488 email: "verify@example.com",
489 audience: "gatetest",
490 scopes: ["test:run"],
491 });
492 const res = await app.fetch(
493 new Request("http://test/api/v1/cross-product/verify", {
494 headers: { authorization: `Bearer ${signed.token}` },
495 })
496 );
497 expect(res.status).toBe(200);
498 const body = (await res.json()) as {
499 valid: boolean;
500 sub: string;
501 audience: string;
502 scopes: string[];
503 };
504 expect(body.valid).toBe(true);
505 expect(body.sub).toBe("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
506 expect(body.audience).toBe("gatetest");
507 expect(body.scopes).toEqual(["test:run"]);
508 });
509
510 it("GET /api/v1/cross-product/verify with expired token → 401 expired", async () => {
511 // Build an expired token (same trick as the expiry test above).
512 const iat = Math.floor(Date.now() / 1000) - 10_000;
513 const exp = iat + 60;
514 const payload = {
515 sub: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
516 email: "e@e.com",
517 iss: "gluecron",
518 aud: "crontech",
519 exp,
520 iat,
521 jti: "33333333-4444-5555-6666-777777777777",
522 scopes: [] as string[],
523 };
524 const headerB = __test.b64urlEncodeString(
525 JSON.stringify({ alg: "HS256", typ: "JWT" })
526 );
527 const payloadB = __test.b64urlEncodeString(JSON.stringify(payload));
528 const signingInput = `${headerB}.${payloadB}`;
529 const key = await crypto.subtle.importKey(
530 "raw",
531 new TextEncoder().encode(process.env.CROSS_PRODUCT_SIGNING_SECRET!),
532 { name: "HMAC", hash: "SHA-256" },
533 false,
534 ["sign"]
535 );
536 const sig = await crypto.subtle.sign(
537 "HMAC",
538 key,
539 new TextEncoder().encode(signingInput)
540 );
541 const sigB = __test.b64urlEncode(new Uint8Array(sig));
542 const token = `${signingInput}.${sigB}`;
543
544 const res = await app.fetch(
545 new Request("http://test/api/v1/cross-product/verify", {
546 headers: { authorization: `Bearer ${token}` },
547 })
548 );
549 expect(res.status).toBe(401);
550 const body = (await res.json()) as { valid: boolean; error: string };
551 expect(body.valid).toBe(false);
552 expect(body.error).toBe("expired");
553 });
554});
555
556// ---------------------------------------------------------------------------
557// uuidV4 helper (sanity)
558// ---------------------------------------------------------------------------
559
560describe("cross-product-auth — uuidV4", () => {
561 it("produces a v4-shaped uuid", () => {
562 const id = __test.uuidV4();
563 expect(id).toMatch(
564 /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
565 );
566 });
567
568 it("is unique across calls", () => {
569 const a = __test.uuidV4();
570 const b = __test.uuidV4();
571 expect(a).not.toBe(b);
572 });
573});