Skip to content

Commit ddf9160

Browse files
carderneTrigger.dev RepoOps
authored andcommitted
fix(webapp): cap project JWT lifetime at 24 hours
Project JWT minting now rejects expiration times longer than 24 hours, using the same validation as the API-key JWT mint route. Delegated tokens remain limited by their own expiry. Mono-RevId: 6ffd715d174118c1262296258c240b92c62cc766
1 parent 44feaaf commit ddf9160

7 files changed

Lines changed: 115 additions & 68 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Project JWT minting now rejects expiration times longer than 24 hours.

‎apps/webapp/app/routes/api.v1.auth.jwt.ts‎

Lines changed: 5 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
22
import { json } from "@remix-run/server-runtime";
33
import { generateJWT as internal_generateJWT } from "@trigger.dev/core/v3";
44
import { resolveJwtSigningKey, scopesWithinAbility } from "@trigger.dev/rbac";
5-
import parseDuration from "parse-duration";
65
import { z } from "zod";
76
import { authenticateBearerWithTelemetry } from "~/services/authTelemetry.server";
87
import { isApiKeyInGraceWindow, presentedBearerToken } from "~/services/apiKeyGraceWindow.server";
8+
import { validateJwtExpiration } from "~/services/jwtExpiration.server";
99
import { logger } from "~/services/logger.server";
1010
import { publicTokenSubject } from "~/services/publicTokenSubject.server";
1111

@@ -18,35 +18,6 @@ const RequestBodySchema = z.object({
1818
expirationTime: z.union([z.number(), z.string()]).optional(),
1919
});
2020

21-
const DEFAULT_EXPIRY = "1h";
22-
// A minted public JWT may be valid for at most 24 hours. A caller-controlled,
23-
// uncapped `expirationTime` ("100y") would otherwise mint a token that no key
24-
// rotation can revoke — public JWTs are not invalidated by rotating the
25-
// environment's key. The cap is the compensating control for that.
26-
const MAX_EXPIRY_SECONDS = 24 * 60 * 60;
27-
28-
// A requested `expirationTime` above this (epoch seconds, ~2001) is an absolute
29-
// timestamp; a smaller number is a relative offset in seconds.
30-
const EXPIRY_EPOCH_THRESHOLD_SECONDS = 1_000_000_000;
31-
32-
// Resolve the requested expiry to an absolute epoch-second timestamp so it can
33-
// be capped. Returns undefined when a string can't be parsed as a duration.
34-
function resolveRequestedExpirySeconds(
35-
expirationTime: number | string | undefined,
36-
nowSec: number
37-
): number | undefined {
38-
if (typeof expirationTime === "number") {
39-
return expirationTime > EXPIRY_EPOCH_THRESHOLD_SECONDS
40-
? expirationTime
41-
: nowSec + expirationTime;
42-
}
43-
const durationMs = parseDuration(expirationTime ?? DEFAULT_EXPIRY);
44-
if (durationMs == null) {
45-
return undefined;
46-
}
47-
return nowSec + Math.floor(durationMs / 1000);
48-
}
49-
5021
export async function action({ request }: LoaderFunctionArgs) {
5122
try {
5223
// Authenticate through the RBAC bearer controller (not the legacy
@@ -108,15 +79,9 @@ export async function action({ request }: LoaderFunctionArgs) {
10879
}
10980

11081
const nowSec = Math.floor(Date.now() / 1000);
111-
const requestedAbsSec = resolveRequestedExpirySeconds(parsedBody.data.expirationTime, nowSec);
112-
if (requestedAbsSec === undefined) {
113-
return json({ error: "Invalid expiration time" }, { status: 400 });
114-
}
115-
if (requestedAbsSec <= nowSec) {
116-
return json({ error: "Expiration time must be in the future" }, { status: 400 });
117-
}
118-
if (requestedAbsSec - nowSec > MAX_EXPIRY_SECONDS) {
119-
return json({ error: "Expiration time cannot exceed 24 hours" }, { status: 400 });
82+
const expiry = validateJwtExpiration(parsedBody.data.expirationTime, nowSec);
83+
if (expiry.error !== undefined) {
84+
return json({ error: expiry.error }, { status: 400 });
12085
}
12186

12287
const claims = {
@@ -132,7 +97,7 @@ export async function action({ request }: LoaderFunctionArgs) {
13297
const jwt = await internal_generateJWT({
13398
secretKey: resolveJwtSigningKey(authenticationResult.environment),
13499
payload: claims,
135-
expirationTime: requestedAbsSec,
100+
expirationTime: expiry.expirationTime,
136101
});
137102

138103
return json({ token: jwt });

‎apps/webapp/app/routes/api.v1.projects.$projectRef.$env.jwt.ts‎

Lines changed: 9 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import {
88
verifyUserActorToken,
99
type UserActorClaims,
1010
} from "@trigger.dev/rbac";
11-
import parseDuration from "parse-duration";
1211
import { z } from "zod";
1312
import {
1413
authenticatedEnvironmentForAuthentication,
@@ -18,6 +17,7 @@ import {
1817
} from "~/services/apiAuth.server";
1918
import { env as appEnv } from "~/env.server";
2019
import { assertSourcePatActive } from "~/services/personalAccessToken.server";
20+
import { validateJwtExpiration } from "~/services/jwtExpiration.server";
2121
import { logger } from "~/services/logger.server";
2222
import { authorizePatEnvironmentAccess } from "~/services/environmentVariableApiAccess.server";
2323
import { publicTokenSubject } from "~/services/publicTokenSubject.server";
@@ -36,27 +36,6 @@ const RequestBodySchema = z.object({
3636
expirationTime: z.union([z.number(), z.string()]).optional(),
3737
});
3838

39-
// A requested `expirationTime` above this (epoch seconds, ~2001) is an absolute
40-
// timestamp; a smaller number is a relative offset in seconds.
41-
const EXPIRY_EPOCH_THRESHOLD_SECONDS = 1_000_000_000;
42-
const DEFAULT_EXPIRY = "1h";
43-
44-
// Resolve the requested expiry to an absolute epoch-second timestamp so it can be
45-
// clamped against a delegated token's own expiry.
46-
function resolveRequestedExpirySeconds(
47-
expirationTime: number | string | undefined,
48-
nowSec: number
49-
): number {
50-
if (typeof expirationTime === "number") {
51-
return expirationTime > EXPIRY_EPOCH_THRESHOLD_SECONDS
52-
? expirationTime
53-
: nowSec + expirationTime;
54-
}
55-
const durationMs = parseDuration(expirationTime ?? DEFAULT_EXPIRY);
56-
const seconds = durationMs != null ? Math.floor(durationMs / 1000) : 60 * 60;
57-
return nowSec + seconds;
58-
}
59-
6039
export async function action({ request, params }: ActionFunctionArgs) {
6140
try {
6241
const bearer = request.headers
@@ -194,14 +173,17 @@ export async function action({ request, params }: ActionFunctionArgs) {
194173
: {}),
195174
};
196175

197-
// A delegated token can't mint a longer-lived JWT than itself: clamp the
198-
// requested expiry to the token's own `exp`. Non-UAT callers are unchanged.
199176
const nowSec = Math.floor(Date.now() / 1000);
200-
const requestedAbsSec = resolveRequestedExpirySeconds(parsedBody.data.expirationTime, nowSec);
177+
const expiry = validateJwtExpiration(parsedBody.data.expirationTime, nowSec);
178+
if (expiry.error !== undefined) {
179+
return json({ error: expiry.error }, { status: 400 });
180+
}
181+
182+
// A delegated token can't mint a longer-lived JWT than itself.
201183
const expirationTime =
202184
isUat && userActor?.expiresAt !== undefined
203-
? Math.min(requestedAbsSec, userActor.expiresAt)
204-
: (parsedBody.data.expirationTime ?? DEFAULT_EXPIRY);
185+
? Math.min(expiry.expirationTime, userActor.expiresAt)
186+
: expiry.expirationTime;
205187

206188
const jwt = await internal_generateJWT({
207189
secretKey: runtimeEnv.apiKey,
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import parseDuration from "parse-duration";
2+
3+
const DEFAULT_EXPIRY = "1h";
4+
const MAX_EXPIRY_SECONDS = 24 * 60 * 60;
5+
// Preserve the mint routes' numeric convention: epoch timestamps above ~2001,
6+
// relative seconds otherwise.
7+
const EXPIRY_EPOCH_THRESHOLD_SECONDS = 1_000_000_000;
8+
9+
export function validateJwtExpiration(
10+
expirationTime: number | string | undefined,
11+
nowSec: number
12+
): { expirationTime: number; error?: never } | { error: string; expirationTime?: never } {
13+
let expiresAt: number;
14+
if (typeof expirationTime === "number") {
15+
expiresAt =
16+
expirationTime > EXPIRY_EPOCH_THRESHOLD_SECONDS ? expirationTime : nowSec + expirationTime;
17+
} else {
18+
const durationMs = parseDuration(expirationTime ?? DEFAULT_EXPIRY);
19+
if (durationMs == null) {
20+
return { error: "Invalid expiration time" };
21+
}
22+
expiresAt = nowSec + Math.floor(durationMs / 1000);
23+
}
24+
25+
if (!Number.isFinite(expiresAt)) {
26+
return { error: "Invalid expiration time" };
27+
}
28+
if (expiresAt <= nowSec) {
29+
return { error: "Expiration time must be in the future" };
30+
}
31+
if (expiresAt - nowSec > MAX_EXPIRY_SECONDS) {
32+
return { error: "Expiration time cannot exceed 24 hours" };
33+
}
34+
return { expirationTime: expiresAt };
35+
}

‎apps/webapp/test/auth-jwt-mint.e2e.full.test.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import { describe, expect, it } from "vitest";
1717
import { getTestServer } from "./helpers/sharedTestServer";
1818
import { seedTestEnvironment } from "./helpers/seedTestEnvironment";
19+
import { seedTestUserProject } from "./helpers/seedTestUserProject";
1920

2021
const ROUTE = "/api/v1/auth/jwt";
2122

@@ -37,6 +38,39 @@ function decodePayload(token: string): Record<string, unknown> {
3738
return JSON.parse(Buffer.from(base64, "base64").toString("utf8"));
3839
}
3940

41+
describe("POST /api/v1/projects/:projectRef/:env/jwt", () => {
42+
// Catches: PAT exchange bypassing the TTL cap or signing a different exp than it validated.
43+
it("bounds PAT-minted JWTs to 24 hours", async () => {
44+
const server = getTestServer();
45+
const { project, pat } = await seedTestUserProject(server.prisma);
46+
const exchange = (expirationTime?: string | number) =>
47+
server.webapp.fetch(`/api/v1/projects/${project.externalRef}/dev/jwt`, {
48+
method: "POST",
49+
headers: {
50+
Authorization: `Bearer ${pat.token}`,
51+
"Content-Type": "application/json",
52+
},
53+
body: JSON.stringify({ expirationTime }),
54+
});
55+
56+
for (const expirationTime of [undefined, "24h", 86400]) {
57+
const before = Math.floor(Date.now() / 1000);
58+
const response = await exchange(expirationTime);
59+
expect(response.status).toBe(200);
60+
const { token } = (await response.json()) as { token: string };
61+
const exp = decodePayload(token).exp as number;
62+
const expectedTtl = expirationTime === undefined ? 3600 : 86400;
63+
expect(exp).toBeGreaterThanOrEqual(before + expectedTtl);
64+
expect(exp).toBeLessThanOrEqual(Math.floor(Date.now() / 1000) + expectedTtl);
65+
}
66+
67+
for (const expirationTime of ["100y", 86401, Math.floor(Date.now() / 1000) + 172800]) {
68+
const response = await exchange(expirationTime);
69+
expect(response.status).toBe(400);
70+
}
71+
});
72+
});
73+
4074
describe("POST /api/v1/auth/jwt", () => {
4175
it("mints a token for a valid root key, echoing the requested scopes", async () => {
4276
const server = getTestServer();
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { describe, expect, it } from "vitest";
2+
import { validateJwtExpiration } from "../app/services/jwtExpiration.server";
3+
4+
const now = 1_800_000_000;
5+
6+
describe("JWT expiration", () => {
7+
// Catches: alternate duration/numeric inputs bypassing the cap or invalid input silently defaulting.
8+
it.each(["100y", 86401, now + 86401, "invalid", 0, -1, now - 1, Infinity, NaN])(
9+
"rejects invalid or out-of-bounds expiration %s",
10+
(expirationTime) => {
11+
expect(validateJwtExpiration(expirationTime, now).error).toBeDefined();
12+
}
13+
);
14+
15+
// Catches: parsing changing the signed absolute expiry or rejecting the exact 24-hour boundary.
16+
it.each([
17+
[undefined, now + 3600],
18+
["1h", now + 3600],
19+
["24h", now + 86400],
20+
[86400, now + 86400],
21+
[now + 86400, now + 86400],
22+
] as const)("resolves %s to an absolute capped expiry", (input, expected) => {
23+
expect(validateJwtExpiration(input, now)).toEqual({ expirationTime: expected });
24+
});
25+
});

‎apps/webapp/test/uatEnvironmentClaim.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -695,7 +695,7 @@ describe("env JWT exchange — capless ceiling and TTL clamp", () => {
695695
const tokenExp = nowSec + 600;
696696
const token = await mintUat({ expirationTime: tokenExp });
697697

698-
const payload = await exchange(token, { expirationTime: "365d" });
698+
const payload = await exchange(token, { expirationTime: "24h" });
699699

700700
expect(payload.exp).toBeLessThanOrEqual(tokenExp + 1);
701701
expect(payload.exp).toBeGreaterThan(nowSec + 500);

0 commit comments

Comments
 (0)