Skip to content

Commit 73ad39a

Browse files
committed
JWT/realtime token integration: publicJWT subject, jwt metadata, allowJWT option, buildJwtAbility
1 parent c9af9a8 commit 73ad39a

6 files changed

Lines changed: 162 additions & 14 deletions

File tree

internal-packages/rbac/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55
"main": "./dist/src/index.js",
66
"types": "./dist/src/index.d.ts",
77
"dependencies": {
8+
"@trigger.dev/core": "workspace:*",
89
"@trigger.dev/plugins": "workspace:*"
910
},
1011
"devDependencies": {
12+
"@trigger.dev/database": "workspace:*",
1113
"@types/node": "^20.14.14",
1214
"rimraf": "6.0.1"
1315
},

internal-packages/rbac/src/ability.test.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from "vitest";
2-
import { permissiveAbility, superAbility, denyAbility, buildFallbackAbility } from "./ability.js";
2+
import { permissiveAbility, superAbility, denyAbility, buildFallbackAbility, buildJwtAbility } from "./ability.js";
33

44
describe("permissiveAbility", () => {
55
it("allows any action on any resource type", () => {
@@ -39,6 +39,50 @@ describe("denyAbility", () => {
3939
});
4040
});
4141

42+
describe("buildJwtAbility", () => {
43+
it("allows action matching a general scope", () => {
44+
const ability = buildJwtAbility(["read:runs"]);
45+
expect(ability.can("read", { type: "runs" })).toBe(true);
46+
expect(ability.can("read", { type: "runs", id: "run_abc" })).toBe(true);
47+
});
48+
49+
it("allows only the specific ID for a scoped permission", () => {
50+
const ability = buildJwtAbility(["read:runs:run_abc"]);
51+
expect(ability.can("read", { type: "runs", id: "run_abc" })).toBe(true);
52+
expect(ability.can("read", { type: "runs", id: "run_xyz" })).toBe(false);
53+
expect(ability.can("read", { type: "runs" })).toBe(false);
54+
});
55+
56+
it("allows any read with read:all scope", () => {
57+
const ability = buildJwtAbility(["read:all"]);
58+
expect(ability.can("read", { type: "runs" })).toBe(true);
59+
expect(ability.can("read", { type: "tasks" })).toBe(true);
60+
expect(ability.can("write", { type: "runs" })).toBe(false);
61+
});
62+
63+
it("allows everything with admin scope", () => {
64+
const ability = buildJwtAbility(["admin"]);
65+
expect(ability.can("read", { type: "runs" })).toBe(true);
66+
expect(ability.can("write", { type: "deployments" })).toBe(true);
67+
});
68+
69+
it("never grants canSuper", () => {
70+
expect(buildJwtAbility(["admin"]).canSuper()).toBe(false);
71+
expect(buildJwtAbility(["read:all"]).canSuper()).toBe(false);
72+
expect(buildJwtAbility([]).canSuper()).toBe(false);
73+
});
74+
75+
it("denies everything for empty scopes", () => {
76+
const ability = buildJwtAbility([]);
77+
expect(ability.can("read", { type: "runs" })).toBe(false);
78+
});
79+
80+
it("denies wrong action with general resource scope", () => {
81+
const ability = buildJwtAbility(["read:runs"]);
82+
expect(ability.can("write", { type: "runs" })).toBe(false);
83+
});
84+
});
85+
4286
describe("buildFallbackAbility", () => {
4387
it("returns permissiveAbility for non-admin users", () => {
4488
const ability = buildFallbackAbility(false);

internal-packages/rbac/src/ability.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,23 @@ export const denyAbility: RbacAbility = {
2121
export function buildFallbackAbility(isAdmin: boolean): RbacAbility {
2222
return isAdmin ? superAbility : permissiveAbility;
2323
}
24+
25+
/** Builds an ability from JWT scope strings like "read:runs", "read:runs:run_abc", "read:all", "admin". */
26+
export function buildJwtAbility(scopes: string[]): RbacAbility {
27+
return {
28+
can(action: string, resource: { type: string; id?: string }): boolean {
29+
return scopes.some((scope) => {
30+
const [scopeAction, scopeType, scopeId] = scope.split(":");
31+
if (scopeAction === "admin") return true;
32+
if (scopeAction !== action && scopeAction !== "*") return false;
33+
if (scopeType === "all") return true;
34+
if (scopeType !== resource.type) return false;
35+
if (!scopeId) return true;
36+
return scopeId === resource.id;
37+
});
38+
},
39+
canSuper(): boolean {
40+
return false;
41+
},
42+
};
43+
}

internal-packages/rbac/src/fallback.ts

Lines changed: 76 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import type {
1010
RoleBaseAccessController,
1111
} from "@trigger.dev/plugins";
1212
import type { PrismaClient } from "@trigger.dev/database";
13-
import { buildFallbackAbility, permissiveAbility } from "./ability.js";
13+
import { validateJWT } from "@trigger.dev/core/v3/jwt";
14+
import { buildFallbackAbility, buildJwtAbility, permissiveAbility } from "./ability.js";
1415

1516
export class RoleBaseAccessFallback {
1617
constructor(private readonly prisma: PrismaClient) {}
@@ -28,12 +29,55 @@ class RoleBaseAccessFallbackController implements RoleBaseAccessController {
2829
private readonly helpers: { getSessionUserId: (request: Request) => Promise<string | null> }
2930
) {}
3031

31-
async authenticateBearer(request: Request): Promise<BearerAuthResult> {
32-
const apiKey = request.headers.get("Authorization")?.replace(/^Bearer /, "").trim();
33-
if (!apiKey) return { ok: false, status: 401, error: "Invalid or Missing API key" };
32+
async authenticateBearer(
33+
request: Request,
34+
options?: { allowJWT?: boolean }
35+
): Promise<BearerAuthResult> {
36+
const rawToken = request.headers.get("Authorization")?.replace(/^Bearer /, "").trim();
37+
if (!rawToken) return { ok: false, status: 401, error: "Invalid or Missing API key" };
38+
39+
if (options?.allowJWT && isPublicJWT(rawToken)) {
40+
const envId = extractJWTSub(rawToken);
41+
if (!envId) return { ok: false, status: 401, error: "Invalid Public Access Token" };
42+
43+
const env = await this.prisma.runtimeEnvironment.findFirst({
44+
where: { id: envId },
45+
include: {
46+
project: true,
47+
organization: true,
48+
parentEnvironment: { select: { apiKey: true } },
49+
},
50+
});
51+
if (!env || env.project.deletedAt !== null) {
52+
return { ok: false, status: 401, error: "Invalid Public Access Token" };
53+
}
54+
55+
const signingKey = env.parentEnvironment?.apiKey ?? env.apiKey;
56+
const result = await validateJWT(rawToken, signingKey);
57+
if (!result.ok) return { ok: false, status: 401, error: "Public Access Token is invalid" };
58+
59+
const scopes = Array.isArray(result.payload.scopes)
60+
? (result.payload.scopes as string[])
61+
: [];
62+
const realtime = result.payload.realtime as { skipColumns?: string[] } | undefined;
63+
const oneTimeUse = result.payload.otu === true;
64+
65+
return {
66+
ok: true,
67+
environment: toRbacEnvironment(env),
68+
subject: {
69+
type: "publicJWT",
70+
environmentId: env.id,
71+
organizationId: env.organizationId,
72+
projectId: env.projectId,
73+
},
74+
ability: buildJwtAbility(scopes),
75+
jwt: { realtime, oneTimeUse },
76+
};
77+
}
3478

3579
const env = await this.prisma.runtimeEnvironment.findFirst({
36-
where: { apiKey },
80+
where: { apiKey: rawToken },
3781
include: {
3882
project: true,
3983
organization: true,
@@ -87,9 +131,10 @@ class RoleBaseAccessFallbackController implements RoleBaseAccessController {
87131

88132
async authenticateAuthorizeBearer(
89133
request: Request,
90-
check: { action: string; resource: RbacResource }
134+
check: { action: string; resource: RbacResource },
135+
options?: { allowJWT?: boolean }
91136
): Promise<BearerAuthResult> {
92-
const auth = await this.authenticateBearer(request);
137+
const auth = await this.authenticateBearer(request, options);
93138
if (!auth.ok) return auth;
94139
if (!auth.ability.can(check.action, check.resource)) {
95140
return { ok: false, status: 403, error: "Unauthorized" };
@@ -143,6 +188,30 @@ class RoleBaseAccessFallbackController implements RoleBaseAccessController {
143188
async removeTokenRole(): Promise<void> {}
144189
}
145190

191+
function isPublicJWT(token: string): boolean {
192+
const parts = token.split(".");
193+
if (parts.length !== 3) return false;
194+
try {
195+
const payload = JSON.parse(Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"));
196+
return payload !== null && typeof payload === "object" && payload.pub === true;
197+
} catch {
198+
return false;
199+
}
200+
}
201+
202+
function extractJWTSub(token: string): string | undefined {
203+
const parts = token.split(".");
204+
if (parts.length !== 3) return undefined;
205+
try {
206+
const payload = JSON.parse(Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"));
207+
return payload !== null && typeof payload === "object" && typeof payload.sub === "string"
208+
? payload.sub
209+
: undefined;
210+
} catch {
211+
return undefined;
212+
}
213+
}
214+
146215
function toRbacEnvironment(
147216
env: {
148217
id: string;
Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
{
22
"compilerOptions": {
3+
"target": "ES2019",
4+
"lib": ["ES2019", "DOM"],
5+
"module": "ESNext",
6+
"moduleResolution": "Bundler",
37
"esModuleInterop": true,
48
"forceConsistentCasingInFileNames": true,
59
"isolatedModules": true,
6-
"moduleResolution": "node",
710
"preserveWatchOutput": true,
811
"skipLibCheck": true,
912
"noEmit": true,
10-
"strict": true
13+
"strict": true,
14+
"customConditions": ["@triggerdotdev/source"]
1115
},
1216
"exclude": ["node_modules"]
1317
}

packages/plugins/src/rbac.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ export type Role = {
1313

1414
export type RbacSubject =
1515
| { type: "user"; userId: string; organizationId: string; projectId?: string }
16-
| { type: "personalAccessToken"; tokenId: string; organizationId: string; projectId?: string };
16+
| { type: "personalAccessToken"; tokenId: string; organizationId: string; projectId?: string }
17+
| { type: "publicJWT"; environmentId: string; organizationId: string; projectId?: string };
1718

1819
export type RbacResource = {
1920
type: string;
@@ -51,15 +52,22 @@ export interface RbacAbility {
5152

5253
export type BearerAuthResult =
5354
| { ok: false; status: 401 | 403; error: string }
54-
| { ok: true; environment: RbacEnvironment; subject: RbacSubject; ability: RbacAbility };
55+
| {
56+
ok: true;
57+
environment: RbacEnvironment;
58+
subject: RbacSubject;
59+
ability: RbacAbility;
60+
jwt?: { realtime?: { skipColumns?: string[] }; oneTimeUse?: boolean };
61+
};
5562

5663
export type SessionAuthResult =
5764
| { ok: false; reason: "unauthenticated" | "unauthorized" }
5865
| { ok: true; user: RbacUser; subject: RbacSubject; ability: RbacAbility };
5966

6067
export interface RoleBaseAccessController {
6168
// API routes (Bearer token): one DB query → identity + pre-built ability
62-
authenticateBearer(request: Request): Promise<BearerAuthResult>;
69+
// options.allowJWT: when true, accepts PUBLIC_JWT tokens in addition to environment API keys
70+
authenticateBearer(request: Request, options?: { allowJWT?: boolean }): Promise<BearerAuthResult>;
6371

6472
// Dashboard loaders/actions (session cookie): one DB query → user + pre-built ability
6573
authenticateSession(
@@ -70,7 +78,8 @@ export interface RoleBaseAccessController {
7078
// Convenience: authenticate + ability.can() check in one call; returns ok:false if check fails
7179
authenticateAuthorizeBearer(
7280
request: Request,
73-
check: { action: string; resource: RbacResource }
81+
check: { action: string; resource: RbacResource },
82+
options?: { allowJWT?: boolean }
7483
): Promise<BearerAuthResult>;
7584

7685
authenticateAuthorizeSession(

0 commit comments

Comments
 (0)