chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
// Cross-cutting auth-layer behaviours that aren't tied to a specific route
|
||||
// family — see TRI-8743. Soft-deleted projects, revoked keys, expired JWTs,
|
||||
// cross-env mismatch, force-fallback toggle.
|
||||
//
|
||||
// Strategy: pick one representative API-key route
|
||||
// (GET /api/v1/runs/run_doesnotexist/result) and one representative JWT
|
||||
// route (POST /api/v1/waitpoints/tokens/<id>/complete) and exercise the
|
||||
// edge cases against those. The route choice doesn't matter — the
|
||||
// auth layer is shared across every API route via apiBuilder.server.ts.
|
||||
// Smoke matrix (api-auth.e2e.test.ts) already covers the trivial
|
||||
// cases (missing/invalid key, basic JWT pass, soft-deleted project);
|
||||
// this file adds cases that need explicit fixture setup.
|
||||
|
||||
import { generateJWT } from "@trigger.dev/core/v3/jwt";
|
||||
import { SignJWT } from "jose";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getTestServer } from "./helpers/sharedTestServer";
|
||||
import { seedTestEnvironment } from "./helpers/seedTestEnvironment";
|
||||
|
||||
describe("Cross-cutting", () => {
|
||||
it("shared prisma client can read from the postgres container", async () => {
|
||||
const server = getTestServer();
|
||||
const count = await server.prisma.user.count();
|
||||
expect(count).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
// The auth path falls back to RevokedApiKey when a key isn't found
|
||||
// in RuntimeEnvironment — letting customers continue to use a key
|
||||
// for a configurable grace window after rotation. See
|
||||
// models/runtimeEnvironment.server.ts. The grace lookup matches by
|
||||
// (apiKey AND expiresAt > now) and rehydrates the env via the FK.
|
||||
describe("Revoked API key grace window", () => {
|
||||
const route = "/api/v1/runs/run_doesnotexist/result";
|
||||
|
||||
it("revoked key within grace (expiresAt > now): auth passes", async () => {
|
||||
const server = getTestServer();
|
||||
const { environment } = await seedTestEnvironment(server.prisma);
|
||||
// Mint a fresh "rotated" key that doesn't exist on any env, then
|
||||
// record it as recently revoked with a future grace expiry.
|
||||
const rotatedKey = `tr_dev_rotated_${Math.random().toString(36).slice(2)}`;
|
||||
await server.prisma.revokedApiKey.create({
|
||||
data: {
|
||||
apiKey: rotatedKey,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // +1 day
|
||||
},
|
||||
});
|
||||
const res = await server.webapp.fetch(route, {
|
||||
headers: { Authorization: `Bearer ${rotatedKey}` },
|
||||
});
|
||||
// Auth passed — the route's resource lookup just doesn't find
|
||||
// run_doesnotexist. The point is NOT 401.
|
||||
expect(res.status).not.toBe(401);
|
||||
});
|
||||
|
||||
it("revoked key past grace (expiresAt < now): 401", async () => {
|
||||
const server = getTestServer();
|
||||
const { environment } = await seedTestEnvironment(server.prisma);
|
||||
const expiredKey = `tr_dev_expired_${Math.random().toString(36).slice(2)}`;
|
||||
await server.prisma.revokedApiKey.create({
|
||||
data: {
|
||||
apiKey: expiredKey,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
expiresAt: new Date(Date.now() - 60 * 1000), // -1 minute
|
||||
},
|
||||
});
|
||||
const res = await server.webapp.fetch(route, {
|
||||
headers: { Authorization: `Bearer ${expiredKey}` },
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// JWT edge cases beyond what the smoke matrix covers (which only
|
||||
// checks "wrong key" and "missing scope"). All target the same
|
||||
// representative JWT route — the JWT validator is shared across
|
||||
// routes via apiBuilder, so coverage here generalises.
|
||||
describe("JWT edge cases", () => {
|
||||
const route = "/api/v1/waitpoints/tokens/wp_does_not_exist/complete";
|
||||
|
||||
async function postWithJwt(jwt: string) {
|
||||
const server = getTestServer();
|
||||
return server.webapp.fetch(route, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
|
||||
it("JWT with expirationTime in the past: 401", async () => {
|
||||
const server = getTestServer();
|
||||
const { environment } = await seedTestEnvironment(server.prisma);
|
||||
// generateJWT only accepts string expirationTimes (relative, like
|
||||
// "15m"). To create a definitively-expired token use jose
|
||||
// directly with an absolute past timestamp.
|
||||
const secret = new TextEncoder().encode(environment.apiKey);
|
||||
const jwt = await new SignJWT({
|
||||
pub: true,
|
||||
sub: environment.id,
|
||||
scopes: ["write:waitpoints"],
|
||||
})
|
||||
.setIssuer("https://id.trigger.dev")
|
||||
.setAudience("https://api.trigger.dev")
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setIssuedAt(0)
|
||||
.setExpirationTime(1) // 1970-01-01 — definitively expired
|
||||
.sign(secret);
|
||||
|
||||
const res = await postWithJwt(jwt);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("JWT with pub: false: 401", async () => {
|
||||
const server = getTestServer();
|
||||
const { environment } = await seedTestEnvironment(server.prisma);
|
||||
const jwt = await generateJWT({
|
||||
secretKey: environment.apiKey,
|
||||
payload: { pub: false, sub: environment.id, scopes: ["write:waitpoints"] },
|
||||
expirationTime: "15m",
|
||||
});
|
||||
// pub: false means "this token isn't meant for client-side use"
|
||||
// — the auth layer rejects it for the same-class JWT routes.
|
||||
const res = await postWithJwt(jwt);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("JWT with no sub claim: 401", async () => {
|
||||
const server = getTestServer();
|
||||
const { environment } = await seedTestEnvironment(server.prisma);
|
||||
const jwt = await generateJWT({
|
||||
secretKey: environment.apiKey,
|
||||
payload: { pub: true, scopes: ["write:waitpoints"] },
|
||||
expirationTime: "15m",
|
||||
});
|
||||
// No sub claim — auth can't resolve which env the token belongs
|
||||
// to, so it must reject. (sub carries the env id.)
|
||||
const res = await postWithJwt(jwt);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("JWT signed with another env's apiKey (cross-env): 401", async () => {
|
||||
const server = getTestServer();
|
||||
// env A's id but signed with env B's apiKey — sub-vs-signature
|
||||
// mismatch the auth layer must catch.
|
||||
const a = await seedTestEnvironment(server.prisma);
|
||||
const b = await seedTestEnvironment(server.prisma);
|
||||
const jwt = await generateJWT({
|
||||
secretKey: b.apiKey, // <-- WRONG key relative to the sub claim
|
||||
payload: { pub: true, sub: a.environment.id, scopes: ["write:waitpoints"] },
|
||||
expirationTime: "15m",
|
||||
});
|
||||
const res = await postWithJwt(jwt);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("JWT malformed (three parts but invalid base64 in payload): 401", async () => {
|
||||
// Three "."-separated parts so the JWT shape gate sees it as a
|
||||
// candidate, but the payload segment is non-base64 garbage.
|
||||
// Validator must surface this as 401, not 500.
|
||||
const malformed = "eyJhbGciOiJIUzI1NiJ9.@@@notbase64@@@.signature";
|
||||
const res = await postWithJwt(malformed);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// The auth layer resolves the JWT's env from the `sub` claim — NOT
|
||||
// from the route path. So a JWT for env A hitting a route that
|
||||
// fetches a resource from env B should never accidentally see env
|
||||
// B's data. Test by minting a JWT for env A and asking for a
|
||||
// resource that lives in env B — expect 404 (not 200).
|
||||
describe("Cross-environment: JWT auth resolves env from sub, not URL", () => {
|
||||
it("env A's JWT cannot read env B's resource: 404", async () => {
|
||||
const server = getTestServer();
|
||||
const a = await seedTestEnvironment(server.prisma);
|
||||
const b = await seedTestEnvironment(server.prisma);
|
||||
|
||||
// Seed a real-ish run row in env B so the route would have
|
||||
// something to find IF auth resolved the env from the URL.
|
||||
const friendlyId = `run_${Math.random().toString(36).slice(2, 10)}`;
|
||||
await server.prisma.taskRun.create({
|
||||
data: {
|
||||
friendlyId,
|
||||
taskIdentifier: "test-task",
|
||||
payload: "{}",
|
||||
payloadType: "application/json",
|
||||
traceId: `trace_${Math.random().toString(36).slice(2)}`,
|
||||
spanId: `span_${Math.random().toString(36).slice(2)}`,
|
||||
runtimeEnvironmentId: b.environment.id,
|
||||
projectId: b.project.id,
|
||||
organizationId: b.organization.id,
|
||||
engine: "V2",
|
||||
status: "COMPLETED_SUCCESSFULLY",
|
||||
queue: "task/test-task",
|
||||
},
|
||||
});
|
||||
|
||||
const jwt = await generateJWT({
|
||||
secretKey: a.apiKey,
|
||||
payload: { pub: true, sub: a.environment.id, scopes: ["read:runs"] },
|
||||
expirationTime: "15m",
|
||||
});
|
||||
|
||||
const res = await server.webapp.fetch(`/api/v1/runs/${friendlyId}/result`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
// The route resolves runs scoped to the JWT's env (env A). The
|
||||
// run lives in env B, so env A's view returns "not found" —
|
||||
// critically, NOT 200.
|
||||
expect(res.status).not.toBe(200);
|
||||
expect([401, 404]).toContain(res.status);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user