fix(webapp): correctly generate JWT tokens for preview branches after triggering a run (fix #2678) (#2695)

This commit is contained in:
Eric Allam
2025-11-19 13:42:12 +00:00
committed by GitHub
parent bee59de3a0
commit 6464eeed53
12 changed files with 207 additions and 57 deletions
@@ -11,6 +11,7 @@ import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { ApiAuthenticationResultSuccess, getOneTimeUseToken } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { extractJwtSigningSecretKey } from "~/services/realtime/jwtAuth.server";
import { determineRealtimeStreamsVersion } from "~/services/realtime/v1StreamsGlobal.server";
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { resolveIdempotencyKeyTTL } from "~/utils/idempotencyKeys.server";
@@ -85,7 +86,7 @@ const { action, loader } = createActionApiRoute(
isCached: false,
}),
buildResponseHeaders: async (responseBody, cachedEntity) => {
return await responseHeaders(cachedEntity, authentication, triggerClient);
return await responseHeaders(cachedEntity, authentication);
},
});
@@ -140,7 +141,12 @@ const { action, loader } = createActionApiRoute(
await saveRequestIdempotency(requestIdempotencyKey, "trigger", result.run.id);
const $responseHeaders = await responseHeaders(result.run, authentication, triggerClient);
const $responseHeaders = await responseHeaders(result.run, authentication);
logger.debug("responseHeaders authentication", {
authentication,
responseHeaders: $responseHeaders,
});
return json(
{
@@ -170,39 +176,26 @@ const { action, loader } = createActionApiRoute(
async function responseHeaders(
run: Pick<TaskRun, "friendlyId">,
authentication: ApiAuthenticationResultSuccess,
triggerClient?: string | null
authentication: ApiAuthenticationResultSuccess
): Promise<Record<string, string>> {
const { environment, realtime } = authentication;
const claimsHeader = JSON.stringify({
const claims = {
sub: environment.id,
pub: true,
scopes: [`read:runs:${run.friendlyId}`],
realtime,
};
const jwt = await internal_generateJWT({
secretKey: extractJwtSigningSecretKey(environment),
payload: claims,
expirationTime: "1h",
});
if (triggerClient === "browser") {
const claims = {
sub: environment.id,
pub: true,
scopes: [`read:runs:${run.friendlyId}`],
realtime,
};
const jwt = await internal_generateJWT({
secretKey: environment.apiKey,
payload: claims,
expirationTime: "1h",
});
return {
"x-trigger-jwt-claims": claimsHeader,
"x-trigger-jwt": jwt,
};
}
return {
"x-trigger-jwt-claims": claimsHeader,
"x-trigger-jwt-claims": JSON.stringify(claims),
"x-trigger-jwt": jwt,
};
}
+2 -1
View File
@@ -17,6 +17,7 @@ import {
import { OutOfEntitlementError } from "~/v3/services/triggerTask.server";
import { HeadersSchema } from "./api.v1.tasks.$taskId.trigger";
import { determineRealtimeStreamsVersion } from "~/services/realtime/v1StreamsGlobal.server";
import { extractJwtSigningSecretKey } from "~/services/realtime/jwtAuth.server";
const { action, loader } = createActionApiRoute(
{
@@ -163,7 +164,7 @@ async function responseHeaders(
};
const jwt = await generateJWT({
secretKey: environment.apiKey,
secretKey: extractJwtSigningSecretKey(environment),
payload: claims,
expirationTime: "1h",
});
+2 -1
View File
@@ -19,6 +19,7 @@ import { BatchProcessingStrategy } from "~/v3/services/batchTriggerV3.server";
import { OutOfEntitlementError } from "~/v3/services/triggerTask.server";
import { HeadersSchema } from "./api.v1.tasks.$taskId.trigger";
import { determineRealtimeStreamsVersion } from "~/services/realtime/v1StreamsGlobal.server";
import { extractJwtSigningSecretKey } from "~/services/realtime/jwtAuth.server";
const { action, loader } = createActionApiRoute(
{
@@ -178,7 +179,7 @@ async function responseHeaders(
};
const jwt = await generateJWT({
secretKey: environment.apiKey,
secretKey: extractJwtSigningSecretKey(environment),
payload: claims,
expirationTime: "1h",
});
@@ -236,6 +236,8 @@ async function authenticateApiKeyWithFailure(
case "PUBLIC_JWT": {
const validationResults = await validatePublicJwtKey(result.apiKey);
logger.debug("validatePublicJwtKey", { validationResults });
if (!validationResults.ok) {
return validationResults;
}
@@ -2,6 +2,7 @@ import { json } from "@remix-run/server-runtime";
import { validateJWT } from "@trigger.dev/core/v3/jwt";
import { findEnvironmentById } from "~/models/runtimeEnvironment.server";
import { AuthenticatedEnvironment } from "../apiAuth.server";
import { logger } from "../logger.server";
export type ValidatePublicJwtKeySuccess = {
ok: true;
@@ -38,6 +39,8 @@ export async function validatePublicJwtKey(token: string): Promise<ValidatePubli
environment.parentEnvironment?.apiKey ?? environment.apiKey
);
logger.debug("validateJWT result", { result });
if (!result.ok) {
switch (result.code) {
case "ERR_JWT_EXPIRED": {
@@ -89,6 +92,12 @@ export function isPublicJWT(token: string): boolean {
}
}
export function extractJwtSigningSecretKey(
environment: AuthenticatedEnvironment & { parentEnvironment?: { apiKey: string } }
) {
return environment.parentEnvironment?.apiKey ?? environment.apiKey;
}
function extractJWTSub(token: string): string | undefined {
// Split the token
const parts = token.split(".");
+16 -29
View File
@@ -2271,6 +2271,22 @@ importers:
specifier: workspace:*
version: link:../../packages/cli-v3
references/issue-2687:
dependencies:
'@trigger.dev/sdk':
specifier: workspace:*
version: link:../../packages/trigger-sdk
dotenv:
specifier: ^16.4.5
version: 16.4.7
tsx:
specifier: ^4.0.0
version: 4.20.6
devDependencies:
trigger.dev:
specifier: workspace:*
version: link:../../packages/cli-v3
references/nextjs-realtime:
dependencies:
'@ai-sdk/openai':
@@ -6546,7 +6562,6 @@ packages:
cpu: [ppc64]
os: [aix]
requiresBuild: true
dev: true
optional: true
/@esbuild/android-arm64@0.16.17:
@@ -6607,7 +6622,6 @@ packages:
cpu: [arm64]
os: [android]
requiresBuild: true
dev: true
optional: true
/@esbuild/android-arm@0.15.18:
@@ -6677,7 +6691,6 @@ packages:
cpu: [arm]
os: [android]
requiresBuild: true
dev: true
optional: true
/@esbuild/android-x64@0.16.17:
@@ -6738,7 +6751,6 @@ packages:
cpu: [x64]
os: [android]
requiresBuild: true
dev: true
optional: true
/@esbuild/darwin-arm64@0.16.17:
@@ -6799,7 +6811,6 @@ packages:
cpu: [arm64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/@esbuild/darwin-x64@0.16.17:
@@ -6860,7 +6871,6 @@ packages:
cpu: [x64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/@esbuild/freebsd-arm64@0.16.17:
@@ -6921,7 +6931,6 @@ packages:
cpu: [arm64]
os: [freebsd]
requiresBuild: true
dev: true
optional: true
/@esbuild/freebsd-x64@0.16.17:
@@ -6982,7 +6991,6 @@ packages:
cpu: [x64]
os: [freebsd]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-arm64@0.16.17:
@@ -7043,7 +7051,6 @@ packages:
cpu: [arm64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-arm@0.16.17:
@@ -7104,7 +7111,6 @@ packages:
cpu: [arm]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-ia32@0.16.17:
@@ -7165,7 +7171,6 @@ packages:
cpu: [ia32]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-loong64@0.15.18:
@@ -7235,7 +7240,6 @@ packages:
cpu: [loong64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-mips64el@0.16.17:
@@ -7296,7 +7300,6 @@ packages:
cpu: [mips64el]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-ppc64@0.16.17:
@@ -7357,7 +7360,6 @@ packages:
cpu: [ppc64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-riscv64@0.16.17:
@@ -7418,7 +7420,6 @@ packages:
cpu: [riscv64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-s390x@0.16.17:
@@ -7479,7 +7480,6 @@ packages:
cpu: [s390x]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-x64@0.16.17:
@@ -7540,7 +7540,6 @@ packages:
cpu: [x64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/netbsd-arm64@0.25.1:
@@ -7549,7 +7548,6 @@ packages:
cpu: [arm64]
os: [netbsd]
requiresBuild: true
dev: true
optional: true
/@esbuild/netbsd-x64@0.16.17:
@@ -7610,7 +7608,6 @@ packages:
cpu: [x64]
os: [netbsd]
requiresBuild: true
dev: true
optional: true
/@esbuild/openbsd-arm64@0.23.0:
@@ -7627,7 +7624,6 @@ packages:
cpu: [arm64]
os: [openbsd]
requiresBuild: true
dev: true
optional: true
/@esbuild/openbsd-x64@0.16.17:
@@ -7688,7 +7684,6 @@ packages:
cpu: [x64]
os: [openbsd]
requiresBuild: true
dev: true
optional: true
/@esbuild/sunos-x64@0.16.17:
@@ -7749,7 +7744,6 @@ packages:
cpu: [x64]
os: [sunos]
requiresBuild: true
dev: true
optional: true
/@esbuild/win32-arm64@0.16.17:
@@ -7810,7 +7804,6 @@ packages:
cpu: [arm64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@esbuild/win32-ia32@0.16.17:
@@ -7871,7 +7864,6 @@ packages:
cpu: [ia32]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@esbuild/win32-x64@0.16.17:
@@ -7932,7 +7924,6 @@ packages:
cpu: [x64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@eslint-community/eslint-utils@4.4.0(eslint@8.31.0):
@@ -25006,7 +24997,6 @@ packages:
'@esbuild/win32-arm64': 0.25.1
'@esbuild/win32-ia32': 0.25.1
'@esbuild/win32-x64': 0.25.1
dev: true
/escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
@@ -26482,7 +26472,6 @@ packages:
resolution: {integrity: sha512-ZAqrLlu18NbDdRaHq+AKXzAmqIUPswPWKUchfytdAjiRFnCe5ojG2bstg6mRiZabkKfCoL/e98pbBELIV/YCeA==}
dependencies:
resolve-pkg-maps: 1.0.0
dev: true
/get-uri@6.0.1:
resolution: {integrity: sha512-7ZqONUVqaabogsYNWlYj0t3YZaL6dhuEueZXGF+/YVmf6dHmaFg8/6psJKqhx9QykIDKzpGcy2cn4oV4YC7V/Q==}
@@ -33697,7 +33686,6 @@ packages:
/resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
dev: true
/resolve.exports@2.0.2:
resolution: {integrity: sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==}
@@ -36153,7 +36141,6 @@ packages:
get-tsconfig: 4.7.6
optionalDependencies:
fsevents: 2.3.3
dev: true
/tsx@4.7.1:
resolution: {integrity: sha512-8d6VuibXHtlN5E3zFkgY8u4DX7Y3Z27zvvPKVmLon/D4AjuKzarkUBTLDBgj9iTQ0hg5xM7c/mYiRVM+HETf0g==}
+3
View File
@@ -0,0 +1,3 @@
TRIGGER_SECRET_KEY=tr_dev_...
TRIGGER_API_URL=https://api.trigger.dev
TRIGGER_BRANCH=
+28
View File
@@ -0,0 +1,28 @@
# Issue 2687 Reproduction
This reference project reproduces the issue where `Realtime` returns 401 when using `createTriggerPublicToken`.
## Setup
1. Make sure your Trigger.dev instance is running (webapp).
2. Copy `.env.example` to `.env` and fill in your details:
```bash
cp .env.example .env
```
3. Edit `.env` to set your `TRIGGER_SECRET_KEY` and `TRIGGER_API_URL`.
## Running the reproduction
Run the reproduction script:
```bash
pnpm run repro
```
## What it does
1. Generates a `triggerPublicToken` for `issue-2687-task`.
2. Triggers the task using this token.
3. Attempts to connect to the Realtime endpoint for the resulting run using the same token.
+19
View File
@@ -0,0 +1,19 @@
{
"name": "references-issue-2687",
"private": true,
"type": "module",
"devDependencies": {
"trigger.dev": "workspace:*"
},
"dependencies": {
"@trigger.dev/sdk": "workspace:*",
"dotenv": "^16.4.5",
"tsx": "^4.0.0"
},
"scripts": {
"dev": "trigger dev",
"deploy": "trigger deploy",
"repro": "tsx src/repro.ts"
}
}
+81
View File
@@ -0,0 +1,81 @@
import "dotenv/config";
import { auth, tasks, runs, configure } from "@trigger.dev/sdk/v3";
const taskId = "issue-2687-task";
async function main() {
if (!process.env.TRIGGER_SECRET_KEY) {
console.error("TRIGGER_SECRET_KEY is not set. Please set it to your project's secret key.");
process.exit(1);
}
const apiUrl = process.env.TRIGGER_API_URL || "https://api.trigger.dev";
const branch = process.env.TRIGGER_PREVIEW_BRANCH;
const secretKey = process.env.TRIGGER_SECRET_KEY;
console.log(`Using API URL: ${apiUrl}`);
console.log(`Using Secret Key: ${secretKey}`);
if (branch) {
console.log(`Using Branch: ${branch}`);
}
// 1. Generate the public token (Server-side)
// We need the secret key for this part, so we use the environment variable which the SDK picks up automatically for this call.
console.log("Generating public token...");
try {
// Ensure we are using the correct API URL for the generation as well, if it matters (it validates against the env).
configure({ baseURL: apiUrl, accessToken: secretKey, previewBranch: branch });
const token = await auth.createTriggerPublicToken(taskId);
console.log("Token generated.");
// 2. Trigger the task using the public token
console.log(`Triggering task ${taskId}...`);
// Use auth.withAuth to temporarily scope the SDK to the public token
await auth.withAuth(
{ accessToken: token, baseURL: apiUrl, previewBranch: branch },
async () => {
const handle = await tasks.trigger(taskId, { foo: "bar" });
console.log(`Task triggered. Run ID: ${handle.id}`);
let tokenToUse = token;
if (handle.publicAccessToken) {
console.log("Received publicAccessToken in handle, using it for realtime.");
console.log(`Public Access Token: ${handle.publicAccessToken}`);
tokenToUse = handle.publicAccessToken;
} else {
console.log("Using initial token for subsequent requests.");
}
// 3. Access Run details (Simulating Realtime/Read access)
// If the token changed (which it might if the API returns a specific read-only token), we should use that.
if (tokenToUse !== token) {
await auth.withAuth(
{ accessToken: tokenToUse, baseURL: apiUrl, previewBranch: branch },
async () => {
console.log(`Subscribing to run ${handle.id} with new token...`);
for await (const run of runs.subscribeToRun(handle.id)) {
console.log(`Run update received. Status: ${run.status}`);
break;
}
}
);
} else {
console.log(`Subscribing to run ${handle.id} with initial token...`);
for await (const run of runs.subscribeToRun(handle.id)) {
console.log(`Run update received. Status: ${run.status}`);
break;
}
}
console.log("Realtime/Read access verified.");
}
);
} catch (error) {
console.error("Error:", error);
}
}
main();
@@ -0,0 +1,9 @@
import { task } from "@trigger.dev/sdk/v3";
export const myTask = task({
id: "issue-2687-task",
run: async (payload: any) => {
console.log("Task running with payload:", payload);
return { message: "Hello World" };
},
});
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from "@trigger.dev/sdk/v3";
export default defineConfig({
project: process.env.TRIGGER_PROJECT_REF!,
logLevel: "log",
maxDuration: 3600,
retries: {
enabledInDev: true,
default: {
maxAttempts: 3,
minTimeoutInMs: 1000,
maxTimeoutInMs: 10000,
factor: 2,
randomize: true,
},
},
});