Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 1.4k
feat(core,sdk): support additional environment API keys#4387
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
82ccc62ec0cd179b4130ae37b1552d3c081File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| }; | ||
| } | ||
carderne marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| 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, | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.