fix(webapp): clamp an env-JWT's lifetime to the delegated token's expiry

A user-actor token could be exchanged for an env JWT that outlived it by
requesting a longer expirationTime. Surface the token's exp as expiresAt
and clamp the minted JWT to it. Non-UAT exchanges are unchanged.
This commit is contained in:
Katia Bulatova
2026-08-10 14:19:12 +00:00
parent 35ab0c51fc
commit 6cb06314f5
4 changed files with 56 additions and 2 deletions
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---
A delegated token can no longer be exchanged for a longer-lived token than itself.
@@ -8,6 +8,7 @@ import {
verifyUserActorToken,
type UserActorClaims,
} from "@trigger.dev/rbac";
import parseDuration from "parse-duration";
import { z } from "zod";
import {
authenticatedEnvironmentForAuthentication,
@@ -34,6 +35,27 @@ const RequestBodySchema = z.object({
expirationTime: z.union([z.number(), z.string()]).optional(),
});
// A requested `expirationTime` above this (epoch seconds, ~2001) is an absolute
// timestamp; a smaller number is a relative offset in seconds.
const EXPIRY_EPOCH_THRESHOLD_SECONDS = 1_000_000_000;
const DEFAULT_EXPIRY = "1h";
// Resolve the requested expiry to an absolute epoch-second timestamp so it can be
// clamped against a delegated token's own expiry.
function resolveRequestedExpirySeconds(
expirationTime: number | string | undefined,
nowSec: number
): number {
if (typeof expirationTime === "number") {
return expirationTime > EXPIRY_EPOCH_THRESHOLD_SECONDS
? expirationTime
: nowSec + expirationTime;
}
const durationMs = parseDuration(expirationTime ?? DEFAULT_EXPIRY);
const seconds = durationMs != null ? Math.floor(durationMs / 1000) : 60 * 60;
return nowSec + seconds;
}
export async function action({ request, params }: ActionFunctionArgs) {
try {
const bearer = request.headers
@@ -160,10 +182,19 @@ export async function action({ request, params }: ActionFunctionArgs) {
: {}),
};
// A delegated token can't mint a longer-lived JWT than itself: clamp the
// requested expiry to the token's own `exp`. Non-UAT callers are unchanged.
const nowSec = Math.floor(Date.now() / 1000);
const requestedAbsSec = resolveRequestedExpirySeconds(parsedBody.data.expirationTime, nowSec);
const expirationTime =
isUat && userActor?.expiresAt !== undefined
? Math.min(requestedAbsSec, userActor.expiresAt)
: (parsedBody.data.expirationTime ?? DEFAULT_EXPIRY);
const jwt = await internal_generateJWT({
secretKey: runtimeEnv.apiKey,
payload: claims,
expirationTime: parsedBody.data.expirationTime ?? "1h",
expirationTime,
});
return json({ token: jwt });
+13 -1
View File
@@ -539,12 +539,13 @@ describe("env JWT exchange — capless ceiling and TTL clamp", () => {
return JSON.parse(Buffer.from(jwt.split(".")[1]!, "base64url").toString());
}
function mintUat(opts: { cap?: string[] } = {}) {
function mintUat(opts: { cap?: string[]; expirationTime?: number } = {}) {
return signUserActorToken(SESSION_SECRET, {
userId: USER_ID,
client: "dashboard-agent",
environmentId: ENV_A.id,
...(opts.cap ? { cap: opts.cap } : {}),
...(opts.expirationTime ? { expirationTime: opts.expirationTime } : {}),
});
}
@@ -621,4 +622,15 @@ describe("env JWT exchange — capless ceiling and TTL clamp", () => {
expect(payload.scopes).toEqual(requested);
});
it("clamps the minted JWT lifetime to the token's own expiry", async () => {
const nowSec = Math.floor(Date.now() / 1000);
const tokenExp = nowSec + 600;
const token = await mintUat({ expirationTime: tokenExp });
const payload = await exchange(token, { expirationTime: "365d" });
expect(payload.exp).toBeLessThanOrEqual(tokenExp + 1);
expect(payload.exp).toBeGreaterThan(nowSec + 500);
});
});
+5
View File
@@ -297,6 +297,10 @@ export type UserActorClaims = {
// Optional scope cap (e.g. `["read:runs"]`) — ceilings the token below the
// user's role. Absent today; the auth path is already cap-ready.
cap?: string[];
// The token's own expiry (`exp`, epoch seconds) when signed with one. Used to
// clamp the lifetime of anything minted from it — a delegated token can't be
// exchanged for a longer-lived one.
expiresAt?: number;
};
export function isUserActorToken(token: string): boolean {
@@ -353,6 +357,7 @@ export async function verifyUserActorToken(
sessionId: act?.sessionId,
environmentId: act?.environmentId,
cap: Array.isArray(payload.cap) ? (payload.cap as string[]) : undefined,
expiresAt: typeof payload.exp === "number" ? payload.exp : undefined,
};
}