fix: security release 2026-07-08 (#4316)
⚒️ Publish Worker (v4) / build (supervisor) (push) Has been cancelled
⚒️ Publish Worker (v4) / build (supervisor) (push) Has been cancelled
This commit is contained in:
@@ -295,9 +295,10 @@ export async function login(options?: LoginOptions): Promise<LoginResult> {
|
||||
const indexResult = await pRetry(
|
||||
() => getPersonalAccessToken(apiClient, authorizationCodeResult.authorizationCode),
|
||||
{
|
||||
//this means we're polling, same distance between each attempt
|
||||
//poll at a fixed 1s interval. ~5 min window so the user has time to
|
||||
//approve the consent screen; stays within the code's 10-min validity.
|
||||
factor: 1,
|
||||
retries: 60,
|
||||
retries: 300,
|
||||
minTimeout: 1000,
|
||||
}
|
||||
);
|
||||
@@ -404,6 +405,15 @@ export async function getPersonalAccessToken(apiClient: CliApiClient, authorizat
|
||||
const token = await apiClient.getPersonalAccessToken(authorizationCode);
|
||||
|
||||
if (!token.success) {
|
||||
// A 429 from the per-code poll rate limiter is transient: the auth code
|
||||
// is still valid and the user may just not have approved the consent
|
||||
// screen yet. Throw a regular (retryable) error so the poll loop backs
|
||||
// off and keeps polling, rather than an AbortError, which pRetry treats
|
||||
// as fatal and would abandon the whole login.
|
||||
if (token.statusCode === 429) {
|
||||
throw new Error(token.error);
|
||||
}
|
||||
|
||||
throw new AbortError(token.error);
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ export class ManagedRunController {
|
||||
});
|
||||
|
||||
const properties = {
|
||||
...env.raw,
|
||||
...env.rawForLogging,
|
||||
TRIGGER_POD_SCHEDULED_AT_MS: env.TRIGGER_POD_SCHEDULED_AT_MS.toISOString(),
|
||||
TRIGGER_DEQUEUED_AT_MS: env.TRIGGER_DEQUEUED_AT_MS.toISOString(),
|
||||
};
|
||||
|
||||
@@ -27,6 +27,9 @@ const Env = z.object({
|
||||
|
||||
// Set at runtime
|
||||
TRIGGER_DEPLOYMENT_ID: z.string(),
|
||||
// Plain deployment friendlyId for telemetry. Optional: older supervisors don't set it, in which
|
||||
// case we fall back to TRIGGER_DEPLOYMENT_ID.
|
||||
TRIGGER_DEPLOYMENT_FRIENDLY_ID: z.string().optional(),
|
||||
TRIGGER_DEPLOYMENT_VERSION: z.string(),
|
||||
TRIGGER_WORKLOAD_CONTROLLER_ID: z.string().default(`controller_${randomUUID()}`),
|
||||
TRIGGER_ENV_ID: z.string(),
|
||||
@@ -74,6 +77,11 @@ export class RunnerEnv {
|
||||
return this.env;
|
||||
}
|
||||
|
||||
// TRIGGER_DEPLOYMENT_ID carries the deployment token; redact it before logging.
|
||||
get rawForLogging() {
|
||||
return { ...this.env, TRIGGER_DEPLOYMENT_ID: "[redacted]" };
|
||||
}
|
||||
|
||||
// Base environment variables
|
||||
get NODE_ENV() {
|
||||
return this.env.NODE_ENV;
|
||||
@@ -93,6 +101,9 @@ export class RunnerEnv {
|
||||
get TRIGGER_DEPLOYMENT_ID() {
|
||||
return this.env.TRIGGER_DEPLOYMENT_ID;
|
||||
}
|
||||
get TRIGGER_DEPLOYMENT_FRIENDLY_ID() {
|
||||
return this.env.TRIGGER_DEPLOYMENT_FRIENDLY_ID;
|
||||
}
|
||||
get TRIGGER_DEPLOYMENT_VERSION() {
|
||||
return this.env.TRIGGER_DEPLOYMENT_VERSION;
|
||||
}
|
||||
|
||||
@@ -951,7 +951,7 @@ export class RunExecution {
|
||||
|
||||
this.sendDebugLog(`[override] processing: ${reason}`, {
|
||||
overrides,
|
||||
currentEnv: this.env.raw,
|
||||
currentEnv: this.env.rawForLogging,
|
||||
});
|
||||
|
||||
// Override the env with the new values
|
||||
|
||||
@@ -252,11 +252,14 @@ export class TaskRunProcessProvider {
|
||||
workerManifest: this.workerManifest,
|
||||
env: processEnv,
|
||||
serverWorker: {
|
||||
id: this.env.TRIGGER_DEPLOYMENT_ID,
|
||||
// Telemetry-only (becomes OTel worker.id). Prefer the plain friendlyId; fall back to
|
||||
// DEPLOYMENT_ID (which may be an opaque token) only when an older supervisor didn't set it.
|
||||
id: this.env.TRIGGER_DEPLOYMENT_FRIENDLY_ID ?? this.env.TRIGGER_DEPLOYMENT_ID,
|
||||
contentHash: this.env.TRIGGER_CONTENT_HASH,
|
||||
version: this.env.TRIGGER_DEPLOYMENT_VERSION,
|
||||
engine: "V2",
|
||||
},
|
||||
|
||||
machineResources: {
|
||||
cpu: Number(this.env.TRIGGER_MACHINE_CPU),
|
||||
memory: Number(this.env.TRIGGER_MACHINE_MEMORY),
|
||||
|
||||
@@ -123,9 +123,10 @@ export async function mcpAuth(options: McpAuthOptions): Promise<LoginResult> {
|
||||
const indexResult = await pRetry(
|
||||
() => getPersonalAccessToken(apiClient, authorizationCodeResult.authorizationCode),
|
||||
{
|
||||
//this means we're polling, same distance between each attempt
|
||||
//poll at a fixed 1s interval. ~5 min window so the user has time to
|
||||
//approve the consent screen; stays within the code's 10-min validity.
|
||||
factor: 1,
|
||||
retries: 60,
|
||||
retries: 300,
|
||||
minTimeout: 1000,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -725,6 +725,13 @@ export type ApiResult<TSuccessResult> =
|
||||
| {
|
||||
success: false;
|
||||
error: string;
|
||||
/**
|
||||
* HTTP status code, when the failure originated from an API response
|
||||
* (e.g. 429 for rate limiting). Undefined for connection/transport
|
||||
* errors that never reached the server. Lets callers distinguish
|
||||
* transient failures worth retrying from fatal ones.
|
||||
*/
|
||||
statusCode?: number;
|
||||
};
|
||||
|
||||
export async function wrapZodFetch<T extends z.ZodTypeAny>(
|
||||
@@ -754,6 +761,7 @@ export async function wrapZodFetch<T extends z.ZodTypeAny>(
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
statusCode: error.status,
|
||||
};
|
||||
} else if (error instanceof Error) {
|
||||
return {
|
||||
|
||||
@@ -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 "./workloadDeploymentToken.js";
|
||||
export * from "./idempotencyKeys.js";
|
||||
export * from "./streams/asyncIterableStream.js";
|
||||
export * from "./utils/getEnv.js";
|
||||
|
||||
@@ -4,6 +4,10 @@ export type GenerateJWTOptions = {
|
||||
secretKey: string;
|
||||
payload: Record<string, any>;
|
||||
expirationTime?: number | Date | string;
|
||||
// Skip the `iat` claim. Combined with an absolute `expirationTime`, this makes the signed token a
|
||||
// pure function of its payload — the same claims mint byte-identical tokens. Off by default so
|
||||
// ordinary short-lived tokens keep their issued-at.
|
||||
omitIssuedAt?: boolean;
|
||||
};
|
||||
|
||||
export const JWT_ALGORITHM = "HS256";
|
||||
@@ -15,13 +19,17 @@ export async function generateJWT(options: GenerateJWTOptions): Promise<string>
|
||||
|
||||
const secret = new TextEncoder().encode(options.secretKey);
|
||||
|
||||
return new SignJWT(options.payload)
|
||||
const jwt = new SignJWT(options.payload)
|
||||
.setIssuer(JWT_ISSUER)
|
||||
.setAudience(JWT_AUDIENCE)
|
||||
.setProtectedHeader({ alg: JWT_ALGORITHM })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(options.expirationTime ?? "15m")
|
||||
.sign(secret);
|
||||
.setExpirationTime(options.expirationTime ?? "15m");
|
||||
|
||||
if (!options.omitIssuedAt) {
|
||||
jwt.setIssuedAt();
|
||||
}
|
||||
|
||||
return jwt.sign(secret);
|
||||
}
|
||||
|
||||
export type ValidationResult =
|
||||
|
||||
@@ -3,6 +3,9 @@ export const WORKER_HEADERS = {
|
||||
DEPLOYMENT_ID: "x-trigger-worker-deployment-id",
|
||||
MANAGED_SECRET: "x-trigger-worker-managed-secret",
|
||||
RUNNER_ID: "x-trigger-worker-runner-id",
|
||||
// Verified environment id recovered from the deployment token. Set by the supervisor only when
|
||||
// enforcing; the platform scopes the worker-action's snapshot read by it.
|
||||
ENVIRONMENT_ID: "x-trigger-worker-environment-id",
|
||||
};
|
||||
|
||||
export const WORKLOAD_HEADERS = {
|
||||
|
||||
@@ -143,7 +143,8 @@ export class SupervisorHttpClient {
|
||||
runId: string,
|
||||
snapshotId: string,
|
||||
body: WorkerApiRunAttemptStartRequestBody,
|
||||
runnerId?: string
|
||||
runnerId?: string,
|
||||
environmentId?: string
|
||||
) {
|
||||
return wrapZodFetch(
|
||||
WorkerApiRunAttemptStartResponseBody,
|
||||
@@ -153,6 +154,7 @@ export class SupervisorHttpClient {
|
||||
headers: {
|
||||
...this.defaultHeaders,
|
||||
...this.runnerIdHeader(runnerId),
|
||||
...this.environmentIdHeader(environmentId),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
@@ -163,7 +165,8 @@ export class SupervisorHttpClient {
|
||||
runId: string,
|
||||
snapshotId: string,
|
||||
body: WorkerApiRunAttemptCompleteRequestBody,
|
||||
runnerId?: string
|
||||
runnerId?: string,
|
||||
environmentId?: string
|
||||
) {
|
||||
return wrapZodFetch(
|
||||
WorkerApiRunAttemptCompleteResponseBody,
|
||||
@@ -173,13 +176,14 @@ export class SupervisorHttpClient {
|
||||
headers: {
|
||||
...this.defaultHeaders,
|
||||
...this.runnerIdHeader(runnerId),
|
||||
...this.environmentIdHeader(environmentId),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async getLatestSnapshot(runId: string, runnerId?: string) {
|
||||
async getLatestSnapshot(runId: string, runnerId?: string, environmentId?: string) {
|
||||
return wrapZodFetch(
|
||||
WorkerApiRunLatestSnapshotResponseBody,
|
||||
`${this.apiUrl}/engine/v1/worker-actions/runs/${runId}/snapshots/latest`,
|
||||
@@ -188,12 +192,18 @@ export class SupervisorHttpClient {
|
||||
headers: {
|
||||
...this.defaultHeaders,
|
||||
...this.runnerIdHeader(runnerId),
|
||||
...this.environmentIdHeader(environmentId),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async getSnapshotsSince(runId: string, snapshotId: string, runnerId?: string) {
|
||||
async getSnapshotsSince(
|
||||
runId: string,
|
||||
snapshotId: string,
|
||||
runnerId?: string,
|
||||
environmentId?: string
|
||||
) {
|
||||
return wrapZodFetch(
|
||||
WorkerApiRunSnapshotsSinceResponseBody,
|
||||
`${this.apiUrl}/engine/v1/worker-actions/runs/${runId}/snapshots/since/${snapshotId}`,
|
||||
@@ -202,6 +212,7 @@ export class SupervisorHttpClient {
|
||||
headers: {
|
||||
...this.defaultHeaders,
|
||||
...this.runnerIdHeader(runnerId),
|
||||
...this.environmentIdHeader(environmentId),
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -235,7 +246,12 @@ export class SupervisorHttpClient {
|
||||
}
|
||||
}
|
||||
|
||||
async continueRunExecution(runId: string, snapshotId: string, runnerId?: string) {
|
||||
async continueRunExecution(
|
||||
runId: string,
|
||||
snapshotId: string,
|
||||
runnerId?: string,
|
||||
environmentId?: string
|
||||
) {
|
||||
return wrapZodFetch(
|
||||
WorkerApiContinueRunExecutionRequestBody,
|
||||
`${this.apiUrl}/engine/v1/worker-actions/runs/${runId}/snapshots/${snapshotId}/continue`,
|
||||
@@ -244,6 +260,7 @@ export class SupervisorHttpClient {
|
||||
headers: {
|
||||
...this.defaultHeaders,
|
||||
...this.runnerIdHeader(runnerId),
|
||||
...this.environmentIdHeader(environmentId),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -295,4 +312,10 @@ export class SupervisorHttpClient {
|
||||
[WORKER_HEADERS.RUNNER_ID]: runnerId,
|
||||
});
|
||||
}
|
||||
|
||||
private environmentIdHeader(environmentId?: string): Record<string, string> {
|
||||
return createHeaders({
|
||||
[WORKER_HEADERS.ENVIRONMENT_ID]: environmentId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,4 +29,6 @@ export type WorkloadClientSocketData = {
|
||||
runnerId: string;
|
||||
runFriendlyId?: string;
|
||||
snapshotId?: string;
|
||||
// Deployment friendlyId resolved from the verified token; prefer over the raw deploymentId.
|
||||
deploymentFriendlyId?: string;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { JSONHeroPath } from "@jsonhero/path";
|
||||
import type { RunMetadataChangeOperation } from "../schemas/common.js";
|
||||
import { isSafeMetadataKey, type RunMetadataChangeOperation } from "../schemas/common.js";
|
||||
import { dequal } from "dequal";
|
||||
|
||||
export type ApplyOperationResult = {
|
||||
@@ -16,6 +16,13 @@ export function applyMetadataOperations(
|
||||
let newMetadata: Record<string, unknown> = structuredClone(currentMetadata);
|
||||
|
||||
for (const operation of Array.isArray(operations) ? operations : [operations]) {
|
||||
// Prevent unsafe JSON paths and direct __proto__ assignments from changing Object.prototype.
|
||||
// ("update" carries no key.)
|
||||
if (operation.type !== "update" && !isSafeMetadataKey(operation.key)) {
|
||||
unappliedOperations.push(operation);
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (operation.type) {
|
||||
case "set": {
|
||||
if (operation.key.startsWith("$.")) {
|
||||
|
||||
@@ -4,6 +4,25 @@ import type { RuntimeEnvironmentType as DBRuntimeEnvironmentType } from "@trigge
|
||||
|
||||
export type Enum<T extends string> = { [K in T]: K };
|
||||
|
||||
const DANGEROUS_METADATA_KEY_SEGMENTS = new Set(["__proto__", "constructor", "prototype"]);
|
||||
|
||||
/**
|
||||
* Prototype-pollution guard for run metadata operation keys. JSON paths are applied via
|
||||
* JSONHeroPath, so dangerous path segments must be rejected. Literal keys are assigned directly,
|
||||
* where only __proto__ can change the target object's prototype.
|
||||
*/
|
||||
export function isSafeMetadataKey(key: string): boolean {
|
||||
if (!key.startsWith("$.")) {
|
||||
return key !== "__proto__";
|
||||
}
|
||||
|
||||
return !key.split(/[.[\]'"]+/).some((segment) => DANGEROUS_METADATA_KEY_SEGMENTS.has(segment));
|
||||
}
|
||||
|
||||
const MetadataOperationKey = z.string().refine(isSafeMetadataKey, {
|
||||
message: "Metadata key may not reference __proto__, constructor, or prototype",
|
||||
});
|
||||
|
||||
export const RunMetadataUpdateOperation = z.object({
|
||||
type: z.literal("update"),
|
||||
value: z.record(z.unknown()),
|
||||
@@ -13,7 +32,7 @@ export type RunMetadataUpdateOperation = z.infer<typeof RunMetadataUpdateOperati
|
||||
|
||||
export const RunMetadataSetKeyOperation = z.object({
|
||||
type: z.literal("set"),
|
||||
key: z.string(),
|
||||
key: MetadataOperationKey,
|
||||
value: DeserializedJsonSchema,
|
||||
});
|
||||
|
||||
@@ -21,14 +40,14 @@ export type RunMetadataSetKeyOperation = z.infer<typeof RunMetadataSetKeyOperati
|
||||
|
||||
export const RunMetadataDeleteKeyOperation = z.object({
|
||||
type: z.literal("delete"),
|
||||
key: z.string(),
|
||||
key: MetadataOperationKey,
|
||||
});
|
||||
|
||||
export type RunMetadataDeleteKeyOperation = z.infer<typeof RunMetadataDeleteKeyOperation>;
|
||||
|
||||
export const RunMetadataAppendKeyOperation = z.object({
|
||||
type: z.literal("append"),
|
||||
key: z.string(),
|
||||
key: MetadataOperationKey,
|
||||
value: DeserializedJsonSchema,
|
||||
});
|
||||
|
||||
@@ -36,7 +55,7 @@ export type RunMetadataAppendKeyOperation = z.infer<typeof RunMetadataAppendKeyO
|
||||
|
||||
export const RunMetadataRemoveFromKeyOperation = z.object({
|
||||
type: z.literal("remove"),
|
||||
key: z.string(),
|
||||
key: MetadataOperationKey,
|
||||
value: DeserializedJsonSchema,
|
||||
});
|
||||
|
||||
@@ -44,7 +63,7 @@ export type RunMetadataRemoveFromKeyOperation = z.infer<typeof RunMetadataRemove
|
||||
|
||||
export const RunMetadataIncrementKeyOperation = z.object({
|
||||
type: z.literal("increment"),
|
||||
key: z.string(),
|
||||
key: MetadataOperationKey,
|
||||
value: z.number(),
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,10 @@ export const CIRCULAR_REFERENCE_SENTINEL = "$@circular((";
|
||||
|
||||
const DEFAULT_MAX_DEPTH = 128;
|
||||
|
||||
// This property name would let a crafted key walk into Object.prototype during
|
||||
// reconstruction and pollute the shared process.
|
||||
const PROTOTYPE_POLLUTION_KEY = "__proto__";
|
||||
|
||||
export function flattenAttributes(
|
||||
obj: unknown,
|
||||
prefix?: string,
|
||||
@@ -297,6 +301,11 @@ export function unflattenAttributes(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip any key whose path could walk into Object.prototype.
|
||||
if (parts.includes(PROTOTYPE_POLLUTION_KEY)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let current: any = result;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const part = parts[i];
|
||||
@@ -330,8 +339,10 @@ export function unflattenAttributes(
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the result to an array if all top-level keys are numeric indices
|
||||
if (Object.keys(result).every((k) => /^\d+$/.test(k))) {
|
||||
// Convert the result to an array if all top-level keys are numeric indices.
|
||||
// Guard against an empty result (e.g. every key was skipped as unsafe), which
|
||||
// would otherwise produce Array(-Infinity) and throw.
|
||||
if (Object.keys(result).length > 0 && Object.keys(result).every((k) => /^\d+$/.test(k))) {
|
||||
const maxIndex = Math.max(...Object.keys(result).map((k) => parseInt(k)));
|
||||
const arrayResult = Array(maxIndex + 1);
|
||||
for (const key in result) {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { generateJWT } from "./jwt.js";
|
||||
import {
|
||||
classifyDeploymentIdHeader,
|
||||
looksLikeWorkloadDeploymentToken,
|
||||
mintWorkloadDeploymentToken,
|
||||
verifyWorkloadDeploymentToken,
|
||||
WORKLOAD_DEPLOYMENT_TOKEN_VERSION,
|
||||
type WorkloadDeploymentTokenInput,
|
||||
} from "./workloadDeploymentToken.js";
|
||||
|
||||
const SECRET = "test-workload-token-secret";
|
||||
const EXP = Math.floor(Date.UTC(2032, 0, 1) / 1000);
|
||||
|
||||
const claims: WorkloadDeploymentTokenInput = {
|
||||
deployment: "deployment_abc123",
|
||||
deployment_version: "20260709.1",
|
||||
environment_id: "env_1",
|
||||
environment_type: "PRODUCTION",
|
||||
org_id: "org_1",
|
||||
project_id: "proj_1",
|
||||
};
|
||||
|
||||
describe("workloadDeploymentToken", () => {
|
||||
it("mints a token that verifies and round-trips every claim", async () => {
|
||||
const token = await mintWorkloadDeploymentToken(claims, SECRET, EXP);
|
||||
|
||||
const result = await verifyWorkloadDeploymentToken(token, SECRET);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
|
||||
expect(result.claims.ver).toBe(WORKLOAD_DEPLOYMENT_TOKEN_VERSION);
|
||||
expect(result.claims.deployment).toBe(claims.deployment);
|
||||
expect(result.claims.deployment_version).toBe(claims.deployment_version);
|
||||
expect(result.claims.environment_id).toBe(claims.environment_id);
|
||||
expect(result.claims.environment_type).toBe(claims.environment_type);
|
||||
expect(result.claims.org_id).toBe(claims.org_id);
|
||||
expect(result.claims.project_id).toBe(claims.project_id);
|
||||
});
|
||||
|
||||
it("sets the caller-supplied absolute exp", async () => {
|
||||
const token = await mintWorkloadDeploymentToken(claims, SECRET, EXP);
|
||||
const result = await verifyWorkloadDeploymentToken(token, SECRET);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
|
||||
expect(result.claims.exp).toBe(EXP);
|
||||
});
|
||||
|
||||
it("mints byte-identical tokens for identical claims (deterministic, no iat)", async () => {
|
||||
const a = await mintWorkloadDeploymentToken(claims, SECRET, EXP);
|
||||
const b = await mintWorkloadDeploymentToken(claims, SECRET, EXP);
|
||||
expect(a).toBe(b);
|
||||
|
||||
const result = await verifyWorkloadDeploymentToken(a, SECRET);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect((result.claims as Record<string, unknown>).iat).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects a token signed with a different secret", async () => {
|
||||
const token = await mintWorkloadDeploymentToken(claims, SECRET, EXP);
|
||||
const result = await verifyWorkloadDeploymentToken(token, "wrong-secret");
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.reason).toBe("invalid_signature");
|
||||
});
|
||||
|
||||
it("rejects a tampered token", async () => {
|
||||
const token = await mintWorkloadDeploymentToken(claims, SECRET, EXP);
|
||||
const [header, payload, signature] = token.split(".");
|
||||
const tampered = `${header}.${payload}x.${signature}`;
|
||||
const result = await verifyWorkloadDeploymentToken(tampered, SECRET);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a valid JWT that is missing required claims", async () => {
|
||||
// Signed with the right secret, but not a workload deployment token.
|
||||
const token = await generateJWT({
|
||||
secretKey: SECRET,
|
||||
payload: { foo: "bar" },
|
||||
expirationTime: "1825d",
|
||||
});
|
||||
const result = await verifyWorkloadDeploymentToken(token, SECRET);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.reason).toBe("malformed_claims");
|
||||
});
|
||||
|
||||
describe("looksLikeWorkloadDeploymentToken", () => {
|
||||
it("is true for a minted token", async () => {
|
||||
const token = await mintWorkloadDeploymentToken(claims, SECRET, EXP);
|
||||
expect(looksLikeWorkloadDeploymentToken(token)).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for a legacy bare friendlyId", () => {
|
||||
expect(looksLikeWorkloadDeploymentToken("deployment_abc123")).toBe(false);
|
||||
});
|
||||
|
||||
it("is false for empty and malformed values", () => {
|
||||
expect(looksLikeWorkloadDeploymentToken("")).toBe(false);
|
||||
expect(looksLikeWorkloadDeploymentToken("a.b")).toBe(false);
|
||||
expect(looksLikeWorkloadDeploymentToken("a..c")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyDeploymentIdHeader", () => {
|
||||
it("classifies a minted token as jwt_valid and returns claims", async () => {
|
||||
const token = await mintWorkloadDeploymentToken(claims, SECRET, EXP);
|
||||
const result = await classifyDeploymentIdHeader(token, SECRET);
|
||||
expect(result.outcome).toBe("jwt_valid");
|
||||
expect(result.claims?.deployment).toBe(claims.deployment);
|
||||
});
|
||||
|
||||
it("classifies a JWT with a bad signature as jwt_invalid", async () => {
|
||||
const token = await mintWorkloadDeploymentToken(claims, SECRET, EXP);
|
||||
const result = await classifyDeploymentIdHeader(token, "wrong-secret");
|
||||
expect(result.outcome).toBe("jwt_invalid");
|
||||
expect(result.claims).toBeUndefined();
|
||||
});
|
||||
|
||||
it("classifies a bare friendlyId as legacy_bare", async () => {
|
||||
const result = await classifyDeploymentIdHeader("deployment_abc123", SECRET);
|
||||
expect(result.outcome).toBe("legacy_bare");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { z } from "zod";
|
||||
import { generateJWT, validateJWT } from "./jwt.js";
|
||||
|
||||
/**
|
||||
* Signed, deployment-scoped token carried in the TRIGGER_DEPLOYMENT_ID env var to identify a run
|
||||
* controller. Long-lived identity token, not a rotating credential; the `ver` claim allows a future
|
||||
* format revision.
|
||||
*/
|
||||
export const WORKLOAD_DEPLOYMENT_TOKEN_VERSION = 1 as const;
|
||||
|
||||
export const WorkloadDeploymentTokenClaims = z.object({
|
||||
ver: z.literal(WORKLOAD_DEPLOYMENT_TOKEN_VERSION),
|
||||
/** Deployment friendlyId (deployment_xxxx). */
|
||||
deployment: z.string(),
|
||||
deployment_version: z.string(),
|
||||
environment_id: z.string(),
|
||||
environment_type: z.string(),
|
||||
org_id: z.string(),
|
||||
project_id: z.string(),
|
||||
exp: z.number(),
|
||||
});
|
||||
|
||||
export type WorkloadDeploymentTokenClaims = z.infer<typeof WorkloadDeploymentTokenClaims>;
|
||||
|
||||
/** Claims the caller supplies; `ver` and `exp` are set by the minter. */
|
||||
export type WorkloadDeploymentTokenInput = Omit<WorkloadDeploymentTokenClaims, "ver" | "exp">;
|
||||
|
||||
/**
|
||||
* `expiresAtSeconds` is an absolute epoch (caller-supplied — the supervisor drives it). Combined with
|
||||
* the omitted `iat`, the token is byte-deterministic per deployment: identical claims mint identical
|
||||
* bytes, so the OTel `worker.id` the runner derives from it stays one value per deployment.
|
||||
*/
|
||||
export async function mintWorkloadDeploymentToken(
|
||||
claims: WorkloadDeploymentTokenInput,
|
||||
secret: string,
|
||||
expiresAtSeconds: number
|
||||
): Promise<string> {
|
||||
return generateJWT({
|
||||
secretKey: secret,
|
||||
payload: { ver: WORKLOAD_DEPLOYMENT_TOKEN_VERSION, ...claims },
|
||||
expirationTime: expiresAtSeconds,
|
||||
omitIssuedAt: true,
|
||||
});
|
||||
}
|
||||
|
||||
export type WorkloadDeploymentTokenVerification =
|
||||
| { ok: true; claims: WorkloadDeploymentTokenClaims }
|
||||
| { ok: false; reason: "invalid_signature" | "malformed_claims"; error: string };
|
||||
|
||||
export async function verifyWorkloadDeploymentToken(
|
||||
token: string,
|
||||
secret: string
|
||||
): Promise<WorkloadDeploymentTokenVerification> {
|
||||
const result = await validateJWT(token, secret);
|
||||
|
||||
if (!result.ok) {
|
||||
return { ok: false, reason: "invalid_signature", error: result.error };
|
||||
}
|
||||
|
||||
const parsed = WorkloadDeploymentTokenClaims.safeParse(result.payload);
|
||||
|
||||
if (!parsed.success) {
|
||||
return { ok: false, reason: "malformed_claims", error: parsed.error.message };
|
||||
}
|
||||
|
||||
return { ok: true, claims: parsed.data };
|
||||
}
|
||||
|
||||
/**
|
||||
* A legacy TRIGGER_DEPLOYMENT_ID is a bare friendlyId (deployment_xxxx); a minted token is a JWT of
|
||||
* three non-empty base64url segments. Used by the dry-run to tell a pre-upgrade runner from a token.
|
||||
*/
|
||||
export function looksLikeWorkloadDeploymentToken(value: string): boolean {
|
||||
const parts = value.split(".");
|
||||
return parts.length === 3 && parts.every((part) => part.length > 0);
|
||||
}
|
||||
|
||||
export type DeploymentIdHeaderOutcome = "jwt_valid" | "jwt_invalid" | "legacy_bare";
|
||||
|
||||
/**
|
||||
* Classify the value carried in the deployment-id header for the rollout metric, verifying the
|
||||
* signature when it is JWT-shaped. Returns the claims on a valid token so callers avoid a second verify.
|
||||
*/
|
||||
export async function classifyDeploymentIdHeader(
|
||||
value: string,
|
||||
secret: string
|
||||
): Promise<{ outcome: DeploymentIdHeaderOutcome; claims?: WorkloadDeploymentTokenClaims }> {
|
||||
if (!looksLikeWorkloadDeploymentToken(value)) {
|
||||
return { outcome: "legacy_bare" };
|
||||
}
|
||||
|
||||
const result = await verifyWorkloadDeploymentToken(value, secret);
|
||||
|
||||
if (result.ok) {
|
||||
return { outcome: "jwt_valid", claims: result.claims };
|
||||
}
|
||||
|
||||
return { outcome: "jwt_invalid" };
|
||||
}
|
||||
@@ -1802,8 +1802,10 @@ describe("TaskExecutor", () => {
|
||||
// Rate limit errors should use the rate limit retry delay
|
||||
expect((result.result as any).retry.delay).toBeGreaterThan(0);
|
||||
} else {
|
||||
// Other retryable errors should use the exponential backoff
|
||||
expect((result.result as any).retry.delay).toBeGreaterThan(1000);
|
||||
// Other retryable errors should use the exponential backoff. The first retry uses
|
||||
// minDelay (1000ms) as its base, and jitter can land exactly on the floor, so this
|
||||
// is >= rather than > to avoid a flaky boundary failure.
|
||||
expect((result.result as any).retry.delay).toBeGreaterThanOrEqual(1000);
|
||||
expect((result.result as any).retry.delay).toBeLessThan(5000);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user