217 lines
9.0 KiB
TypeScript
217 lines
9.0 KiB
TypeScript
// 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);
|
|
});
|
|
});
|
|
});
|