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:
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user