Skip to content

Commit 06969b2

Browse files
authored
feat(cli,webapp): mint short-lived delegated tokens that act as a user (#3997)
## Summary Adds a short-lived, delegated token (`tr_uat_...`) that authenticates against the API as a user without handing out a long-lived personal access token. You mint one from a PAT, optionally narrow it to a set of scopes, and give it a lifetime; the API then treats requests as that user, subject to their role. `trigger.dev mint-token` is the entry point (it uses your stored PAT): ```bash UAT=$(trigger.dev mint-token --ttl 3600 --cap read:runs) ``` The token works anywhere a PAT does for user-level endpoints, and can be exchanged for an environment JWT at `POST /api/v1/projects/:ref/:env/jwt` to reach environment-scoped data (the same exchange a PAT supports). ## How it works A user-actor token is a short-lived JWT verified by a new first-class `authenticateUserActor` method on the RBAC plugin. Self-hosters get a built-in fallback; role-aware enforcement comes from the plugin. Effective permissions are the intersection of the user's role and the token's optional scope cap, so a token is only ever narrower than the user, never broader. Minting is restricted to personal access tokens (a token can't mint another one, and an environment key can't mint one). Tokens default to a 1 hour lifetime (max 365 days). When exchanged for an environment JWT, the user is stamped on it for attribution and the scope cap is carried through.
1 parent 315baf2 commit 06969b2

14 files changed

Lines changed: 511 additions & 46 deletions

File tree

.changeset/mint-token-command.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"trigger.dev": patch
3+
---
4+
5+
Adds `trigger.dev mint-token`, which mints a short-lived delegated token from your stored personal access token. The token authenticates against the API as you, can be narrowed with `--cap` and given a lifetime with `--ttl`, and prints to stdout so it can be captured.
6+
7+
```bash
8+
UAT=$(trigger.dev mint-token --ttl 3600 --cap read:runs)
9+
```
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
2+
import { signUserActorToken } from "@trigger.dev/rbac";
3+
import { z } from "zod";
4+
import { env } from "~/env.server";
5+
import { logger } from "~/services/logger.server";
6+
import { rbac } from "~/services/rbac.server";
7+
8+
// Callers pick the TTL (default 1h) up to a hard ceiling; renewal = mint again
9+
// with the PAT. The default is short, but the ceiling allows long-lived tokens
10+
// for callers that need them (e.g. a long-running integration).
11+
const DEFAULT_UAT_TTL_SECONDS = 60 * 60; // 1 hour
12+
const MAX_UAT_TTL_SECONDS = 365 * 24 * 60 * 60; // 365 days
13+
14+
// Mint a short-lived delegated user-actor token (`tr_uat_`) from a personal
15+
// access token. A UAT is a strict downgrade of the PAT: same user identity,
16+
// short-lived, optionally narrowed by `cap`. It lets a holder (an agent, the
17+
// MCP server, an IDE) act as the user without carrying a long-lived PAT.
18+
const RequestBodySchema = z
19+
.object({
20+
// Optional scope cap (e.g. ["read:runs"]) — ceilings the UAT below the
21+
// user's role. Absent → identity-only, floored by the user's role at
22+
// use-time.
23+
cap: z.array(z.string()).optional(),
24+
// Attribution label recorded in the token's `act.client` (e.g. the agent
25+
// or tool that requested it).
26+
client: z.string().min(1).max(255).optional(),
27+
// Lifetime in seconds. Omitted → 1h. Over the ceiling → 400 (we don't
28+
// silently clamp, so a caller never thinks it got longer than it did).
29+
ttlSeconds: z.number().int().positive().max(MAX_UAT_TTL_SECONDS).optional(),
30+
})
31+
.optional();
32+
33+
export async function action({ request }: ActionFunctionArgs) {
34+
try {
35+
// Mint only from a real PAT. authenticatePat requires the `tr_pat_`
36+
// prefix, so a UAT can't mint another UAT (no indefinite renewal) and an
37+
// env API key / OAT can't mint one either.
38+
const patAuth = await rbac.authenticatePat(request, {});
39+
if (!patAuth.ok) {
40+
return json({ error: patAuth.error }, { status: patAuth.status });
41+
}
42+
43+
// A role-restricted PAT (one with a TokenRole cap) can't mint a UAT: the
44+
// UAT is floored by the user's role at use-time and wouldn't carry the
45+
// PAT's narrower ceiling, so minting would widen the grant. Reject rather
46+
// than silently escalate. (The OSS fallback has no TokenRoles, so this
47+
// only takes effect with the cloud RBAC plugin installed.)
48+
const tokenRole = await rbac.getTokenRole(patAuth.tokenId);
49+
if (tokenRole) {
50+
return json(
51+
{
52+
error:
53+
"Cannot mint a user-actor token from a role-restricted personal access token",
54+
},
55+
{ status: 403 }
56+
);
57+
}
58+
59+
const parsedBody = RequestBodySchema.safeParse(await request.json().catch(() => ({})));
60+
if (!parsedBody.success) {
61+
return json(
62+
{ error: "Invalid request body", issues: parsedBody.error.issues },
63+
{ status: 400 }
64+
);
65+
}
66+
const body = parsedBody.data ?? {};
67+
const ttlSeconds = body.ttlSeconds ?? DEFAULT_UAT_TTL_SECONDS;
68+
69+
const token = await signUserActorToken(env.SESSION_SECRET, {
70+
userId: patAuth.userId,
71+
client: body.client ?? "personal-access-token",
72+
cap: body.cap,
73+
// Absolute exp (seconds since epoch). jose treats a number as absolute.
74+
expirationTime: Math.floor(Date.now() / 1000) + ttlSeconds,
75+
});
76+
77+
return json({ token, expiresInSeconds: ttlSeconds });
78+
} catch (error) {
79+
if (error instanceof Response) throw error;
80+
logger.error("Failed to mint user-actor token", { error });
81+
return json({ error: "Internal Server Error" }, { status: 500 });
82+
}
83+
}

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

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import { type ActionFunctionArgs, json } from "@remix-run/node";
22
import { generateJWT as internal_generateJWT } from "@trigger.dev/core/v3";
3+
import { isUserActorToken, verifyUserActorToken } from "@trigger.dev/rbac";
34
import { z } from "zod";
45
import {
56
authenticatedEnvironmentForAuthentication,
67
authenticateRequest,
8+
type AuthenticationResult,
79
} from "~/services/apiAuth.server";
10+
import { env as appEnv } from "~/env.server";
811
import { logger } from "~/services/logger.server";
912
import { authorizePatEnvironmentAccess } from "~/services/environmentVariableApiAccess.server";
1013

@@ -24,11 +27,36 @@ const RequestBodySchema = z.object({
2427

2528
export async function action({ request, params }: ActionFunctionArgs) {
2629
try {
27-
const authenticationResult = await authenticateRequest(request, {
28-
personalAccessToken: true,
29-
organizationAccessToken: true,
30-
apiKey: false,
31-
});
30+
const bearer = request.headers.get("Authorization")?.replace(/^Bearer /, "").trim();
31+
const isUat = !!bearer && isUserActorToken(bearer);
32+
33+
// A delegated user-actor token authenticates as its user, like a PAT. We
34+
// resolve it here (not through authenticateRequest) so the exchange stays
35+
// scoped to this route — UATs deliberately aren't accepted on every
36+
// PAT route. `uatCap` (the token's optional scope cap) ceilings the
37+
// minted env JWT below.
38+
let uatCap: string[] | undefined;
39+
let userActorId: string | undefined;
40+
let authenticationResult: AuthenticationResult | undefined;
41+
if (isUat) {
42+
const claims = await verifyUserActorToken(appEnv.SESSION_SECRET, bearer!);
43+
if (!claims) {
44+
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
45+
}
46+
uatCap = claims.cap;
47+
userActorId = claims.userId;
48+
// The env lookup keys purely on the user, identical to a PAT.
49+
authenticationResult = {
50+
type: "personalAccessToken",
51+
result: { userId: claims.userId },
52+
};
53+
} else {
54+
authenticationResult = await authenticateRequest(request, {
55+
personalAccessToken: true,
56+
organizationAccessToken: true,
57+
apiKey: false,
58+
});
59+
}
3260

3361
if (!authenticationResult) {
3462
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
@@ -73,10 +101,27 @@ export async function action({ request, params }: ActionFunctionArgs) {
73101
);
74102
}
75103

104+
// The env JWT carries scopes only — downstream auth builds its ability
105+
// from them with no role context. So for a user-actor token we ceiling
106+
// the scopes by the token's own cap here (a read-only agent token can't
107+
// widen its grant through the exchange) and stamp the user via `act` so
108+
// the minted env JWT stays attributable. The cap is a ceiling, not a
109+
// replacement: intersect what the caller asked for with the cap (or use
110+
// the full cap if they asked for nothing). No cap → the request passes
111+
// through, same as a PAT.
112+
const requestedScopes = parsedBody.data.claims?.scopes;
113+
const scopes =
114+
isUat && uatCap
115+
? requestedScopes && requestedScopes.length > 0
116+
? requestedScopes.filter((scope) => uatCap.includes(scope))
117+
: uatCap
118+
: requestedScopes;
119+
76120
const claims = {
77121
sub: runtimeEnv.id,
78122
pub: true,
79-
...parsedBody.data.claims,
123+
...(scopes ? { scopes } : {}),
124+
...(userActorId ? { act: { sub: userActorId } } : {}),
80125
};
81126

82127
const jwt = await internal_generateJWT({

apps/webapp/app/services/environmentVariableApiAccess.server.ts

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { json } from "@remix-run/server-runtime";
22
import type { RuntimeEnvironmentType } from "@trigger.dev/database";
3+
import { isUserActorToken } from "@trigger.dev/rbac";
34
import { rbac } from "~/services/rbac.server";
45

56
type EnvironmentScopedResource = "envvars" | "apiKeys";
@@ -15,11 +16,12 @@ const RESOURCE_LABELS: Record<EnvironmentScopedResource, string> = {
1516
*
1617
* Machine credentials (an environment's secret/public API key) are already
1718
* scoped to a single environment, so they pass through unchanged. A personal
18-
* access token carries a user, so enforce that user's role for the targeted
19-
* environment tier — e.g. a Developer can't read deployed env vars or API keys
20-
* via the API, matching the dashboard restriction. Blocking the credential read
21-
* for deployed tiers is also what stops a restricted role deploying via the CLI
22-
* (deploy needs the environment's secret key).
19+
* access token (or a delegated user-actor token) carries a user, so enforce
20+
* that user's role for the targeted environment tier — e.g. a Developer can't
21+
* read deployed env vars or API keys via the API, matching the dashboard
22+
* restriction. Blocking the credential read for deployed tiers is also what
23+
* stops a restricted role deploying via the CLI (deploy needs the
24+
* environment's secret key).
2325
*
2426
* Returns a `Response` to short-circuit with when access is denied, or
2527
* `undefined` when the request may proceed.
@@ -41,16 +43,23 @@ export async function authorizePatEnvironmentAccess({
4143
resource: EnvironmentScopedResource;
4244
action: "read" | "write";
4345
}): Promise<Response | undefined> {
44-
if (authType !== "personalAccessToken") {
46+
const bearer = request.headers.get("Authorization")?.replace(/^Bearer /, "").trim();
47+
const isUat = !!bearer && isUserActorToken(bearer);
48+
49+
// Machine creds (apiKey) and org tokens carry no user role to enforce. A
50+
// user-actor token carries a user just like a PAT, so it's gated too.
51+
if (authType !== "personalAccessToken" && !isUat) {
4552
return undefined;
4653
}
4754

48-
const patAuth = await rbac.authenticatePat(request, { organizationId, projectId });
49-
if (!patAuth.ok) {
50-
return json({ error: patAuth.error }, { status: patAuth.status });
55+
const userAuth = isUat
56+
? await rbac.authenticateUserActor(request, { organizationId, projectId })
57+
: await rbac.authenticatePat(request, { organizationId, projectId });
58+
if (!userAuth.ok) {
59+
return json({ error: userAuth.error }, { status: userAuth.status });
5160
}
5261

53-
if (!patAuth.ability.can(action, { type: resource, envType })) {
62+
if (!userAuth.ability.can(action, { type: resource, envType })) {
5463
return json(
5564
{
5665
error: `You don't have permission to access this environment's ${RESOURCE_LABELS[resource]}.`,

apps/webapp/app/services/personalAccessToken.server.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { logger } from "./logger.server";
66
import { rbac } from "./rbac.server";
77
import { decryptToken, encryptToken, hashToken } from "~/utils/tokens.server";
88
import { env } from "~/env.server";
9+
import { isUserActorToken } from "@trigger.dev/rbac";
910

1011
const tokenValueLength = 40;
1112
//lowercase only, removed 0 and l to avoid confusion
@@ -165,6 +166,13 @@ export async function authenticateApiRequestWithPersonalAccessToken(
165166
return;
166167
}
167168

169+
// A user-actor token authenticates as the user wherever a PAT does.
170+
// The plugin verifies it (identity path → no org context to floor against).
171+
if (isUserActorToken(token)) {
172+
const result = await rbac.authenticateUserActor(request, {});
173+
return result.ok ? { userId: result.userId } : undefined;
174+
}
175+
168176
return authenticatePersonalAccessToken(token);
169177
}
170178

apps/webapp/app/services/rbac.server.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,7 @@ export const rbac = plugin.create(
2525
// $replica is structurally a PrismaClient minus `$transaction` — the
2626
// RBAC fallback only uses `findFirst` on it, so the cast is safe.
2727
{ primary: prisma, replica: $replica as PrismaClient },
28-
{ forceFallback: env.RBAC_FORCE_FALLBACK }
28+
// SESSION_SECRET signs delegated user-actor tokens; the plugin verifies
29+
// them with it in authenticateUserActor.
30+
{ forceFallback: env.RBAC_FORCE_FALLBACK, userActorSecret: env.SESSION_SECRET }
2931
);

apps/webapp/app/services/routeBuilders/apiBuilder.server.ts

Lines changed: 33 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { apiCors } from "~/utils/apiCors";
66
import { logger } from "../logger.server";
77
import { rbac } from "../rbac.server";
88
import type { RbacAbility, RbacResource } from "@trigger.dev/rbac";
9+
import { isUserActorToken } from "@trigger.dev/rbac";
910
import {
1011
PersonalAccessTokenAuthenticationResult,
1112
updateLastAccessedAtIfStale,
@@ -552,28 +553,39 @@ export function createLoaderPATApiRoute<
552553
// `updateLastAccessedAtIfStale` — no DB roundtrip when the
553554
// cached timestamp is fresher than the throttle window).
554555
const ctx = contextFn ? await contextFn(parsedParams, request) : {};
555-
const patAuth = await rbac.authenticatePat(request, ctx);
556-
if (!patAuth.ok) {
557-
return await wrapResponse(
558-
request,
559-
json({ error: patAuth.error }, { status: patAuth.status }),
560-
corsStrategy !== "none"
561-
);
562-
}
563556

564-
const authenticationResult: PersonalAccessTokenAuthenticationResult = {
565-
userId: patAuth.userId,
566-
};
567-
const ability: RbacAbility = patAuth.ability;
568-
569-
// Fire the `lastAccessedAt` write conditionally. Two-layer throttle:
570-
// JS skips the SQL when the value is fresh (most requests); the
571-
// SQL `WHERE` clause inside the helper is race-safe for concurrent
572-
// auths that both decide to fire. Don't `await` it from the
573-
// critical path? — it's a one-row update on a small hot table and
574-
// we want to surface failures, so it's awaited (same shape as the
575-
// legacy `authenticatePersonalAccessToken`).
576-
await updateLastAccessedAtIfStale(patAuth.tokenId, patAuth.lastAccessedAt);
557+
let authenticationResult: PersonalAccessTokenAuthenticationResult;
558+
let ability: RbacAbility;
559+
560+
const bearer = request.headers.get("Authorization")?.replace(/^Bearer /, "").trim();
561+
if (bearer && isUserActorToken(bearer)) {
562+
// A user-actor token validates + computes the cap-and-floor ability
563+
// in one call, same shape as a PAT.
564+
const uatAuth = await rbac.authenticateUserActor(request, ctx);
565+
if (!uatAuth.ok) {
566+
return await wrapResponse(
567+
request,
568+
json({ error: uatAuth.error }, { status: uatAuth.status }),
569+
corsStrategy !== "none"
570+
);
571+
}
572+
authenticationResult = { userId: uatAuth.userId };
573+
ability = uatAuth.ability;
574+
} else {
575+
// PAT: validate + compute the cap-and-floor ability in one query.
576+
const patAuth = await rbac.authenticatePat(request, ctx);
577+
if (!patAuth.ok) {
578+
return await wrapResponse(
579+
request,
580+
json({ error: patAuth.error }, { status: patAuth.status }),
581+
corsStrategy !== "none"
582+
);
583+
}
584+
authenticationResult = { userId: patAuth.userId };
585+
ability = patAuth.ability;
586+
// Throttled in the helper (no DB write when the cached value is fresh).
587+
await updateLastAccessedAtIfStale(patAuth.tokenId, patAuth.lastAccessedAt);
588+
}
577589

578590
if (authorization) {
579591
const $resource = authorization.resource(parsedParams, parsedSearchParams, parsedHeaders);

0 commit comments

Comments
 (0)