diff --git a/.changeset/friendly-keys-batch-tokens.md b/.changeset/friendly-keys-batch-tokens.md new file mode 100644 index 0000000000..505edacd33 --- /dev/null +++ b/.changeset/friendly-keys-batch-tokens.md @@ -0,0 +1,6 @@ +--- +"@trigger.dev/core": patch +"@trigger.dev/sdk": patch +--- + +Allow additional environment API keys to create scoped public access tokens through the Trigger.dev API. Use server-issued public access tokens for batch operations so environment-scoped API keys can read batch results. diff --git a/packages/core/src/v3/apiClient/index.ts b/packages/core/src/v3/apiClient/index.ts index deda5d01f9..5329f15fad 100644 --- a/packages/core/src/v3/apiClient/index.ts +++ b/packages/core/src/v3/apiClient/index.ts @@ -1,6 +1,7 @@ import { nanoid } from "nanoid"; import { z } from "zod"; import { VERSION } from "../../version.js"; +import { isAdditionalApiKey } from "../apiKeys.js"; import type { ApiClientConfiguration } from "../apiClientManager-api.js"; import { generateJWT } from "../jwt.js"; import { @@ -201,6 +202,15 @@ export type { export * from "./getBranch.js"; +export type CreatePublicTokenRequestBody = { + scopes: string[]; + expirationTime?: string | number; + oneTimeUse?: boolean; + realtime?: { skipColumns?: string[] }; +}; + +const CreatePublicTokenResponseBody = z.object({ token: z.string() }); + /** * Trigger.dev v3 API client */ @@ -230,6 +240,21 @@ export class ApiClient { this.futureFlags = futureFlags; } + /** + * Key for signing a public access token locally. Only root keys can do this — + * an additional key isn't the environment's signing material, so a token + * signed with one would never verify. Throw rather than return a dead token. + */ + get #selfSigningKey(): string { + if (isAdditionalApiKey(this.accessToken)) { + throw new Error( + "This additional API key cannot self-sign public tokens, and the server did not return one. Upgrade the server or use the root API key." + ); + } + + return this.accessToken; + } + get fetchClient(): typeof fetch { const headers = this.#getHeaders(false); @@ -325,7 +350,7 @@ export class ApiClient { const claims = claimsHeader ? JSON.parse(claimsHeader) : undefined; const jwt = await generateJWT({ - secretKey: this.accessToken, + secretKey: this.#selfSigningKey, payload: { ...claims, scopes: [`read:runs:${data.id}`], @@ -359,11 +384,20 @@ export class ApiClient { ) .withResponse() .then(async ({ data, response }) => { + const jwtHeader = response.headers.get("x-trigger-jwt"); + + if (typeof jwtHeader === "string") { + return { + ...data, + publicAccessToken: jwtHeader, + }; + } + const claimsHeader = response.headers.get("x-trigger-jwt-claims"); const claims = claimsHeader ? JSON.parse(claimsHeader) : undefined; const jwt = await generateJWT({ - secretKey: this.accessToken, + secretKey: this.#selfSigningKey, payload: { ...claims, scopes: [`read:batch:${data.id}`], @@ -407,11 +441,20 @@ export class ApiClient { ) .withResponse() .then(async ({ data, response }) => { + const jwtHeader = response.headers.get("x-trigger-jwt"); + + if (typeof jwtHeader === "string") { + return { + ...data, + publicAccessToken: jwtHeader, + }; + } + const claimsHeader = response.headers.get("x-trigger-jwt-claims"); const claims = claimsHeader ? JSON.parse(claimsHeader) : undefined; const jwt = await generateJWT({ - secretKey: this.accessToken, + secretKey: this.#selfSigningKey, payload: { ...claims, scopes: [`read:batch:${data.id}`], @@ -1107,7 +1150,7 @@ export class ApiClient { const claims = claimsHeader ? JSON.parse(claimsHeader) : undefined; const jwt = await generateJWT({ - secretKey: this.accessToken, + secretKey: this.#selfSigningKey, payload: { ...claims, scopes: [`write:waitpoints:${data.id}`], @@ -1870,6 +1913,22 @@ export class ApiClient { ); } + async createPublicToken( + body: CreatePublicTokenRequestBody, + requestOptions?: ZodFetchOptions + ): Promise<{ token: string }> { + return zodfetch( + CreatePublicTokenResponseBody, + `${this.baseUrl}/api/v1/auth/public-tokens`, + { + method: "POST", + headers: this.#getHeaders(false), + body: JSON.stringify(body), + }, + mergeRequestOptions(this.defaultRequestOptions, requestOptions) + ); + } + retrieveBatch(batchId: string, requestOptions?: ZodFetchOptions) { return zodfetch( RetrieveBatchV2Response, diff --git a/packages/core/src/v3/apiKeys.test.ts b/packages/core/src/v3/apiKeys.test.ts new file mode 100644 index 0000000000..8d0108fb63 --- /dev/null +++ b/packages/core/src/v3/apiKeys.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { isAdditionalApiKey } from "./apiKeys.js"; + +describe("isAdditionalApiKey", () => { + it.each(["dev", "stg", "prod", "preview"])("recognizes %s additional keys", (environment) => { + expect(isAdditionalApiKey(`tr_${environment}_sk_0123456789abcdefghijklmn`)).toBe(true); + }); + + it.each([ + "tr_prod_0123456789abcdefghijklmn", + "tr_prod_sk_too-short", + "tr_prod_sk_0123456789abcdefghijklmn_extra", + "tr_test_sk_0123456789abcdefghijklmn", + "tr_prod_sk_0123456789abcdefghijkl_", + ])("rejects %s", (key) => { + expect(isAdditionalApiKey(key)).toBe(false); + }); +}); diff --git a/packages/core/src/v3/apiKeys.ts b/packages/core/src/v3/apiKeys.ts new file mode 100644 index 0000000000..2571d66503 --- /dev/null +++ b/packages/core/src/v3/apiKeys.ts @@ -0,0 +1,11 @@ +const ADDITIONAL_API_KEY_PATTERN = /^tr_(dev|stg|prod|preview)_sk_[0-9a-zA-Z]{24}$/; + +/** + * Returns whether a key has the additional environment API key format. + * + * This is only a routing hint. It must never be used as a security boundary; + * servers authenticate additional keys by resolving their stored hash. + */ +export function isAdditionalApiKey(key: string): boolean { + return ADDITIONAL_API_KEY_PATTERN.test(key); +} diff --git a/packages/core/src/v3/index.ts b/packages/core/src/v3/index.ts index 35dbfa287f..10551fca6f 100644 --- a/packages/core/src/v3/index.ts +++ b/packages/core/src/v3/index.ts @@ -30,6 +30,7 @@ export * from "./resource-catalog-api.js"; export * from "./types/index.js"; export { links } from "./links.js"; export * from "./jwt.js"; +export * from "./apiKeys.js"; export * from "./workloadDeploymentToken.js"; export * from "./idempotencyKeys.js"; export * from "./streams/asyncIterableStream.js"; diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index 0931ff1d89..65ec72f430 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -14,6 +14,7 @@ import { type inferSchemaOut, InputStreamOncePromise, type InputStreamOnceResult, + isAdditionalApiKey, isSchemaZodEsque, logger, type MachinePresetName, @@ -10600,24 +10601,53 @@ async function mintPublicTokenWithOverride(args: { throw new Error("chat.createStartSessionAction: no API access token configured for JWT mint."); } const ctx: ChatStartSessionEndpointContext = { endpoint: "auth", chatId: args.chatId }; - const url = `${resolveChatStartBaseURL("auth", args.chatId, args.baseURLOption)}/api/v1/auth/jwt/claims`; + const scopes = [`read:sessions:${args.chatId}`, `write:sessions:${args.chatId}`]; + const serverMint = isAdditionalApiKey(accessToken); + const endpoint = serverMint ? "/api/v1/auth/public-tokens" : "/api/v1/auth/jwt/claims"; + const url = `${resolveChatStartBaseURL("auth", args.chatId, args.baseURLOption)}${endpoint}`; const init: RequestInit = { method: "POST", headers: overrideRequestHeaders(accessToken), + ...(serverMint + ? { + body: JSON.stringify({ + scopes, + expirationTime: + args.expirationTime instanceof Date + ? Math.floor(args.expirationTime.getTime() / 1000) + : args.expirationTime, + }), + } + : {}), }; const response = args.fetchOverride ? await args.fetchOverride(url, init, ctx) : await fetch(url, init); if (!response.ok) { + // An additional API key cannot self-sign, so it must use the server mint + // endpoint. On a server too old to expose it, explain the recovery path + // instead of surfacing a bare 404 (mirrors auth.createServerPublicToken). + if (serverMint && response.status === 404) { + throw new Error( + "This additional API key cannot self-sign public tokens, and the server does not support public-token minting. Upgrade the server or use the root API key." + ); + } const text = await response.text().catch(() => ""); throw new Error(`auth.createPublicToken failed: ${response.status} ${text}`); } - const claims = (await response.json()) as Record; + const responseBody = (await response.json()) as Record; + if (serverMint) { + if (typeof responseBody.token !== "string") { + throw new Error("auth.createPublicToken failed: server response did not include a token"); + } + return responseBody.token; + } + return generateJWT({ secretKey: accessToken, payload: { - ...claims, - scopes: [`read:sessions:${args.chatId}`, `write:sessions:${args.chatId}`], + ...responseBody, + scopes, }, expirationTime: args.expirationTime, }); diff --git a/packages/trigger-sdk/src/v3/auth.test.ts b/packages/trigger-sdk/src/v3/auth.test.ts new file mode 100644 index 0000000000..c32ce788a5 --- /dev/null +++ b/packages/trigger-sdk/src/v3/auth.test.ts @@ -0,0 +1,200 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import { apiClientManager } from "@trigger.dev/core/v3"; +import { validateJWT } from "@trigger.dev/core/v3/jwt"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { auth } from "./auth.js"; + +type ReceivedRequest = { + method: string; + url: string; + authorization?: string; + body: unknown; +}; + +describe("public token API key routing", () => { + let server: Server; + let baseUrl: string; + let requests: ReceivedRequest[]; + let publicTokenStatus: number; + + beforeEach(async () => { + requests = []; + publicTokenStatus = 200; + server = createServer((request, response) => { + void handleRequest(request, response, requests, () => publicTokenStatus); + }); + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const address = server.address() as AddressInfo; + baseUrl = `http://127.0.0.1:${address.port}`; + resolve(); + }); + }); + }); + + afterEach(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + it("uses server minting for additional keys", async () => { + const key = "tr_prod_sk_0123456789abcdefghijklmn"; + const token = await apiClientManager.runWithConfig({ baseURL: baseUrl, accessToken: key }, () => + auth.createPublicToken({ + scopes: { read: { runs: ["run_123"] } }, + expirationTime: new Date("2030-01-01T00:00:00.000Z"), + realtime: { skipColumns: ["payload"] }, + }) + ); + + expect(token).toBe("server-minted-token"); + expect(requests).toEqual([ + { + method: "POST", + url: "/api/v1/auth/public-tokens", + authorization: `Bearer ${key}`, + body: { + scopes: ["read:runs:run_123"], + expirationTime: 1893456000, + realtime: { skipColumns: ["payload"] }, + }, + }, + ]); + }); + + it("server-mints trigger tokens with one-time-use semantics", async () => { + const key = "tr_dev_sk_0123456789abcdefghijklmn"; + await apiClientManager.runWithConfig({ baseURL: baseUrl, accessToken: key }, () => + auth.createTriggerPublicToken(["task-one", "task-two"], { multipleUse: true }) + ); + + expect(requests[0]?.body).toEqual({ + scopes: ["trigger:tasks:task-one", "trigger:tasks:task-two"], + oneTimeUse: false, + }); + }); + + it("server-mints batch trigger tokens for additional keys", async () => { + const key = "tr_stg_sk_0123456789abcdefghijklmn"; + await apiClientManager.runWithConfig({ baseURL: baseUrl, accessToken: key }, () => + auth.createBatchTriggerPublicToken("batch-task", { + expirationTime: "2h", + multipleUse: false, + realtime: { skipColumns: ["payload"] }, + }) + ); + + expect(requests).toEqual([ + { + method: "POST", + url: "/api/v1/auth/public-tokens", + authorization: `Bearer ${key}`, + body: { + scopes: ["batchTrigger:tasks:batch-task"], + expirationTime: "2h", + oneTimeUse: true, + realtime: { skipColumns: ["payload"] }, + }, + }, + ]); + }); + + it("keeps root key self-minting unchanged", async () => { + const key = "tr_prod_0123456789abcdefghijklmn"; + const token = await apiClientManager.runWithConfig({ baseURL: baseUrl, accessToken: key }, () => + auth.createPublicToken({ scopes: { read: { runs: true } } }) + ); + + expect(requests.map((request) => request.url)).toEqual(["/api/v1/auth/jwt/claims"]); + const validation = await validateJWT(token, key); + expect(validation.ok).toBe(true); + if (!validation.ok) return; + expect(validation.payload).toMatchObject({ + sub: "env_test", + pub: true, + scopes: ["read:runs"], + }); + }); + + it.each([ + ["no options at all", undefined], + ["an empty scopes object", {}], + ["a scope group with nothing selected", { read: {} }], + ])("keeps root-key behavior unchanged for %s", async (_label, scopes) => { + const key = "tr_prod_0123456789abcdefghijklmn"; + const token = await apiClientManager.runWithConfig({ baseURL: baseUrl, accessToken: key }, () => + auth.createPublicToken(scopes === undefined ? undefined : { scopes }) + ); + + expect(requests.map((request) => request.url)).toEqual(["/api/v1/auth/jwt/claims"]); + const validation = await validateJWT(token, key); + expect(validation.ok).toBe(true); + }); + + it.each([ + ["no options at all", undefined], + ["an empty scopes object", {}], + ["a scope group with nothing selected", { read: {} }], + ])("rejects %s clearly for additional keys", async (_label, scopes) => { + const key = "tr_prod_sk_0123456789abcdefghijklmn"; + const promise = apiClientManager.runWithConfig({ baseURL: baseUrl, accessToken: key }, () => + auth.createPublicToken(scopes === undefined ? undefined : { scopes }) + ); + + await expect(promise).rejects.toThrow( + "requires at least one scope when using an additional API key" + ); + expect(requests).toEqual([]); + }); + + it("explains how to recover when the server lacks the mint endpoint", async () => { + publicTokenStatus = 404; + const promise = apiClientManager.runWithConfig( + { + baseURL: baseUrl, + accessToken: "tr_prod_sk_0123456789abcdefghijklmn", + }, + () => auth.createPublicToken({ scopes: { read: { runs: true } } }) + ); + + await expect(promise).rejects.toThrow("Upgrade the server or use the root API key"); + }); +}); + +async function handleRequest( + request: IncomingMessage, + response: ServerResponse, + requests: ReceivedRequest[], + publicTokenStatus: () => number +) { + const chunks: Buffer[] = []; + for await (const chunk of request) { + chunks.push(Buffer.from(chunk)); + } + const rawBody = Buffer.concat(chunks).toString(); + requests.push({ + method: request.method ?? "", + url: request.url ?? "", + authorization: request.headers.authorization, + body: rawBody ? JSON.parse(rawBody) : undefined, + }); + + if (request.url === "/api/v1/auth/jwt/claims") { + return json(response, { sub: "env_test", pub: true }); + } + if (request.url === "/api/v1/auth/public-tokens") { + const status = publicTokenStatus(); + return json( + response, + status === 200 ? { token: "server-minted-token" } : { error: "Not found" }, + status + ); + } + + return json(response, { error: "Not found" }, 404); +} + +function json(response: ServerResponse, body: unknown, status = 200) { + response.writeHead(status, { "content-type": "application/json" }); + response.end(JSON.stringify(body)); +} diff --git a/packages/trigger-sdk/src/v3/auth.ts b/packages/trigger-sdk/src/v3/auth.ts index 5e76765956..d26a08fa87 100644 --- a/packages/trigger-sdk/src/v3/auth.ts +++ b/packages/trigger-sdk/src/v3/auth.ts @@ -1,8 +1,11 @@ import { type RealtimeRunSkipColumns, + type ApiClient, type ApiClientConfiguration, + type CreatePublicTokenRequestBody, apiClientManager, generateJWT as internal_generateJWT, + isAdditionalApiKey, } from "@trigger.dev/core/v3"; import "@trigger.dev/core/v3/sdk-scope-storage"; @@ -102,7 +105,9 @@ export type PublicTokenPermissions = { export type CreatePublicTokenOptions = { /** - * A collection of permission scopes to be granted to the token. + * A collection of permission scopes to be granted to the token. This remains + * optional for root API key compatibility; additional API keys require at + * least one scope. * * @example * @@ -117,7 +122,8 @@ export type CreatePublicTokenOptions = { scopes?: PublicTokenPermissions; /** - * The expiration time for the token. This can be a number representing the time in milliseconds, a `Date` object, or a string. + * The expiration time for the token: a duration string, a `Date`, or a Unix + * timestamp in **seconds**. * * @example * @@ -146,6 +152,35 @@ export type CreatePublicTokenOptions = { }; }; +// A `Date` becomes Unix seconds; duration strings and numbers pass through. A +// bare number is already Unix seconds, matching how the local signing path +// reads it, so both key types treat the option the same. +function serverExpirationTime( + expirationTime: number | Date | string | undefined +): number | string | undefined { + return expirationTime instanceof Date + ? Math.floor(expirationTime.getTime() / 1000) + : expirationTime; +} + +async function createServerPublicToken( + apiClient: ApiClient, + body: CreatePublicTokenRequestBody +): Promise { + try { + const result = await apiClient.createPublicToken(body); + return result.token; + } catch (error) { + if (error !== null && typeof error === "object" && "status" in error && error.status === 404) { + throw new Error( + "This additional API key cannot self-sign public tokens, and the server does not support public-token minting. Upgrade the server or use the root API key." + ); + } + + throw error; + } +} + /** * Creates a public token using the provided options. * @@ -170,15 +205,34 @@ export type CreatePublicTokenOptions = { * ``` */ async function createPublicToken(options?: CreatePublicTokenOptions): Promise { + const scopes = options?.scopes ? flattenScopes(options.scopes) : []; const apiClient = apiClientManager.clientOrThrow(); + if (isAdditionalApiKey(apiClient.accessToken)) { + // Additional keys cannot self-sign, and the server rejects empty scope + // lists because they cannot authorize anything. Keep the legacy root-key + // behaviour unchanged while failing clearly for this new credential type. + if (scopes.length === 0) { + throw new Error( + "auth.createPublicToken() requires at least one scope when using an additional API key. " + + 'For example: auth.createPublicToken({ scopes: { read: { runs: ["run_1234"] } } })' + ); + } + + return createServerPublicToken(apiClient, { + scopes, + expirationTime: serverExpirationTime(options?.expirationTime), + realtime: options?.realtime, + }); + } + const claims = await apiClient.generateJWTClaims(); return await internal_generateJWT({ secretKey: apiClient.accessToken, payload: { ...claims, - scopes: options?.scopes ? flattenScopes(options.scopes) : undefined, + scopes: options?.scopes ? scopes : undefined, realtime: options?.realtime, }, expirationTime: options?.expirationTime, @@ -199,7 +253,8 @@ async function withPublicToken(options: CreatePublicTokenOptions, fn: () => Prom export type CreateTriggerTokenOptions = { /** - * The expiration time for the token. This can be a number representing the time in milliseconds, a `Date` object, or a string. + * The expiration time for the token: a duration string, a `Date`, or a Unix + * timestamp in **seconds**. * * @example * @@ -271,6 +326,21 @@ async function createTriggerPublicToken( options?: CreateTriggerTokenOptions ): Promise { const apiClient = apiClientManager.clientOrThrow(); + const scopes = flattenScopes({ + trigger: { + tasks: task, + }, + }); + const oneTimeUse = typeof options?.multipleUse === "boolean" ? !options.multipleUse : true; + + if (isAdditionalApiKey(apiClient.accessToken)) { + return createServerPublicToken(apiClient, { + scopes, + expirationTime: serverExpirationTime(options?.expirationTime), + oneTimeUse, + realtime: options?.realtime, + }); + } const claims = await apiClient.generateJWTClaims(); @@ -278,13 +348,9 @@ async function createTriggerPublicToken( secretKey: apiClient.accessToken, payload: { ...claims, - otu: typeof options?.multipleUse === "boolean" ? !options.multipleUse : true, + otu: oneTimeUse, realtime: options?.realtime, - scopes: flattenScopes({ - trigger: { - tasks: task, - }, - }), + scopes, }, expirationTime: options?.expirationTime, }); @@ -343,6 +409,21 @@ async function createBatchTriggerPublicToken( options?: CreateTriggerTokenOptions ): Promise { const apiClient = apiClientManager.clientOrThrow(); + const scopes = flattenScopes({ + batchTrigger: { + tasks: task, + }, + }); + const oneTimeUse = typeof options?.multipleUse === "boolean" ? !options.multipleUse : true; + + if (isAdditionalApiKey(apiClient.accessToken)) { + return createServerPublicToken(apiClient, { + scopes, + expirationTime: serverExpirationTime(options?.expirationTime), + oneTimeUse, + realtime: options?.realtime, + }); + } const claims = await apiClient.generateJWTClaims(); @@ -350,13 +431,9 @@ async function createBatchTriggerPublicToken( secretKey: apiClient.accessToken, payload: { ...claims, - otu: typeof options?.multipleUse === "boolean" ? !options.multipleUse : true, + otu: oneTimeUse, realtime: options?.realtime, - scopes: flattenScopes({ - batchTrigger: { - tasks: task, - }, - }), + scopes, }, expirationTime: options?.expirationTime, }); diff --git a/packages/trigger-sdk/src/v3/createStartSessionAction.test.ts b/packages/trigger-sdk/src/v3/createStartSessionAction.test.ts index d1a6a9b61d..ca18ce5998 100644 --- a/packages/trigger-sdk/src/v3/createStartSessionAction.test.ts +++ b/packages/trigger-sdk/src/v3/createStartSessionAction.test.ts @@ -132,6 +132,44 @@ describe("chat.createStartSessionAction — runtime", () => { expect(lastStartBody?.triggerConfig.lockToVersion).toBe("20260101.1"); }); + it("server-mints override tokens for additional API keys", async () => { + const requests: Array<{ url: string; body: unknown }> = []; + const start = chat.createStartSessionAction("fake-chat", { + apiClient: { + baseURL: "https://example.invalid", + accessToken: "tr_prod_sk_0123456789abcdefghijklmn", + }, + tokenTTL: "30m", + fetch: async (url, init, context) => { + requests.push({ + url, + body: typeof init.body === "string" ? JSON.parse(init.body) : undefined, + }); + + if (context.endpoint === "sessions") { + return Response.json({ + id: "session_fixture", + runId: "run_fixture", + publicAccessToken: "session-token", + }); + } + + return Response.json({ token: "server-minted-token" }); + }, + }); + + const result = await start({ chatId: "chat-additional-key" }); + + expect(result.publicAccessToken).toBe("server-minted-token"); + expect(requests[1]).toEqual({ + url: "https://example.invalid/api/v1/auth/public-tokens", + body: { + scopes: ["read:sessions:chat-additional-key", "write:sessions:chat-additional-key"], + expirationTime: "30m", + }, + }); + }); + it("keeps session-level metadata distinct from per-turn clientData", async () => { installStartFixture();