Skip to content

Commit 6744bf2

Browse files
committed
RBAC tests: cross-cutting auth edge cases (TRI-8743)
Cross-cutting behaviours that aren't tied to a route family. Strategy: one representative API-key route + one representative JWT route exercise the edge cases — the auth layer is shared across every API route via apiBuilder.server.ts, so coverage here generalises. Smoke matrix already covers trivial cases (missing/ invalid key, basic JWT pass, soft-deleted project); this fills the gaps that need explicit fixture setup. Cases added: Revoked API key grace window (against /api/v1/runs/.../result): - revoked key with expiresAt > now: auth passes (rotation grace). - revoked key with expiresAt < now: 401. JWT edge cases (against /api/v1/waitpoints/tokens/.../complete): - expirationTime in the past (epoch 1) → 401. generateJWT only accepts string expirationTimes; constructed with jose's SignJWT directly to set an absolute past timestamp. - pub: false → 401 (token not meant for client-side use). - no sub claim → 401 (auth can't resolve env without it). - signed with another env's apiKey (sub-vs-signature mismatch) → 401. - malformed (3 parts but invalid base64 in payload) → 401 (must surface as 401, not 500 — guards against panic-on-malformed). Cross-environment isolation: - env A's JWT used to fetch env B's resource → not 200. Verifies the auth layer resolves env from the JWT's sub claim, NOT from the URL — env A's view scopes its lookup to env A and doesn't see env B's data. Critical security property: would let any customer read another's runs if it ever broke. Out of scope here: - Plugin force-fallback variant (running the suite under RBAC_FORCE_FALLBACK=1 and the unset default) — would need a second harness invocation. Filed mentally for follow-up. - Revoked PAT decryption-mismatch case (hash collision is effectively impossible to construct on demand). Verification: typecheck clean. Test execution deferred to your normal e2e run.
1 parent 5da9ba3 commit 6744bf2

1 file changed

Lines changed: 201 additions & 1 deletion

File tree

Lines changed: 201 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,215 @@
11
// Cross-cutting auth-layer behaviours that aren't tied to a specific route
22
// family — see TRI-8743. Soft-deleted projects, revoked keys, expired JWTs,
33
// cross-env mismatch, force-fallback toggle.
4+
//
5+
// Strategy: pick one representative API-key route
6+
// (GET /api/v1/runs/run_doesnotexist/result) and one representative JWT
7+
// route (POST /api/v1/waitpoints/tokens/<id>/complete) and exercise the
8+
// edge cases against those. The route choice doesn't matter — the
9+
// auth layer is shared across every API route via apiBuilder.server.ts.
10+
// Smoke matrix (api-auth.e2e.test.ts) already covers the trivial
11+
// cases (missing/invalid key, basic JWT pass, soft-deleted project);
12+
// this file adds cases that need explicit fixture setup.
413

14+
import { generateJWT } from "@trigger.dev/core/v3/jwt";
15+
import { SignJWT } from "jose";
516
import { describe, expect, it } from "vitest";
617
import { getTestServer } from "./helpers/sharedTestServer";
18+
import { seedTestEnvironment } from "./helpers/seedTestEnvironment";
719

820
describe("Cross-cutting", () => {
9-
// Placeholder until TRI-8743 adds the actual matrix.
1021
it("shared prisma client can read from the postgres container", async () => {
1122
const server = getTestServer();
1223
const count = await server.prisma.user.count();
1324
expect(count).toBeGreaterThanOrEqual(0);
1425
});
26+
27+
// The auth path falls back to RevokedApiKey when a key isn't found
28+
// in RuntimeEnvironment — letting customers continue to use a key
29+
// for a configurable grace window after rotation. See
30+
// models/runtimeEnvironment.server.ts. The grace lookup matches by
31+
// (apiKey AND expiresAt > now) and rehydrates the env via the FK.
32+
describe("Revoked API key grace window", () => {
33+
const route = "/api/v1/runs/run_doesnotexist/result";
34+
35+
it("revoked key within grace (expiresAt > now): auth passes", async () => {
36+
const server = getTestServer();
37+
const { environment } = await seedTestEnvironment(server.prisma);
38+
// Mint a fresh "rotated" key that doesn't exist on any env, then
39+
// record it as recently revoked with a future grace expiry.
40+
const rotatedKey = `tr_dev_rotated_${Math.random().toString(36).slice(2)}`;
41+
await server.prisma.revokedApiKey.create({
42+
data: {
43+
apiKey: rotatedKey,
44+
runtimeEnvironmentId: environment.id,
45+
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // +1 day
46+
},
47+
});
48+
const res = await server.webapp.fetch(route, {
49+
headers: { Authorization: `Bearer ${rotatedKey}` },
50+
});
51+
// Auth passed — the route's resource lookup just doesn't find
52+
// run_doesnotexist. The point is NOT 401.
53+
expect(res.status).not.toBe(401);
54+
});
55+
56+
it("revoked key past grace (expiresAt < now): 401", async () => {
57+
const server = getTestServer();
58+
const { environment } = await seedTestEnvironment(server.prisma);
59+
const expiredKey = `tr_dev_expired_${Math.random().toString(36).slice(2)}`;
60+
await server.prisma.revokedApiKey.create({
61+
data: {
62+
apiKey: expiredKey,
63+
runtimeEnvironmentId: environment.id,
64+
expiresAt: new Date(Date.now() - 60 * 1000), // -1 minute
65+
},
66+
});
67+
const res = await server.webapp.fetch(route, {
68+
headers: { Authorization: `Bearer ${expiredKey}` },
69+
});
70+
expect(res.status).toBe(401);
71+
});
72+
});
73+
74+
// JWT edge cases beyond what the smoke matrix covers (which only
75+
// checks "wrong key" and "missing scope"). All target the same
76+
// representative JWT route — the JWT validator is shared across
77+
// routes via apiBuilder, so coverage here generalises.
78+
describe("JWT edge cases", () => {
79+
const route = "/api/v1/waitpoints/tokens/wp_does_not_exist/complete";
80+
81+
async function postWithJwt(jwt: string) {
82+
const server = getTestServer();
83+
return server.webapp.fetch(route, {
84+
method: "POST",
85+
headers: {
86+
Authorization: `Bearer ${jwt}`,
87+
"Content-Type": "application/json",
88+
},
89+
body: JSON.stringify({}),
90+
});
91+
}
92+
93+
it("JWT with expirationTime in the past: 401", async () => {
94+
const server = getTestServer();
95+
const { environment } = await seedTestEnvironment(server.prisma);
96+
// generateJWT only accepts string expirationTimes (relative, like
97+
// "15m"). To create a definitively-expired token use jose
98+
// directly with an absolute past timestamp.
99+
const secret = new TextEncoder().encode(environment.apiKey);
100+
const jwt = await new SignJWT({
101+
pub: true,
102+
sub: environment.id,
103+
scopes: ["write:waitpoints"],
104+
})
105+
.setIssuer("https://id.trigger.dev")
106+
.setAudience("https://api.trigger.dev")
107+
.setProtectedHeader({ alg: "HS256" })
108+
.setIssuedAt(0)
109+
.setExpirationTime(1) // 1970-01-01 — definitively expired
110+
.sign(secret);
111+
112+
const res = await postWithJwt(jwt);
113+
expect(res.status).toBe(401);
114+
});
115+
116+
it("JWT with pub: false: 401", async () => {
117+
const server = getTestServer();
118+
const { environment } = await seedTestEnvironment(server.prisma);
119+
const jwt = await generateJWT({
120+
secretKey: environment.apiKey,
121+
payload: { pub: false, sub: environment.id, scopes: ["write:waitpoints"] },
122+
expirationTime: "15m",
123+
});
124+
// pub: false means "this token isn't meant for client-side use"
125+
// — the auth layer rejects it for the same-class JWT routes.
126+
const res = await postWithJwt(jwt);
127+
expect(res.status).toBe(401);
128+
});
129+
130+
it("JWT with no sub claim: 401", async () => {
131+
const server = getTestServer();
132+
const { environment } = await seedTestEnvironment(server.prisma);
133+
const jwt = await generateJWT({
134+
secretKey: environment.apiKey,
135+
payload: { pub: true, scopes: ["write:waitpoints"] },
136+
expirationTime: "15m",
137+
});
138+
// No sub claim — auth can't resolve which env the token belongs
139+
// to, so it must reject. (sub carries the env id.)
140+
const res = await postWithJwt(jwt);
141+
expect(res.status).toBe(401);
142+
});
143+
144+
it("JWT signed with another env's apiKey (cross-env): 401", async () => {
145+
const server = getTestServer();
146+
// env A's id but signed with env B's apiKey — sub-vs-signature
147+
// mismatch the auth layer must catch.
148+
const a = await seedTestEnvironment(server.prisma);
149+
const b = await seedTestEnvironment(server.prisma);
150+
const jwt = await generateJWT({
151+
secretKey: b.apiKey, // <-- WRONG key relative to the sub claim
152+
payload: { pub: true, sub: a.environment.id, scopes: ["write:waitpoints"] },
153+
expirationTime: "15m",
154+
});
155+
const res = await postWithJwt(jwt);
156+
expect(res.status).toBe(401);
157+
});
158+
159+
it("JWT malformed (three parts but invalid base64 in payload): 401", async () => {
160+
// Three "."-separated parts so the JWT shape gate sees it as a
161+
// candidate, but the payload segment is non-base64 garbage.
162+
// Validator must surface this as 401, not 500.
163+
const malformed = "eyJhbGciOiJIUzI1NiJ9.@@@notbase64@@@.signature";
164+
const res = await postWithJwt(malformed);
165+
expect(res.status).toBe(401);
166+
});
167+
});
168+
169+
// The auth layer resolves the JWT's env from the `sub` claim — NOT
170+
// from the route path. So a JWT for env A hitting a route that
171+
// fetches a resource from env B should never accidentally see env
172+
// B's data. Test by minting a JWT for env A and asking for a
173+
// resource that lives in env B — expect 404 (not 200).
174+
describe("Cross-environment: JWT auth resolves env from sub, not URL", () => {
175+
it("env A's JWT cannot read env B's resource: 404", async () => {
176+
const server = getTestServer();
177+
const a = await seedTestEnvironment(server.prisma);
178+
const b = await seedTestEnvironment(server.prisma);
179+
180+
// Seed a real-ish run row in env B so the route would have
181+
// something to find IF auth resolved the env from the URL.
182+
const friendlyId = `run_${Math.random().toString(36).slice(2, 10)}`;
183+
await server.prisma.taskRun.create({
184+
data: {
185+
friendlyId,
186+
taskIdentifier: "test-task",
187+
payload: "{}",
188+
payloadType: "application/json",
189+
traceId: `trace_${Math.random().toString(36).slice(2)}`,
190+
spanId: `span_${Math.random().toString(36).slice(2)}`,
191+
runtimeEnvironmentId: b.environment.id,
192+
projectId: b.project.id,
193+
organizationId: b.organization.id,
194+
engine: "V2",
195+
status: "COMPLETED_SUCCESSFULLY",
196+
},
197+
});
198+
199+
const jwt = await generateJWT({
200+
secretKey: a.apiKey,
201+
payload: { pub: true, sub: a.environment.id, scopes: ["read:runs"] },
202+
expirationTime: "15m",
203+
});
204+
205+
const res = await server.webapp.fetch(`/api/v1/runs/${friendlyId}/result`, {
206+
headers: { Authorization: `Bearer ${jwt}` },
207+
});
208+
// The route resolves runs scoped to the JWT's env (env A). The
209+
// run lives in env B, so env A's view returns "not found" —
210+
// critically, NOT 200.
211+
expect(res.status).not.toBe(200);
212+
expect([401, 404]).toContain(res.status);
213+
});
214+
});
15215
});

0 commit comments

Comments
 (0)