diff --git a/.changeset/development-oidc-native.md b/.changeset/development-oidc-native.md new file mode 100644 index 00000000000..3eb592def36 --- /dev/null +++ b/.changeset/development-oidc-native.md @@ -0,0 +1,14 @@ +--- +"miniflare": minor +"wrangler": minor +"@cloudflare/vite-plugin": minor +"@cloudflare/vitest-plugin": minor +--- + +Connect authenticated local Workers OIDC to the native token getter + +Share lazy per-Worker brokers, authenticated development sessions, bounded API responses, token profile validation, current authorization and on-demand renewal across Wrangler, `cf dev`, and the Cloudflare Vite plugin. Keep OAuth credentials in the host process and display the server-authorized development subject. Vitest live issuance requires `developmentOidc: true`; offline tests can supply a host broker mock. + +Retain authorized development sessions after temporary network, signing-service, rate-limit, and timeout failures. Honor `Retry-After` for issuance requests, and clear sessions and pending requests after login, permission, renewal, or invalid-response failures. + +This experimental integration requires a workerd release containing `Worker.oidcTokenService` and Cloudflare's development issuance service, including its human OAuth verifier and API gateway routes. The current runtime pin predates the callback; a matching local workerd build can be exercised using `MINIFLARE_WORKERD_PATH` until that release is available. diff --git a/.changeset/development-oidc-primitives.md b/.changeset/development-oidc-primitives.md new file mode 100644 index 00000000000..757306a782b --- /dev/null +++ b/.changeset/development-oidc-primitives.md @@ -0,0 +1,10 @@ +--- +"miniflare": minor +"@cloudflare/workers-auth": minor +"wrangler": patch +"@cloudflare/vite-plugin": patch +--- + +Add shared host-side development OIDC session and bounded issuance primitives, and honor the authentication profile forwarded by `cf dev` in shared development authentication. Native local OIDC issuance still requires the matching workerd callback and Cloudflare development issuance API integrations. + +Bound host request lifetimes, pending callers, and UTF-8 response data. Invalidation rejects callers promptly, including when a transport ignores abort, while retaining concurrency accounting for unfinished operations. Snapshot credentials and session metadata, and use the selected OAuth credential for account discovery. diff --git a/.changeset/oidc-validation-review.md b/.changeset/oidc-validation-review.md new file mode 100644 index 00000000000..205852736e7 --- /dev/null +++ b/.changeset/oidc-validation-review.md @@ -0,0 +1,9 @@ +--- +"@cloudflare/workers-utils": minor +"@cloudflare/remote-bindings": minor +"miniflare": minor +--- + +Reject malformed audiences consistently in experimental Workers OIDC + +Use the signer's exact URI grammar in configuration and local issuance. Reject hidden binding fields, malformed identifiers, and whitespace-only audiences before requesting a session or token. diff --git a/.changeset/workload-identity-bindings.md b/.changeset/workload-identity-bindings.md new file mode 100644 index 00000000000..ca165fd365f --- /dev/null +++ b/.changeset/workload-identity-bindings.md @@ -0,0 +1,17 @@ +--- +"@cloudflare/config": minor +"@cloudflare/workers-utils": minor +"@cloudflare/deploy-helpers": minor +"miniflare": minor +"wrangler": minor +"@cloudflare/vite-plugin": minor +"@cloudflare/vitest-plugin": minor +--- + +Replace implicit context OIDC access with explicit identity bindings. + +Configure an existing identity with its approved audience policy, then call `env.IDENTITY.getToken({ audience, fresh })`. Preserve bindings through cf configuration, uploads, Previews, local runtime configuration, and generated types. Configuration requests an approved assignment; it cannot create one. + +Recheck OAuth, Worker access, and the current local assignment on every getter. Keep session renewal lazy, forward every fresh request separately, and reject sessions that do not match the configured identity and audience policy. Local Durable Objects use the local identity profile. Vitest still requires an explicit live-issuance opt-in. + +Native execution requires the matching workerd identity-binding implementation and Cloudflare authorization services. Until a runtime release includes those changes, use `MINIFLARE_WORKERD_PATH` with that build. diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 1a02cc1ea00..c2413473029 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -1,6 +1,7 @@ name: Prerelease on: + workflow_dispatch: pull_request: push: branches: diff --git a/packages/config/package.json b/packages/config/package.json index 4294509474c..844a1271a5d 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -35,12 +35,12 @@ "test:watch": "vitest" }, "dependencies": { + "@cloudflare/workers-utils": "workspace:*", "zod": "catalog:default" }, "devDependencies": { "@cloudflare/workers-tsconfig": "workspace:*", "@cloudflare/workers-types": "catalog:default", - "@cloudflare/workers-utils": "workspace:*", "ts-dedent": "^2.2.0", "tsdown": "0.16.3", "typescript": "catalog:default", diff --git a/packages/config/src/__tests__/convert.test.ts b/packages/config/src/__tests__/convert.test.ts index a40c5bf827d..c4e6d9e78e1 100644 --- a/packages/config/src/__tests__/convert.test.ts +++ b/packages/config/src/__tests__/convert.test.ts @@ -1744,3 +1744,36 @@ describe("convertToWranglerConfig", () => { }); }); }); + +it("preserves explicit OIDC identities when converting cf configuration", ({ + expect, +}) => { + const result = convertToWranglerConfig({ + worker: { + ...baseWorker, + env: { + ORDERS: { + type: "oidc", + identity_id: "orders", + audience: "urn:orders", + allowed_audiences: ["urn:orders", "urn:reports"], + }, + REPORTS: { + type: "oidc", + identity_id: "reports", + audience: "recipient", + }, + }, + }, + containers: [], + }); + expect(result.oidc).toEqual([ + { + binding: "ORDERS", + identity_id: "orders", + audience: "urn:orders", + allowed_audiences: ["urn:orders", "urn:reports"], + }, + { binding: "REPORTS", identity_id: "reports", audience: "recipient" }, + ]); +}); diff --git a/packages/config/src/__tests__/schema.test.ts b/packages/config/src/__tests__/schema.test.ts index 05ccff212fc..67dc45ac5e3 100644 --- a/packages/config/src/__tests__/schema.test.ts +++ b/packages/config/src/__tests__/schema.test.ts @@ -1753,3 +1753,60 @@ describe("ExportSchema", () => { expect(typeAssertions).toBeTypeOf("function"); }); }); + +describe("OIDC identity bindings", () => { + it("accepts several explicit identities and exact audience policies", ({ + expect, + }) => { + const result = InputWorkerSchema.parse({ + ...baseConfig, + env: { + ORDERS: { + type: "oidc", + identity_id: "orders", + audience: "urn:orders", + allowed_audiences: ["urn:orders", "urn:reports"], + }, + REPORTS: { + type: "oidc", + identity_id: "reports", + audience: "recipient", + }, + }, + }); + expect(result.env?.ORDERS).toMatchObject({ + identity_id: "orders", + audience: "urn:orders", + }); + }); + it.for([ + { identity_id: "orders", audience: " " }, + { identity_id: "orders", audience: "recipient", allowed_audiences: [] }, + { + identity_id: "orders", + audience: "recipient", + allowed_audiences: ["other"], + }, + { + identity_id: "orders", + audience: "recipient", + allowed_audiences: ["recipient", "recipient"], + }, + { + identity_id: "orders", + audience: "recipient", + assignment_id: "copied-grant", + }, + { identity_id: "orders", audience: "recipient", subject: "identity:admin" }, + ])( + "rejects unapproved configuration fields or invalid audiences: %j", + (binding, { expect }) => { + expect( + InputWorkerSchema.safeParse({ + ...baseConfig, + env: { IDENTITY: { type: "oidc", ...binding } }, + }).success + ).toBe(false); + } + ); +}); diff --git a/packages/config/src/bindings.ts b/packages/config/src/bindings.ts index 90dfea13cf4..40f95f4c3c3 100644 --- a/packages/config/src/bindings.ts +++ b/packages/config/src/bindings.ts @@ -469,6 +469,16 @@ export interface R2Binding extends R2BindingOptions { type: "r2"; } +/** Requests an existing approved identity assignment. This configuration never grants access. */ +export interface OidcBindingOptions { + identity_id: string; + audience: string; + allowed_audiences?: string[]; +} +export interface OidcBinding extends OidcBindingOptions { + type: "oidc"; +} + export interface RateLimitBindingOptions { /** The namespace ID for this rate limiter. */ namespace: string; @@ -847,6 +857,7 @@ export interface Bindings { */ r2(options?: R2BindingOptions): R2Binding; /** Binding to a rate limiter. */ + oidc(options: OidcBindingOptions): OidcBinding; rateLimit(options: RateLimitBindingOptions): RateLimitBinding; /** * Declares a secret that is required by your Worker, exposed on `env` under @@ -952,6 +963,7 @@ export const bindings = { pipeline: (options) => ({ type: "pipeline", ...options }), k2: (options) => ({ type: "k2", ...options }), queue: (options) => ({ type: "queue", ...options }), + oidc: (options) => ({ type: "oidc", ...options }), rateLimit: (options) => ({ type: "rate-limit", ...options }), r2: (options) => ({ type: "r2", ...options }), secret: () => ({ type: "secret" }), diff --git a/packages/config/src/convert.ts b/packages/config/src/convert.ts index d1131280589..5a12a0e2885 100644 --- a/packages/config/src/convert.ts +++ b/packages/config/src/convert.ts @@ -421,6 +421,17 @@ function convertBindingsAndAssets( } switch (binding.type) { + case "oidc": { + (result.oidc ??= []).push({ + binding: name, + identity_id: binding.identity_id, + audience: binding.audience, + ...(binding.allowed_audiences === undefined + ? {} + : { allowed_audiences: binding.allowed_audiences }), + }); + break; + } case "agent-memory": { agentMemory.push( omitUndefined({ diff --git a/packages/config/src/inference.ts b/packages/config/src/inference.ts index dfa21dd23c7..121c7e62620 100644 --- a/packages/config/src/inference.ts +++ b/packages/config/src/inference.ts @@ -108,6 +108,9 @@ interface BindingTypeMap { logfwdr: any; media: MediaBinding; "mtls-certificate": Fetcher; + oidc: { + getToken(options?: { audience?: string; fresh?: boolean }): Promise; + }; "rate-limit": RateLimit; r2: R2Bucket; secret: string; diff --git a/packages/config/src/public.ts b/packages/config/src/public.ts index 88238b21374..7ce3b7bcdf4 100644 --- a/packages/config/src/public.ts +++ b/packages/config/src/public.ts @@ -53,6 +53,8 @@ export type { PipelineBindingOptions, QueueBinding, QueueBindingOptions, + OidcBinding, + OidcBindingOptions, RateLimitBinding, RateLimitBindingOptions, R2Binding, diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts index 717c918155b..ff1bd7e3cdc 100644 --- a/packages/config/src/schema.ts +++ b/packages/config/src/schema.ts @@ -1,3 +1,4 @@ +import { isOidcBinding } from "@cloudflare/workers-utils/oidc"; import * as z from "zod"; import type { SendEmailBinding, VpcNetworkBinding } from "./bindings"; import type { Settings } from "./types"; @@ -103,6 +104,25 @@ export const HyperdriveBindingSchema = z.strictObject({ }); export const KnownBindingSchema = z.discriminatedUnion("type", [ + z + .strictObject({ + type: z.literal("oidc"), + identity_id: z.string().regex(/^[A-Za-z0-9_-]{1,128}$/), + audience: z.string().min(1).max(2048), + allowed_audiences: z + .array(z.string().min(1).max(2048)) + .min(1) + .max(64) + .optional(), + }) + .refine( + ({ type: _type, ...binding }) => + isOidcBinding({ binding: "IDENTITY", ...binding }), + { + message: + "OIDC requires valid exact audiences and an allowed list containing its default.", + } + ), z.strictObject({ type: z.literal("agent-memory"), namespace: z.string(), diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index 70f4d3a1a6f..c04af5a1d30 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -31,6 +31,7 @@ import type { PipelineBinding, QueueBinding, R2Binding, + OidcBinding, RateLimitBinding, SecretBinding, SecretsStoreSecretBinding, @@ -115,6 +116,7 @@ type Binding = | PipelineBinding | QueueBinding | R2Binding + | OidcBinding | RateLimitBinding | SecretBinding | SecretsStoreSecretBinding diff --git a/packages/deploy-helpers/src/deploy/helpers/config-diffs.ts b/packages/deploy-helpers/src/deploy/helpers/config-diffs.ts index fe77f51aa15..b32a3050491 100644 --- a/packages/deploy-helpers/src/deploy/helpers/config-diffs.ts +++ b/packages/deploy-helpers/src/deploy/helpers/config-diffs.ts @@ -34,6 +34,7 @@ const reorderableBindings = { k2: true, secrets_store_secrets: true, artifacts: true, + oidc: true, ratelimits: true, analytics_engine_datasets: true, unsafe_hello_world: true, diff --git a/packages/deploy-helpers/src/deploy/helpers/create-worker-upload-form.ts b/packages/deploy-helpers/src/deploy/helpers/create-worker-upload-form.ts index 98f16e277d6..c82d3a2b633 100644 --- a/packages/deploy-helpers/src/deploy/helpers/create-worker-upload-form.ts +++ b/packages/deploy-helpers/src/deploy/helpers/create-worker-upload-form.ts @@ -474,6 +474,20 @@ export function createWorkerUploadForm( ); }); + for (const { + binding, + identity_id, + audience, + allowed_audiences, + } of extractBindingsOfType("oidc", bindings)) { + metadataBindings.push({ + type: "oidc", + name: binding, + identity_id, + audience, + ...(allowed_audiences === undefined ? {} : { allowed_audiences }), + }); + } ratelimits.forEach(({ name, namespace_id, simple }) => { metadataBindings.push({ name, diff --git a/packages/deploy-helpers/src/preview/api.ts b/packages/deploy-helpers/src/preview/api.ts index f71af9d7246..a100ac95f32 100644 --- a/packages/deploy-helpers/src/preview/api.ts +++ b/packages/deploy-helpers/src/preview/api.ts @@ -11,6 +11,9 @@ import type { } from "@cloudflare/workers-utils"; export interface Binding { + identity_id?: string; + audience?: string; + allowed_audiences?: string[]; type: string; text?: string; json?: Json; diff --git a/packages/deploy-helpers/src/preview/shared.ts b/packages/deploy-helpers/src/preview/shared.ts index b6ef2cd8e69..85292e9e469 100644 --- a/packages/deploy-helpers/src/preview/shared.ts +++ b/packages/deploy-helpers/src/preview/shared.ts @@ -661,6 +661,9 @@ function extractBindings( }; } + for (const { binding, ...value } of previews?.oidc ?? []) { + env[binding] = { type: "oidc", ...value }; + } for (const ratelimit of previews?.ratelimits ?? []) { env[ratelimit.name] = { type: "ratelimit", diff --git a/packages/deploy-helpers/src/startup-profile.ts b/packages/deploy-helpers/src/startup-profile.ts index 08ae1aede40..e14d8d8ed9d 100644 --- a/packages/deploy-helpers/src/startup-profile.ts +++ b/packages/deploy-helpers/src/startup-profile.ts @@ -311,6 +311,7 @@ type MiniflareBindingOptions = Pick< | "k2" | "queueProducers" | "r2Buckets" + | "oidc" | "ratelimits" | "secretsStoreSecrets" | "serviceBindings" @@ -368,6 +369,7 @@ async function convertWorkerBundleBindings( { queueName: string; deliveryDelay?: number } > = {}; const r2Buckets: Record = {}; + const oidc: MiniflareBindingOption<"oidc"> = {}; const ratelimits: MiniflareBindingOption<"ratelimits"> = {}; const secretsStoreSecrets: MiniflareBindingOption<"secretsStoreSecrets"> = {}; const serviceBindings: NonNullable = @@ -538,6 +540,11 @@ async function convertWorkerBundleBindings( case "flagship": flagship[binding.name] = { app_id: binding.app_id }; break; + case "oidc": { + const { name, type: _type, ...request } = binding; + oidc[name] = request; + break; + } case "ratelimit": ratelimits[binding.name] = { namespace_id: binding.namespace_id, @@ -611,6 +618,7 @@ async function convertWorkerBundleBindings( k2, queueProducers, r2Buckets, + oidc, ratelimits, secretsStoreSecrets, serviceBindings, diff --git a/packages/miniflare/package.json b/packages/miniflare/package.json index 5f5d9b41250..b3f4949061c 100644 --- a/packages/miniflare/package.json +++ b/packages/miniflare/package.json @@ -31,7 +31,7 @@ }, "scripts": { "build": "node scripts/build.mjs && pnpm run types:build", - "capnp:workerd": "capnp-es node_modules/workerd/workerd.capnp -ots:src/runtime/config/generated --src-prefix=node_modules/workerd", + "capnp:workerd": "node scripts/generate-workerd-config.mjs", "check:generate-api": "node -r esbuild-register scripts/check-generate-api.ts", "check:type": "tsc", "clean": "node -r esbuild-register ../../tools/clean/clean.ts ./dist ./dist-types", diff --git a/packages/miniflare/scripts/generate-workerd-config.mjs b/packages/miniflare/scripts/generate-workerd-config.mjs new file mode 100644 index 00000000000..5a650403c79 --- /dev/null +++ b/packages/miniflare/scripts/generate-workerd-config.mjs @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +// The development broker is upstream in workerd, awaiting its first npm release. +// Generate from the pinned schema plus that additive field, preserving other +// fields in our runtime pin (including the stub retry policy). +const schemaPath = "node_modules/workerd/workerd.capnp"; +let schema = readFileSync(schemaPath, "utf8"); +if (!schema.includes("oidcTokenService @21")) { + const marker = " workflowsEngine @20 :WorkflowsEngine;"; + assert( + schema.includes(marker), + "Workerd schema changed; update the OIDC schema supplement." + ); + schema = schema.replace( + marker, + ` oidcTokenService @21 :ServiceDesignator; + # Trusted local development broker; POST /token, JSON audience, text/plain JWT. + +${marker}` + ); +} +if (!schema.includes("oidc @29")) { + const marker = " workerdDebugPort @28"; + assert( + schema.includes(marker), + "Workerd binding schema changed; update the OIDC supplement." + ); + schema = schema.replace( + marker, + " oidc @29 :Text;\n # Fixed binding selector for the trusted OIDC broker.\n\n" + + marker + ); +} +const directory = mkdtempSync(path.join(tmpdir(), "miniflare-capnp-")); +try { + const input = path.join(directory, "workerd.capnp"); + writeFileSync(input, schema); + execFileSync( + process.execPath, + [ + "node_modules/capnp-es/dist/compiler/capnpc-js.mjs", + input, + "-ots:src/runtime/config/generated", + `--src-prefix=${directory}`, + ], + { stdio: "inherit" } + ); +} finally { + rmSync(directory, { recursive: true, force: true }); +} diff --git a/packages/miniflare/src/config/schema.ts b/packages/miniflare/src/config/schema.ts index 9666de6fcf8..5a3805c8f0d 100644 --- a/packages/miniflare/src/config/schema.ts +++ b/packages/miniflare/src/config/schema.ts @@ -42,6 +42,7 @@ import type { import type { UnsafeUniqueKey } from "../plugins/shared/constants"; import type { Log } from "../shared"; import type { WorkerRegistry } from "../shared/dev-registry-types"; +import type { DevelopmentOidcBrokerFactory } from "../shared/development-oidc-broker"; import type { Awaitable } from "../workers"; import type * as http from "node:http"; @@ -597,6 +598,18 @@ export const DevConfigSchema = z.strictObject({ unsafeInspectorProxy: z.boolean().optional(), unsafeDirectSockets: z.array(UnsafeDirectSocketSchema).optional(), unsafeOverrideFetchWorker: z.string().optional(), + developmentOidc: z + .custom( + (value) => + typeof value === "object" && + value !== null && + "key" in value && + typeof value.key === "string" && + value.key.length <= 16384 && + "create" in value && + typeof value.create === "function" + ) + .optional(), unsafeEvalBinding: z.string().optional(), useModuleFallbackService: z.boolean().optional(), /** Whether this Worker is 'public' - whether should be advertised in the dev registry diff --git a/packages/miniflare/src/config/v4-convert.ts b/packages/miniflare/src/config/v4-convert.ts index 734cd06a2d4..b0c1b328407 100644 --- a/packages/miniflare/src/config/v4-convert.ts +++ b/packages/miniflare/src/config/v4-convert.ts @@ -188,6 +188,7 @@ function convertWorkerOptions( dev.unsafeInspectorProxy = worker.unsafeInspectorProxy; dev.unsafeDirectSockets = worker.unsafeDirectSockets; dev.unsafeOverrideFetchWorker = worker.unsafeOverrideFetchWorker; + dev.developmentOidc = worker.developmentOidc; dev.unsafeEvalBinding = worker.unsafeEvalBinding; dev.useModuleFallbackService = worker.unsafeUseModuleFallbackService; dev.unsafeRegisterWorker = worker.unsafeRegisterWorker ?? true; @@ -710,6 +711,9 @@ function addProductBindings( dev: { connectionString: String(value) }, }; } + for (const [name, binding] of Object.entries(worker.oidc ?? {})) { + env[name] = { type: "oidc", ...binding }; + } for (const [name, binding] of Object.entries(worker.ratelimits ?? {})) { env[name] = { type: "rate-limit", diff --git a/packages/miniflare/src/config/v4-schema.ts b/packages/miniflare/src/config/v4-schema.ts index 01c32bac455..4fb69a24ee8 100644 --- a/packages/miniflare/src/config/v4-schema.ts +++ b/packages/miniflare/src/config/v4-schema.ts @@ -14,6 +14,7 @@ import type { Miniflare } from "../index"; import type { RemoteProxyConnectionString } from "../plugins/shared"; import type { Json } from "../shared"; import type { WorkerRegistry } from "../shared/dev-registry-types"; +import type { DevelopmentOidcBrokerFactory } from "../shared/development-oidc-broker"; import type { Awaitable } from "../workers"; import type { AssetConfig, RouterConfig } from "@cloudflare/workers-shared"; import type * as http from "node:http"; @@ -368,6 +369,18 @@ const V4WorkerOptionsShapeSchema = z.object({ unsafeInspectorProxy: z.boolean().optional(), routes: z.array(z.string()).optional(), cronTriggers: z.array(z.string()).optional(), + oidc: z + .record( + z.string(), + z + .object({ + identity_id: z.string(), + audience: z.string(), + allowed_audiences: z.array(z.string()).optional(), + }) + .strict() + ) + .optional(), bindings: z.record(z.string(), JsonSchema).optional(), /** WASM binding file paths; string values are relative to `rootPath` if not absolute. */ wasmBindings: z @@ -385,6 +398,18 @@ const V4WorkerOptionsShapeSchema = z.object({ unsafeDirectSockets: z.array(V4UnsafeDirectSocketSchema).optional(), connectHandlers: z.array(V4ConnectHandlerSchema).optional(), unsafeOverrideFetchWorker: z.string().optional(), + developmentOidc: z + .custom( + (value) => + typeof value === "object" && + value !== null && + "key" in value && + typeof value.key === "string" && + value.key.length <= 16384 && + "create" in value && + typeof value.create === "function" + ) + .optional(), unsafeEvalBinding: z.string().optional(), unsafeUseModuleFallbackService: z.boolean().optional(), unsafeRegisterWorker: z.boolean().optional(), @@ -806,6 +831,10 @@ export type V4WorkerOptionsShape = { unsafeInspectorProxy?: boolean; routes?: string[]; cronTriggers?: string[]; + oidc?: Record< + string, + { identity_id: string; audience: string; allowed_audiences?: string[] } + >; bindings?: Record; wasmBindings?: Record; textBlobBindings?: Record; @@ -831,6 +860,8 @@ export type V4WorkerOptionsShape = { } >; unsafeOverrideFetchWorker?: string; + /** Host-only development OIDC broker; never exposed to Worker code. */ + developmentOidc?: DevelopmentOidcBrokerFactory; unsafeEvalBinding?: string; unsafeUseModuleFallbackService?: boolean; unsafeRegisterWorker?: boolean; diff --git a/packages/miniflare/src/index.ts b/packages/miniflare/src/index.ts index 1284d267928..e0908a48c5d 100644 --- a/packages/miniflare/src/index.ts +++ b/packages/miniflare/src/index.ts @@ -116,6 +116,7 @@ import { getStorageCandidateName, getWorkerRegistry, } from "./shared/dev-registry"; +import { DevelopmentOidcBrokers } from "./shared/development-oidc-broker"; import { getOutboundDoProxyClassName, normaliseServiceDesignator, @@ -905,6 +906,9 @@ export class Miniflare { this.#sharedOpts = sharedOpts; this.#workerOpts = workerOpts; + this.#developmentOidc.update( + workerOpts.map((worker) => worker.dev?.developmentOidc) + ); const workerNamesToProxy = this.#workerNamesToProxy(); const enableInspectorProxy = workerNamesToProxy.size > 0; @@ -1079,6 +1083,7 @@ export class Miniflare { // Control-plane credentials belong to one instance and never enter the registry. readonly #devRegistrySecret = crypto.randomBytes(32); readonly #loopbackSecret = crypto.randomBytes(32).toString("hex"); + readonly #developmentOidc = new DevelopmentOidcBrokers(); #registryPushPromise: Promise = Promise.resolve(); #queueRegistryUpdate(): Promise { @@ -1629,6 +1634,73 @@ export class Miniflare { req: http.IncomingMessage, res?: http.ServerResponse ): Promise => { + const capability = req.headers["mf-oidc-capability"]; + if (typeof capability === "string") { + let response: Response; + try { + if ( + !this.#developmentOidc.isAuthorized(capability) || + req.method !== "POST" || + req.url !== "/token" || + req.headers["content-type"] !== "application/json" + ) { + throw new Error("Invalid callback."); + } + // Native getters send at most 2048 bytes of audience (JSON escaping can expand this). + const bytes = Buffer.alloc(16384); + let size = 0; + for await (const chunk of req) { + if (chunk.length > bytes.length - size) { + throw new Error("Invalid callback."); + } + bytes.set(chunk, size); + size += chunk.length; + } + const options: unknown = JSON.parse( + new TextDecoder("utf-8", { fatal: true }).decode( + bytes.subarray(0, size) + ) + ); + if ( + options === null || + typeof options !== "object" || + Array.isArray(options) || + Object.keys(options).some( + (key) => !["assignment", "audience", "fresh"].includes(key) + ) || + !("assignment" in options) || + typeof options.assignment !== "string" || + !/^[A-Za-z0-9_-]{1,128}$/.test(options.assignment) || + ("fresh" in options && typeof options.fresh !== "boolean") || + ("audience" in options && typeof options.audience !== "string") + ) { + throw new Error("Invalid callback."); + } + const token = await this.#developmentOidc.getToken(capability, { + assignment: options.assignment, + ...("audience" in options + ? { audience: options.audience as string } + : {}), + ...("fresh" in options ? { fresh: options.fresh as boolean } : {}), + }); + response = new Response(token, { + headers: { + "Content-Type": "text/plain", + "Cache-Control": "no-store", + }, + }); + } catch { + // Never serialize host exceptions: adapters may include credentials in them. + response = new Response( + "Local OIDC issuance failed. Check the dev server for login or permission errors.", + { status: 503, headers: { "Cache-Control": "no-store" } } + ); + } + if (res !== undefined) { + await this.#writeResponse(response, res); + } + return response; + } const supplied = req.headers[CoreHeaders.LOOPBACK_SECRET.toLowerCase()]; const actual = Buffer.from(typeof supplied === "string" ? supplied : ""); const expected = Buffer.from(this.#loopbackSecret); @@ -2319,6 +2391,7 @@ export class Miniflare { workerNames, loopbackHost, loopbackPort, + developmentOidcSecret: this.#developmentOidc.capability(workerIndex), durableObjectClassNames, unsafeEphemeralDurableObjects, workflowExporters, @@ -3156,6 +3229,9 @@ export class Miniflare { this.#previousWorkerOpts = this.#workerOpts; this.#sharedOpts = sharedOpts; this.#workerOpts = workerOpts; + this.#developmentOidc.update( + workerOpts.map((worker) => worker.dev?.developmentOidc) + ); this.#log = this.#sharedOpts.log ?? this.#log; this.#hyperdriveProxyController.log = this.#log; @@ -3858,6 +3934,7 @@ export class Miniflare { async dispose(): Promise { this.#disposeController.abort(); + this.#developmentOidc.dispose(); for (const socket of this.#dispatchConnectTcpSockets) { socket.destroy(); } diff --git a/packages/miniflare/src/plugins/core/index.ts b/packages/miniflare/src/plugins/core/index.ts index 502bd098409..c1e96060779 100644 --- a/packages/miniflare/src/plugins/core/index.ts +++ b/packages/miniflare/src/plugins/core/index.ts @@ -21,6 +21,7 @@ import { removeDirSync } from "@cloudflare/workers-utils/fs-helpers"; import SCRIPT_ACCESS_IDENTITY from "worker:access/access-identity"; import SCRIPT_DEV_CONTROL from "worker:core/dev-control"; import SCRIPT_ENTRY from "worker:core/entry"; +import SCRIPT_OIDC from "worker:core/oidc"; import OUTBOUND_WORKER from "worker:core/outbound"; import { z } from "zod"; import { kCurrentWorker } from "../../config/schema"; @@ -369,6 +370,9 @@ export const CORE_PLUGIN: Plugin = { getBindings(options, _sharedOptions, workerIndex) { const { config, legacy, dev } = options; const bindings: Awaitable[] = []; + for (const [name] of getEnvBindingsOfType(config, "oidc")) { + bindings.push({ name, oidc: name }); + } for (const [name, binding] of getEnvBindingsOfType(config, "json")) { bindings.push({ name, json: JSON.stringify(binding.value) }); @@ -490,6 +494,7 @@ export const CORE_PLUGIN: Plugin = { additionalModules, loopbackHost, loopbackPort, + developmentOidcSecret, }) { const { config, dev } = options; // Define regular user worker @@ -610,6 +615,34 @@ export const CORE_PLUGIN: Plugin = { ] : userFlags; + if (developmentOidcSecret) { + // Use the same Worker-to-Node fetch bridge as other host services. The native + // request adapter's body stream currently cannot be pumped to an external service. + services.push({ + name: `core:oidc:${workerIndex}`, + worker: { + modules: [{ name: "oidc.worker.js", esModule: SCRIPT_OIDC() }], + compatibilityDate: "2026-01-01", + bindings: [ + { + name: "BROKER", + service: { name: `core:oidc:external:${workerIndex}` }, + }, + ], + }, + }); + services.push({ + name: `core:oidc:external:${workerIndex}`, + external: { + address: `${loopbackHost}:${loopbackPort}`, + http: { + injectRequestHeaders: [ + { name: "MF-OIDC-Capability", value: developmentOidcSecret }, + ], + }, + }, + }); + } services.push({ name: serviceName, worker: { @@ -628,6 +661,9 @@ export const CORE_PLUGIN: Plugin = { : dev?.unsafeEphemeralDurableObjects ? { inMemory: kVoid } : { localDisk: DURABLE_OBJECTS_STORAGE_SERVICE_NAME }, + oidcTokenService: developmentOidcSecret + ? { name: `core:oidc:${workerIndex}` } + : undefined, globalOutbound: { name: getOutboundInterceptorName(workerIndex) }, cacheApiOutbound: { name: getCacheServiceName(workerIndex) }, moduleFallback: diff --git a/packages/miniflare/src/plugins/shared/index.ts b/packages/miniflare/src/plugins/shared/index.ts index 9c0658a0c3b..e954e00c490 100644 --- a/packages/miniflare/src/plugins/shared/index.ts +++ b/packages/miniflare/src/plugins/shared/index.ts @@ -74,6 +74,7 @@ export interface PluginServicesOptions { workerNames: string[]; loopbackHost: string; loopbackPort: number; + developmentOidcSecret?: string; // ~~Leaky abstractions~~ "Plugin specific options" :) durableObjectClassNames: DurableObjectClassNames; diff --git a/packages/miniflare/src/runtime/config/generated/workerd.ts b/packages/miniflare/src/runtime/config/generated/workerd.ts index 0c17d36b8cd..718f56c32ad 100644 --- a/packages/miniflare/src/runtime/config/generated/workerd.ts +++ b/packages/miniflare/src/runtime/config/generated/workerd.ts @@ -2315,6 +2315,11 @@ export const Worker_Binding_Which = { * */ WORKER_LOADER: 20, + /** + * Fixed binding selector for the trusted OIDC broker. + * + */ + OIDC: 22, /** * A binding that provides a connect() method to dynamically connect to any workerd * instance's debug port. This allows dynamic access to worker entrypoints via the @@ -2354,6 +2359,7 @@ export class Worker_Binding extends $.Struct { static readonly UNSAFE_EVAL = Worker_Binding_Which.UNSAFE_EVAL; static readonly MEMORY_CACHE = Worker_Binding_Which.MEMORY_CACHE; static readonly WORKER_LOADER = Worker_Binding_Which.WORKER_LOADER; + static readonly OIDC = Worker_Binding_Which.OIDC; static readonly WORKERD_DEBUG_PORT = Worker_Binding_Which.WORKERD_DEBUG_PORT; static readonly Type = Worker_Binding_Type; static readonly DurableObjectNamespaceDesignator = @@ -2897,6 +2903,21 @@ export class Worker_Binding extends $.Struct { set workerLoader(_: true) { $.utils.setUint16(0, 20, this); } + /** + * Fixed binding selector for the trusted OIDC broker. + * + */ + get oidc(): string { + $.utils.testWhich("oidc", $.utils.getUint16(0, this), 22, this); + return $.utils.getText(1, this); + } + get _isOidc(): boolean { + return $.utils.getUint16(0, this) === 22; + } + set oidc(value: string) { + $.utils.setUint16(0, 22, this); + $.utils.setText(1, value, this); + } get _isWorkerdDebugPort(): boolean { return $.utils.getUint16(0, this) === 21; } @@ -3429,7 +3450,7 @@ export class Worker_DurableObjectStorage extends $.Struct { static readonly _capnp = { displayName: "durableObjectStorage", id: "cc72b3faa57827d4", - size: new $.ObjectSize(8, 16), + size: new $.ObjectSize(8, 17), }; get _isNone(): boolean { return $.utils.getUint16(2, this) === 0; @@ -3494,7 +3515,7 @@ export class Worker_ContainerEngine extends $.Struct { static readonly _capnp = { displayName: "containerEngine", id: "82de68f58dc2eb24", - size: new $.ObjectSize(8, 16), + size: new $.ObjectSize(8, 17), }; get _isNone(): boolean { return $.utils.getUint16(4, this) === 0; @@ -3591,7 +3612,7 @@ export class Worker extends $.Struct { static readonly _capnp = { displayName: "Worker", id: "acfa77e88fd97d1c", - size: new $.ObjectSize(8, 16), + size: new $.ObjectSize(8, 17), defaultGlobalOutbound: $.readRawPointer( new Uint8Array([ 16, 7, 80, 1, 3, 0, 0, 17, 9, 74, 0, 1, 255, 105, 110, 116, 101, 114, @@ -3957,6 +3978,28 @@ export class Worker extends $.Struct { set accessBindingService(value: ServiceDesignator) { $.utils.copyFrom(value, $.utils.getPointer(14, this)); } + _adoptOidcTokenService(value: $.Orphan): void { + $.utils.adopt(value, $.utils.getPointer(16, this)); + } + _disownOidcTokenService(): $.Orphan { + return $.utils.disown(this.oidcTokenService); + } + /** + * Trusted local development broker; POST /token, JSON audience, text/plain JWT. + * + */ + get oidcTokenService(): ServiceDesignator { + return $.utils.getStruct(16, ServiceDesignator, this); + } + _hasOidcTokenService(): boolean { + return !$.utils.isNull($.utils.getPointer(16, this)); + } + _initOidcTokenService(): ServiceDesignator { + return $.utils.initStructAt(16, ServiceDesignator, this); + } + set oidcTokenService(value: ServiceDesignator) { + $.utils.copyFrom(value, $.utils.getPointer(16, this)); + } _adoptWorkflowsEngine(value: $.Orphan): void { $.utils.adopt(value, $.utils.getPointer(15, this)); } diff --git a/packages/miniflare/src/runtime/config/workerd.ts b/packages/miniflare/src/runtime/config/workerd.ts index 874a4981b15..0d129809aaa 100644 --- a/packages/miniflare/src/runtime/config/workerd.ts +++ b/packages/miniflare/src/runtime/config/workerd.ts @@ -92,6 +92,7 @@ export type Worker = ( containerEngine?: Worker_ContainerEngine; accessBlobHeader?: string; accessBindingService?: ServiceDesignator; + oidcTokenService?: ServiceDesignator; workflowsEngine?: WorkflowsEngine; }; @@ -145,6 +146,7 @@ export type Worker_Binding = { | { hyperdrive?: Worker_Binding_Hyperdrive } | { unsafeEval?: Void } | { workerLoader?: Worker_Binding_WorkerLoader } + | { oidc?: string } | { workerdDebugPort?: Void } ); diff --git a/packages/miniflare/src/shared/development-oidc-broker.ts b/packages/miniflare/src/shared/development-oidc-broker.ts new file mode 100644 index 00000000000..6e78f152c3b --- /dev/null +++ b/packages/miniflare/src/shared/development-oidc-broker.ts @@ -0,0 +1,127 @@ +import { randomBytes, timingSafeEqual } from "node:crypto"; +import { + DevelopmentOidcRequestPool, + validateDevelopmentOidcAudience, +} from "./development-oidc"; + +/** Host-side broker. Never place it or its credentials in Worker bindings. */ +export interface DevelopmentOidcBroker { + getToken(options: { + assignment: string; + audience?: string; + fresh?: boolean; + }): Promise; + dispose(): void; +} + +/** Lazy provider factory. Equal keys retain a session across code reloads. */ +export interface DevelopmentOidcBrokerFactory { + /** Include every setting that changes the linked Worker or authentication profile. */ + key: string; + create(): DevelopmentOidcBroker; +} + +interface Entry { + factory: DevelopmentOidcBrokerFactory; + capability: string; + controller: AbortController; + broker?: DevelopmentOidcBroker; +} + +/** Instance-scoped capabilities and lazy broker lifetimes, independent of Worker code. */ +export class DevelopmentOidcBrokers { + #entries = new Map(); + #requests = new DevelopmentOidcRequestPool(); + #disposed = false; + + /** Reconcile configured workers, cancelling sessions whose authority changed. */ + update(factories: (DevelopmentOidcBrokerFactory | undefined)[]): void { + if (this.#disposed) { + return; + } + for (const [index, entry] of this.#entries) { + if (factories[index]?.key !== entry.factory.key) { + this.#entries.delete(index); + entry.controller.abort( + new Error("Development OIDC configuration changed.") + ); + try { + entry.broker?.dispose(); + } catch { + /* Keep cancellation and runtime teardown independent of adapters. */ + } + } + } + factories.forEach((factory, index) => { + if (factory && !this.#entries.has(index)) { + this.#entries.set(index, { + factory: Object.freeze({ + key: factory.key, + create: factory.create.bind(factory), + }), + capability: randomBytes(32).toString("hex"), + controller: new AbortController(), + }); + } + }); + } + + /** Return a private capability for the native callback's external service. */ + capability(index: number): string | undefined { + return this.#entries.get(index)?.capability; + } + + /** Check the separate OIDC capability before reading an HTTP request body. */ + isAuthorized(capability: string): boolean { + return this.#find(capability) !== undefined; + } + + #find(capability: string): Entry | undefined { + if (capability.length !== 64) { + return undefined; + } + const supplied = Buffer.from(capability); + if (supplied.length !== 64) { + return undefined; + } + return [...this.#entries.values()].find((entry) => + timingSafeEqual(Buffer.from(entry.capability), supplied) + ); + } + + /** Handle a capability-authorized callback. Body limits must be applied before decoding. */ + async getToken( + capability: string, + options: { assignment: string; audience?: string; fresh?: boolean } + ): Promise { + const entry = this.#find(capability); + if (!entry) { + throw new Error("Development OIDC callback is unavailable."); + } + if (options.audience !== undefined) { + validateDevelopmentOidcAudience(options.audience); + } + const broker = (entry.broker ??= entry.factory.create()); + const token = await this.#requests.run( + () => broker.getToken(options), + entry.controller.signal + ); + // Reconfiguration can run after the operation settles but before this continuation. + entry.controller.signal.throwIfAborted(); + if ( + typeof token !== "string" || + token.length > 65536 || + token.trim() !== token || + !/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(token) + ) { + throw new Error("Development OIDC returned an invalid token."); + } + return token; + } + + /** Cancel callbacks before shutting down the runtime. */ + dispose(): void { + this.update([]); + this.#disposed = true; + } +} diff --git a/packages/miniflare/src/shared/development-oidc.ts b/packages/miniflare/src/shared/development-oidc.ts new file mode 100644 index 00000000000..e8a338ae1d7 --- /dev/null +++ b/packages/miniflare/src/shared/development-oidc.ts @@ -0,0 +1,228 @@ +import { Buffer } from "node:buffer"; +import { addAbortListener } from "node:events"; +import { validOidcAudience } from "@cloudflare/workers-utils/oidc"; + +/** The server-authorized identity and policy that own a development token. */ +export interface DevelopmentOidcIdentity { + accountId: string; + workerId: string; + userId: string; + developmentSessionId: string; + policy: string; + identityId: string; + assignmentId: string; + policyRevision: string; +} + +/** A token returned by the trusted development issuance transport. */ +export interface DevelopmentOidcToken { + token: string; + /** JWT expiry, in seconds since the Unix epoch. */ + expiresAt: number; +} + +/** Host-side issuance callback. Credentials must stay outside Worker code. */ +export type DevelopmentOidcTokenIssuer = ( + signal: AbortSignal +) => Promise; + +const MINIMUM_LIFETIME_MS = 60_000; +const MAXIMUM_LIFETIME_MS = 300_000; +const MAXIMUM_ENTRIES = 128; +const REQUEST_TIMEOUT_MS = 30_000; + +/** A bounded host request timed out without invalidating its authorized session. */ +export class DevelopmentOidcRequestTimeoutError extends Error { + constructor() { + super("The development OIDC request timed out. Retry the getter."); + } +} + +/** + * Bound host operations even when an adapter ignores cancellation. An aborted + * operation retains its slot until the underlying promise settles. Callers stop + * waiting immediately; transports must still release their own network resources. + */ +export class DevelopmentOidcRequestPool { + #active = 0; + + /** + * Run a host operation with a deadline and a shared concurrency bound. + * + * @param request Host operation, which must honor the supplied abort signal. + * @param parent Session or shutdown cancellation signal. + * @returns The operation's result, or a cancellation/timeout error. + */ + async run( + request: (signal: AbortSignal) => Promise, + parent: AbortSignal + ): Promise { + parent.throwIfAborted(); + if (this.#active >= MAXIMUM_ENTRIES) { + throw new Error("Too many concurrent development OIDC requests."); + } + this.#active++; + const controller = new AbortController(); + const signal = AbortSignal.any([parent, controller.signal]); + const timer = setTimeout(() => { + controller.abort(new DevelopmentOidcRequestTimeoutError()); + }, REQUEST_TIMEOUT_MS); + timer.unref(); + const result = Promise.resolve().then(() => { + // Disposal before this microtask must not start network activity. + signal.throwIfAborted(); + return request(signal); + }); + return new Promise((resolve, reject) => { + const subscription = addAbortListener(signal, () => { + cleanup(); + reject(signal.reason); + }); + function cleanup() { + clearTimeout(timer); + subscription[Symbol.dispose](); + } + void result.then( + (value) => { + this.#active--; + cleanup(); + if (signal.aborted) { + reject(signal.reason); + } else { + resolve(value); + } + }, + (error: unknown) => { + this.#active--; + cleanup(); + reject(error); + } + ); + }); + } +} + +function isBoundedString(value: unknown, maxBytes: number): value is string { + return ( + typeof value === "string" && + value.length > 0 && + value.length <= maxBytes && + value.isWellFormed() && + Buffer.byteLength(value, "utf8") <= maxBytes + ); +} + +/** + * Validate the same exact StringOrURI grammar as the signing API. + * + * @param audience Audience received from a getter or authorized session. + */ +export function validateDevelopmentOidcAudience( + audience: unknown +): asserts audience is string { + if (!validOidcAudience(audience)) { + throw new Error( + "Development OIDC requires a StringOrURI audience of 1–2048 UTF-8 bytes that is not whitespace-only." + ); + } +} + +/** + * Copy only bounded identity fields so adapters cannot mutate cache authority. + * + * @param identity Server-authorized account, Worker, user, session, and policy. + * @returns An immutable snapshot containing no transport response data. + */ +export function snapshotDevelopmentOidcIdentity( + identity: DevelopmentOidcIdentity +): Readonly { + const snapshot = { + accountId: identity?.accountId, + workerId: identity?.workerId, + userId: identity?.userId, + developmentSessionId: identity?.developmentSessionId, + policy: identity?.policy, + identityId: identity?.identityId, + assignmentId: identity?.assignmentId, + policyRevision: identity?.policyRevision, + }; + if (Object.values(snapshot).some((value) => !isBoundedString(value, 4096))) { + throw new Error( + "Development OIDC requires a complete authorized identity with bounded fields." + ); + } + return Object.freeze(snapshot); +} + +interface CachedToken extends DevelopmentOidcToken { + monotonicExpiry: number; +} + +/** Bound and validate each issuance. Only the server caches assertions, after current authorization. */ +export class DevelopmentOidcTokenRequests { + #requests = new DevelopmentOidcRequestPool(); + #controller = new AbortController(); + #disposed = false; + async getToken( + identity: DevelopmentOidcIdentity, + audience: string, + issue: DevelopmentOidcTokenIssuer + ): Promise { + if (this.#disposed) { + throw new Error("The development OIDC session has been disposed."); + } + snapshotDevelopmentOidcIdentity(identity); + validateDevelopmentOidcAudience(audience); + const controller = this.#controller; + const token = await this.#requests.run(async (signal) => { + const response = await issue(signal); + signal.throwIfAborted(); + const token = response?.token, + expiresAt = response?.expiresAt; + const remaining = expiresAt * 1000 - Date.now(); + if ( + !isBoundedString(token, 65536) || + !Number.isSafeInteger(expiresAt) || + remaining < MINIMUM_LIFETIME_MS || + remaining > MAXIMUM_LIFETIME_MS + ) { + throw new Error( + "Development OIDC issuance returned an invalid token or lifetime. Retry the getter." + ); + } + return { + token, + expiresAt, + monotonicExpiry: performance.now() + remaining, + }; + }, controller.signal); + controller.signal.throwIfAborted(); + if (!isReusable(token)) { + throw new Error( + "Development OIDC issuance returned an invalid token or lifetime. Retry the getter." + ); + } + return token.token; + } + clear(): void { + this.#controller.abort( + new Error( + "The development OIDC session changed during issuance. Retry the getter." + ) + ); + this.#controller = new AbortController(); + } + dispose(): void { + this.#disposed = true; + this.clear(); + } +} + +function isReusable(token: CachedToken): boolean { + return ( + Math.min( + token.expiresAt * 1000 - Date.now(), + token.monotonicExpiry - performance.now() + ) >= MINIMUM_LIFETIME_MS + ); +} diff --git a/packages/miniflare/src/shared/index.ts b/packages/miniflare/src/shared/index.ts index 564fd7f10d3..825fb6b07e9 100644 --- a/packages/miniflare/src/shared/index.ts +++ b/packages/miniflare/src/shared/index.ts @@ -1,4 +1,6 @@ export * from "./error"; +export * from "./development-oidc"; +export * from "./development-oidc-broker"; export * from "./event"; export * from "./forwarded-proto"; export * from "./log"; diff --git a/packages/miniflare/src/workers/core/oidc.worker.ts b/packages/miniflare/src/workers/core/oidc.worker.ts new file mode 100644 index 00000000000..e3180c1359c --- /dev/null +++ b/packages/miniflare/src/workers/core/oidc.worker.ts @@ -0,0 +1,16 @@ +interface Env { + BROKER: Fetcher; +} + +export default { + async fetch(request: Request, env: Env): Promise { + // Only the native getter can reach this service. Materialize its bounded + // JSON body before crossing the HTTP bridge; it contains only the fixed selector, audience, and freshness option. + const body = await request.text(); + return env.BROKER.fetch("http://oidc/token", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }); + }, +} satisfies ExportedHandler; diff --git a/packages/miniflare/test/plugins/core/development-oidc.spec.ts b/packages/miniflare/test/plugins/core/development-oidc.spec.ts new file mode 100644 index 00000000000..4765024fb86 --- /dev/null +++ b/packages/miniflare/test/plugins/core/development-oidc.spec.ts @@ -0,0 +1,245 @@ +import { + Miniflare, + type DevelopmentOidcBrokerFactory, + type WorkerOptions, +} from "miniflare"; +import { describe, test, vi } from "vitest"; +import { singleModuleManifest, useDispose } from "../../test-shared"; + +const identity = { + type: "oidc" as const, + identity_id: "orders", + audience: "recipient", +}; +const token = "eyJhbGciOiJSUzI1NiJ9.eyJleGVjdXRpb24iOiJsb2NhbCJ9.c2ln"; + +function worker(developmentOidc?: DevelopmentOidcBrokerFactory): WorkerOptions { + return { + config: { + name: "linked", + env: { IDENTITY: identity }, + compatibilityDate: "2026-01-01", + compatibilityFlags: ["workers_oidc"], + manifest: singleModuleManifest(` + export default { async fetch(request, env, ctx) { + try { + if (new URL(request.url).pathname === "/env") return Response.json(Object.keys(env)); + const audience = new URL(request.url).searchParams.get("audience"); + return new Response(await env.IDENTITY.getToken(audience === null ? undefined : {audience})); + } catch (error) { return new Response(error.message, {status: 500}); } + } }; + `), + }, + dev: { developmentOidc }, + }; +} + +// The native callback has not reached the pinned npm runtime yet. CI enables +// this suite when testing the matching workerd build through its standard override. +describe.skipIf(!process.env.MINIFLARE_WORKERD_PATH)( + "native development OIDC broker", + () => { + test("keeps multiple binding selectors and simultaneous fresh calls distinct", async ({ + expect, + }) => { + let serial = 0; + const getToken = vi.fn( + async ({ assignment }: { assignment: string }) => + `e30.e30.${Buffer.from(`${assignment}-${++serial}`).toString("base64url")}` + ); + const mf = new Miniflare({ + workers: [ + { + config: { + name: "multiple", + compatibilityDate: "2026-01-01", + compatibilityFlags: ["workers_oidc"], + env: { + FIRST: identity, + SECOND: { ...identity, identity_id: "reports" }, + }, + manifest: + singleModuleManifest(`export default {async fetch(request, env) { + return Response.json(await Promise.all([env.FIRST.getToken({fresh:true}), env.FIRST.getToken({fresh:true}), env.SECOND.getToken()])); + }};`), + }, + dev: { + developmentOidc: { + key: "multiple", + create: () => ({ getToken, dispose() {} }), + }, + }, + }, + ], + }); + useDispose(mf); + const result = (await ( + await mf.dispatchFetch("http://localhost/") + ).json()) as string[]; + expect(new Set(result).size).toBe(3); + expect(getToken.mock.calls.map((call) => call[0])).toEqual([ + { assignment: "FIRST", fresh: true }, + { assignment: "FIRST", fresh: true }, + { assignment: "SECOND", fresh: false }, + ]); + }); + + test("shares development identity with local DOs and isolates service/RPC target Workers", async ({ + expect, + }) => { + const otherToken = "e30.eyJ3b3JrZXIiOiJvdGhlciJ9.c2ln"; + const getToken = vi.fn(async () => token), + otherGetToken = vi.fn(async () => otherToken); + const mf = new Miniflare({ + workers: [ + { + config: { + name: "first", + compatibilityDate: "2026-01-01", + compatibilityFlags: ["workers_oidc"], + manifest: singleModuleManifest(` + import { DurableObject } from "cloudflare:workers"; + export class LocalObject extends DurableObject { + async token() {return this.env.IDENTITY.getToken();} + async explicit() {return this.env.IDENTITY.getToken({subject: "object"});} + } + export default {async fetch(request, env, ctx) { + const one = env.OBJECTS.getByName("one"), two = env.OBJECTS.getByName("two"); + let explicitRejected = false; + try {await one.explicit();} catch {explicitRejected = true;} + return Response.json({worker: await env.IDENTITY.getToken(), one: await one.token(), two: await two.token(), target: await env.OTHER.token(), explicitRejected}); + }}; + `), + env: { + IDENTITY: identity, + OBJECTS: { + type: "durable-object", + worker: "first", + exportName: "LocalObject", + }, + OTHER: { + type: "worker", + worker: "second", + exportName: "Issuer", + }, + }, + exports: { + LocalObject: { type: "durable-object", storage: "sqlite" }, + }, + }, + dev: { + developmentOidc: { + key: "first", + create: () => ({ getToken, dispose() {} }), + }, + }, + }, + { + config: { + name: "second", + env: { IDENTITY: identity }, + compatibilityDate: "2026-01-01", + compatibilityFlags: ["workers_oidc"], + manifest: singleModuleManifest(` + import {WorkerEntrypoint} from "cloudflare:workers"; + export class Issuer extends WorkerEntrypoint {async token() {return this.env.IDENTITY.getToken();}} + export default {fetch() {return new Response("second");}}; + `), + }, + dev: { + developmentOidc: { + key: "second", + create: () => ({ getToken: otherGetToken, dispose() {} }), + }, + }, + }, + ], + }); + useDispose(mf); + expect( + await (await mf.dispatchFetch("http://localhost/")).json() + ).toEqual({ + worker: token, + one: token, + two: token, + target: otherToken, + explicitRejected: true, + }); + expect(getToken).toHaveBeenCalledTimes(3); + expect(otherGetToken).toHaveBeenCalledOnce(); + }); + + test("routes default/explicit audiences through explicit bindings; retains reloads and disposes changes", async ({ + expect, + }) => { + const getToken = vi.fn(async () => token), + dispose = vi.fn(); + const create = vi.fn(() => ({ getToken, dispose })); + const factory = { key: "linked", create }; + const mf = new Miniflare({ workers: [worker(factory)] }); + useDispose(mf); + await mf.ready; + expect(create).not.toHaveBeenCalled(); + expect(await (await mf.dispatchFetch("http://localhost/")).text()).toBe( + token + ); + expect(getToken).toHaveBeenLastCalledWith({ + assignment: "IDENTITY", + fresh: false, + }); + expect( + await ( + await mf.dispatchFetch("http://localhost/?audience=exact%22%E9%9B%AA") + ).text() + ).toBe(token); + expect(getToken).toHaveBeenLastCalledWith({ + assignment: "IDENTITY", + audience: 'exact"雪', + fresh: false, + }); + expect( + await (await mf.dispatchFetch("http://localhost/env")).json() + ).toEqual(["IDENTITY"]); + await mf.setOptions({ workers: [worker({ ...factory })] }); + expect(await (await mf.dispatchFetch("http://localhost/")).text()).toBe( + token + ); + expect(create).toHaveBeenCalledOnce(); + expect(dispose).not.toHaveBeenCalled(); + await mf.setOptions({ + workers: [worker({ ...factory, key: "another" })], + }); + expect(dispose).toHaveBeenCalledOnce(); + expect(await (await mf.dispatchFetch("http://localhost/")).text()).toBe( + token + ); + await mf.dispose(); + expect(dispose).toHaveBeenCalledTimes(2); + }); + test("rejects absent brokers and suppresses host exception secrets", async ({ + expect, + }) => { + const mf = new Miniflare({ + workers: [ + worker({ + key: "errors", + create: () => ({ + getToken: async () => { + throw new Error("bearer-secret"); + }, + dispose() {}, + }), + }), + ], + }); + useDispose(mf); + const response = await mf.dispatchFetch("http://localhost/"); + expect(response.status).toBe(500); + expect(await response.text()).not.toContain("bearer-secret"); + await mf.setOptions({ workers: [worker()] }); + const unavailable = await mf.dispatchFetch("http://localhost/"); + expect(unavailable.status).toBe(500); + await unavailable.text(); + }); + } +); diff --git a/packages/miniflare/test/shared/development-oidc-broker.spec.ts b/packages/miniflare/test/shared/development-oidc-broker.spec.ts new file mode 100644 index 00000000000..a0ce32c8456 --- /dev/null +++ b/packages/miniflare/test/shared/development-oidc-broker.spec.ts @@ -0,0 +1,108 @@ +import { DevelopmentOidcBrokers, DevelopmentOidcRequestPool } from "miniflare"; +import { describe, it, vi } from "vitest"; + +const token = "e30.e30.c2ln"; +describe("development OIDC capability lifecycle", () => { + it("isolates instances and workers, revokes removed capabilities, and creates lazily", async ({ + expect, + }) => { + const manager = new DevelopmentOidcBrokers(), + other = new DevelopmentOidcBrokers(); + const create = vi.fn(() => ({ + getToken: async () => token, + dispose: vi.fn(), + })); + manager.update([ + { key: "one", create }, + { key: "two", create }, + ]); + expect(create).not.toHaveBeenCalled(); + const first = manager.capability(0) ?? "", + second = manager.capability(1) ?? ""; + expect(first).toHaveLength(64); + expect(first).not.toBe(second); + expect(other.isAuthorized(first)).toBe(false); + expect(manager.isAuthorized("é".repeat(64))).toBe(false); + expect(await manager.getToken(first, { assignment: "IDENTITY" })).toBe( + token + ); + expect(create).toHaveBeenCalledOnce(); + manager.update([{ key: "new", create }]); + expect(manager.isAuthorized(first)).toBe(false); + expect(manager.isAuthorized(second)).toBe(false); + await expect( + manager.getToken(first, { assignment: "IDENTITY" }) + ).rejects.toThrow("unavailable"); + manager.dispose(); + }); + it("cancels pending getters on shutdown and observes late failures", async ({ + expect, + }) => { + const manager = new DevelopmentOidcBrokers(); + let reject: ((error: Error) => void) | undefined; + const dispose = vi.fn(); + manager.update([ + { + key: "one", + create: () => ({ + getToken: () => + new Promise((_, fail) => { + reject = fail; + }), + dispose, + }), + }, + ]); + const pending = manager.getToken(manager.capability(0) ?? "", { + assignment: "IDENTITY", + }); + await Promise.resolve(); + manager.dispose(); + await expect(pending).rejects.toThrow("configuration changed"); + reject?.(new Error("late")); + expect(dispose).toHaveBeenCalledOnce(); + }); + it("rejects a resolved operation if its authority changes before the caller resumes", async ({ + expect, + }) => { + const manager = new DevelopmentOidcBrokers(); + manager.update([ + { + key: "one", + create: () => ({ getToken: async () => token, dispose() {} }), + }, + ]); + // Simulate the operation settling before the awaiting caller resumes. + const operation = vi + .spyOn(DevelopmentOidcRequestPool.prototype, "run") + .mockResolvedValueOnce(token); + try { + const pending = manager.getToken(manager.capability(0) ?? "", { + assignment: "IDENTITY", + }); + manager.update([]); + await expect(pending).rejects.toThrow("configuration changed"); + } finally { + operation.mockRestore(); + manager.dispose(); + } + }); + it.for(["invalid", "x".repeat(65537), "a.b.c\n"])( + "rejects malformed or oversized broker tokens", + async (value, { expect }) => { + const manager = new DevelopmentOidcBrokers(); + manager.update([ + { + key: "one", + create: () => ({ getToken: async () => value, dispose() {} }), + }, + ]); + await expect( + manager.getToken(manager.capability(0) ?? "", { + assignment: "IDENTITY", + }) + ).rejects.toThrow("invalid token"); + manager.dispose(); + } + ); +}); diff --git a/packages/miniflare/test/shared/development-oidc.spec.ts b/packages/miniflare/test/shared/development-oidc.spec.ts new file mode 100644 index 00000000000..c182041b743 --- /dev/null +++ b/packages/miniflare/test/shared/development-oidc.spec.ts @@ -0,0 +1,423 @@ +import { afterEach, beforeEach, describe, it, vi } from "vitest"; +import { + DevelopmentOidcRequestTimeoutError, + DevelopmentOidcTokenRequests, +} from "../../src/shared/development-oidc"; +import type { + DevelopmentOidcIdentity, + DevelopmentOidcToken, +} from "../../src/shared/development-oidc"; + +const identity: DevelopmentOidcIdentity = { + accountId: "account", + workerId: "worker", + userId: "user", + developmentSessionId: "session", + policy: "policy", + identityId: "orders", + assignmentId: "local-orders", + policyRevision: "1:1", +}; +const audience = "https://example.com"; + +function issued(token = "development-token"): DevelopmentOidcToken { + return { token, expiresAt: Math.floor(Date.now() / 1000) + 300 }; +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(1_800_000_000_000); +}); +afterEach(() => vi.useRealTimers()); + +describe("development OIDC token requests", () => { + it.for(Object.keys(identity) as (keyof DevelopmentOidcIdentity)[])( + "rejects missing %s before issuance", + async (field, { expect }) => { + const cache = new DevelopmentOidcTokenRequests(); + const issue = vi.fn(async () => issued()); + await expect( + cache.getToken({ ...identity, [field]: "" }, audience, issue) + ).rejects.toThrow("complete authorized identity"); + expect(issue).not.toHaveBeenCalled(); + } + ); + + it("authorizes concurrent requests independently and performs no idle renewal", async ({ + expect, + }) => { + const cache = new DevelopmentOidcTokenRequests(); + const issue = vi.fn(async () => issued()); + expect( + await Promise.all( + Array.from({ length: 20 }, () => + cache.getToken(identity, audience, issue) + ) + ) + ).toEqual(Array(20).fill("development-token")); + expect(issue).toHaveBeenCalledTimes(20); + await vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000); + expect(issue).toHaveBeenCalledTimes(20); + expect(await cache.getToken(identity, audience, issue)).toBe( + "development-token" + ); + expect(issue).toHaveBeenCalledTimes(21); + }); + + it("accepts exactly 60 seconds remaining and rejects less", async ({ + expect, + }) => { + const requests = new DevelopmentOidcTokenRequests(); + await expect( + requests.getToken(identity, audience, async () => ({ + token: "valid", + expiresAt: Date.now() / 1000 + 60, + })) + ).resolves.toBe("valid"); + await expect( + requests.getToken(identity, audience, async () => ({ + token: "expired", + expiresAt: Date.now() / 1000 + 59, + })) + ).rejects.toThrow("invalid token or lifetime"); + }); + + it("does not extend cached lifetime when the wall clock moves backwards", async ({ + expect, + }) => { + const cache = new DevelopmentOidcTokenRequests(); + const issue = vi.fn(async () => issued()); + await cache.getToken(identity, audience, issue); + vi.advanceTimersByTime(241_000); + vi.setSystemTime(1_800_000_000_000); + await cache.getToken(identity, audience, issue); + expect(issue).toHaveBeenCalledTimes(2); + }); + + it.for(Object.keys(identity) as (keyof DevelopmentOidcIdentity)[])( + "separates %s", + async (field, { expect }) => { + const cache = new DevelopmentOidcTokenRequests(); + const issue = vi.fn(async () => issued()); + await cache.getToken(identity, audience, issue); + await cache.getToken({ ...identity, [field]: "other" }, audience, issue); + expect(issue).toHaveBeenCalledTimes(2); + } + ); + + it("separates audiences and keys containing separators", async ({ + expect, + }) => { + const cache = new DevelopmentOidcTokenRequests(); + const issue = vi.fn(async () => issued()); + await cache.getToken(identity, audience, issue); + await cache.getToken(identity, audience + "/other", issue); + await cache.getToken( + { ...identity, userId: "a:b", developmentSessionId: "c" }, + audience, + issue + ); + await cache.getToken( + { ...identity, userId: "a", developmentSessionId: "b:c" }, + audience, + issue + ); + expect(issue).toHaveBeenCalledTimes(4); + }); + + it("propagates renewal failure without returning the old token, then recovers", async ({ + expect, + }) => { + const cache = new DevelopmentOidcTokenRequests(); + const issue = vi + .fn(async () => issued()) + .mockResolvedValueOnce(issued("old")); + await cache.getToken(identity, audience, issue); + vi.advanceTimersByTime(241_000); + issue.mockRejectedValueOnce(new Error("Permission denied")); + await expect(cache.getToken(identity, audience, issue)).rejects.toThrow( + "Permission denied" + ); + expect(await cache.getToken(identity, audience, issue)).toBe( + "development-token" + ); + }); + + it.for([NaN, Infinity, 1.5, -1, 1_800_000_059, 1_800_000_301])( + "rejects invalid expiry %s", + async (expiresAt, { expect }) => { + const cache = new DevelopmentOidcTokenRequests(); + await expect( + cache.getToken(identity, audience, async () => ({ + token: "invalid", + expiresAt, + })) + ).rejects.toThrow("invalid token or lifetime"); + } + ); + + it.for(["", "x".repeat(65_537)])( + "rejects invalid token length (%#)", + async (token, { expect }) => { + const cache = new DevelopmentOidcTokenRequests(); + await expect( + cache.getToken(identity, audience, async () => issued(token)) + ).rejects.toThrow("invalid token or lifetime"); + } + ); + + it("checks lifetime again after awaiting issuance", async ({ expect }) => { + const cache = new DevelopmentOidcTokenRequests(); + const token = issued(); + await expect( + cache.getToken(identity, audience, async () => { + vi.setSystemTime(Date.now() + 241_000); + return token; + }) + ).rejects.toThrow("invalid token or lifetime"); + }); + + it("clears cached tokens and rejects pending results across session changes", async ({ + expect, + }) => { + const cache = new DevelopmentOidcTokenRequests(); + let complete: ((value: DevelopmentOidcToken) => void) | undefined; + const issue = vi.fn( + (signal: AbortSignal) => + new Promise((resolve) => { + expect(signal.aborted).toBe(false); + complete = resolve; + }) + ); + const pending = cache.getToken(identity, audience, issue); + await Promise.resolve(); + cache.clear(); + expect(issue.mock.calls[0][0].aborted).toBe(true); + complete?.(issued()); + await expect(pending).rejects.toThrow("session changed"); + const fresh = vi.fn(async () => issued("fresh")); + expect(await cache.getToken(identity, audience, fresh)).toBe("fresh"); + cache.clear(); + await cache.getToken(identity, audience, fresh); + expect(fresh).toHaveBeenCalledTimes(2); + }); + + it("keeps new requests independent of an old cancelled request", async ({ + expect, + }) => { + const cache = new DevelopmentOidcTokenRequests(); + let fail: ((reason: Error) => void) | undefined; + const pending = cache.getToken( + identity, + audience, + () => + new Promise((_resolve, reject) => { + fail = reject; + }) + ); + await Promise.resolve(); + cache.clear(); + const fresh = vi.fn(async () => issued()); + await cache.getToken(identity, audience, fresh); + fail?.(new Error("old request failed")); + await expect(pending).rejects.toThrow("session changed"); + await cache.getToken(identity, audience, fresh); + expect(fresh).toHaveBeenCalledTimes(2); + }); + + it("rejects revoked authorization even after a successful token", async ({ + expect, + }) => { + const requests = new DevelopmentOidcTokenRequests(); + const issue = vi.fn(async () => issued()); + await requests.getToken(identity, audience, issue); + issue.mockRejectedValueOnce(new Error("assignment revoked")); + await expect(requests.getToken(identity, audience, issue)).rejects.toThrow( + "assignment revoked" + ); + expect(issue).toHaveBeenCalledTimes(2); + }); + + it("bounds pending issuance and cancels it on disposal", async ({ + expect, + }) => { + const cache = new DevelopmentOidcTokenRequests(); + const signals: AbortSignal[] = []; + const completions: ((value: DevelopmentOidcToken) => void)[] = []; + const requests = Array.from({ length: 128 }, (_, i) => + cache.getToken(identity, String(i), (signal) => { + signals.push(signal); + return new Promise((resolve) => completions.push(resolve)); + }) + ); + await expect( + cache.getToken(identity, "overflow", async () => issued()) + ).rejects.toThrow("Too many concurrent"); + await Promise.resolve(); + cache.dispose(); + expect(signals.every((signal) => signal.aborted)).toBe(true); + for (const resolve of completions) { + resolve(issued()); + } + const results = await Promise.allSettled(requests); + expect(results.every((result) => result.status === "rejected")).toBe(true); + await expect( + cache.getToken(identity, audience, async () => issued()) + ).rejects.toThrow("disposed"); + }); +}); + +describe("development OIDC resource and cancellation boundaries", () => { + it.for([" ", "x".repeat(2049), "é".repeat(1025), "\ud800"])( + "rejects malformed or oversized audiences (%#)", + async (value, { expect }) => { + const issue = vi.fn(async () => issued()); + await expect( + new DevelopmentOidcTokenRequests().getToken(identity, value, issue) + ).rejects.toThrow("1–2048 UTF-8 bytes"); + expect(issue).not.toHaveBeenCalled(); + } + ); + + it("bounds identity and token bytes, rather than just characters", async ({ + expect, + }) => { + const cache = new DevelopmentOidcTokenRequests(); + const issue = vi.fn(async () => issued("é".repeat(32_769))); + await expect( + cache.getToken({ ...identity, policy: "é".repeat(2049) }, audience, issue) + ).rejects.toThrow("bounded fields"); + expect(issue).not.toHaveBeenCalled(); + await expect(cache.getToken(identity, audience, issue)).rejects.toThrow( + "invalid token or lifetime" + ); + }); + + it("does not start issuance if disposed before the deferred callback", async ({ + expect, + }) => { + const cache = new DevelopmentOidcTokenRequests(); + const issue = vi.fn(async () => issued()); + const pending = cache.getToken(identity, audience, issue); + const rejected = expect(pending).rejects.toThrow("session changed"); + cache.dispose(); + await rejected; + expect(issue).not.toHaveBeenCalled(); + }); + + it("rejects immediately on invalidation even when the issuer ignores abort", async ({ + expect, + }) => { + const cache = new DevelopmentOidcTokenRequests(); + const pending = cache.getToken( + identity, + audience, + () => new Promise(() => {}) + ); + const rejected = expect(pending).rejects.toThrow("session changed"); + await Promise.resolve(); + cache.clear(); + await rejected; + }); + + it("times out stalled issuance and still observes a late rejection", async ({ + expect, + }) => { + const cache = new DevelopmentOidcTokenRequests(); + let fail: ((reason: Error) => void) | undefined; + let signal: AbortSignal | undefined; + const pending = cache.getToken(identity, audience, (value) => { + signal = value; + return new Promise((_resolve, reject) => { + fail = reject; + }); + }); + const rejected = expect(pending).rejects.toBeInstanceOf( + DevelopmentOidcRequestTimeoutError + ); + await vi.advanceTimersByTimeAsync(30_000); + await rejected; + expect(signal?.aborted).toBe(true); + fail?.(new Error("late network error")); + await Promise.resolve(); + expect(await cache.getToken(identity, audience, async () => issued())).toBe( + "development-token" + ); + }); + + it("bounds concurrent calls for the same identity", async ({ expect }) => { + const cache = new DevelopmentOidcTokenRequests(); + const issue = vi.fn(() => new Promise(() => {})); + const requests = Array.from({ length: 128 }, () => + cache.getToken(identity, audience, issue) + ); + const settled = Promise.allSettled(requests); + await expect(cache.getToken(identity, audience, issue)).rejects.toThrow( + "Too many concurrent" + ); + expect(issue).toHaveBeenCalledTimes(128); + cache.dispose(); + expect((await settled).every((value) => value.status === "rejected")).toBe( + true + ); + }); + + it("keeps physical issuance bounded across repeated cache clears", async ({ + expect, + }) => { + const cache = new DevelopmentOidcTokenRequests(); + const completions: ((value: DevelopmentOidcToken) => void)[] = []; + const issue = vi.fn( + () => + new Promise((resolve) => { + completions.push(resolve); + }) + ); + for (let i = 0; i < 128; i++) { + const pending = cache.getToken(identity, audience, issue); + const rejected = expect(pending).rejects.toThrow("session changed"); + await Promise.resolve(); + cache.clear(); + await rejected; + } + await expect(cache.getToken(identity, audience, issue)).rejects.toThrow( + "Too many concurrent" + ); + expect(issue).toHaveBeenCalledTimes(128); + completions.shift()?.(issued()); + await vi.advanceTimersByTimeAsync(0); + expect( + await cache.getToken(identity, audience, async () => issued("fresh")) + ).toBe("fresh"); + for (const complete of completions) { + complete(issued()); + } + }); + + it("snapshots an issuance response once for every waiter", async ({ + expect, + }) => { + const cache = new DevelopmentOidcTokenRequests(); + let reads = 0; + const response = { + get token() { + reads++; + return "token-" + reads; + }, + expiresAt: issued().expiresAt, + }; + const results = await Promise.all( + Array.from({ length: 10 }, () => + cache.getToken(identity, audience, async () => response) + ) + ); + expect(results).toEqual( + Array.from({ length: 10 }, (_, i) => "token-" + (i + 1)) + ); + expect(reads).toBe(10); + response.expiresAt = 0; + expect(await cache.getToken(identity, audience, async () => issued())).toBe( + "development-token" + ); + }); +}); diff --git a/packages/remote-bindings/README.md b/packages/remote-bindings/README.md new file mode 100644 index 00000000000..f8d7efa015d --- /dev/null +++ b/packages/remote-bindings/README.md @@ -0,0 +1,132 @@ +# Shared authenticated development tooling + +This package supplies host-side remote binding sessions and authentication to +Wrangler, the Cloudflare Vite plugin, and the Vitest plugin. + +## Authenticated local development OIDC + +`getDevelopmentOidcAuthHook()` resolves the selected cf or Wrangler profile and +reads its human OAuth credentials on demand. API tokens, global keys, and temporary +accounts cannot substitute for a developer's OAuth login. A parent `cf dev` +process forwards its explicit profile through `CLOUDFLARE_DEV_AUTH_PROFILE`, with +`CLOUDFLARE_CF_AUTH` selecting cf credential storage. Neither variable contains +credentials. Account discovery uses that same OAuth credential even when an +unrelated API token is present in the environment. + +`DevelopmentOidcProvider` owns one linked Worker's authenticated development +session. Keep it across code reloads and dispose it on shutdown or Worker +configuration changes. It rereads authentication on each getter, clears its cache +and cancels outstanding requests when credentials change, and rejects explicit +local DO object/namespace subjects. It is independent of remote bindings: a +Worker with no remote bindings still needs its own development identity. + +The provider uses Miniflare's `DevelopmentOidcTokenCache`. The cache separates +account, Worker, user, development session, policy, and effective audience. It +coalesces requests, bounds memory and concurrent issuance, and returns tokens only +while at least 60 seconds remain. Renewal happens on demand, with no idle polling +and no stale-token fallback. Cached lifetime also uses a monotonic deadline so a +wall-clock rollback cannot extend it. Identity fields are capped at 4 KiB each, +audiences at 2,048 UTF-8 bytes, and token strings at 64 KiB. Metadata and issuance +responses are copied into immutable snapshots; credential-bearing provider state +uses JavaScript private fields. + +Each cache and provider permits at most 128 waiting getters. Each host request +pool permits at most 128 underlying operations and imposes a 30-second deadline. +Invalidation and shutdown reject waiting callers immediately. Operations that +ignore abort retain their concurrency slot until they settle, including across +cache clears; the pool fails closed if all slots are stuck. These are initial +host-side limits, not Cloudflare's issuance rate limits, and need validation with +real adapters and load tests. A transport must honor cancellation and bound HTTP +response bodies before decoding them; these primitives cannot reclaim resources +owned by a misbehaving adapter. + +`DevelopmentOidcTransport` is the host-only adapter contract. It must call the +authorized Cloudflare development API, use human OAuth, and validate the returned +development token profile before returning a token. Cloudflare must resolve the +Worker and user and create the session; local config and transport inputs grant +no signing authority. Never log the OAuth credential or returned bearer token, +and never sign development tokens locally. + +### Consumer integration and native callback + +`getDevelopmentOidcOptions()` supplies a lazy host broker for Workers with the +`workers_oidc` compatibility flag. Wrangler local development, `cf dev` delegates, +and the Cloudflare Vite plugin (dev and local preview) install it for each user +Worker. Miniflare owns the broker lifecycle: code reloads retain a session; +configuration changes and shutdown cancel and dispose it. Internal proxy, asset, +and helper Workers do not receive the broker. + +The native `Worker.oidcTokenService` sends `POST /token` with `{}` or +`{"audience":"exact audience"}` and receives a compact JWT as `text/plain`. +Miniflare configures a private external service with a separate random capability +for each Worker and instance. It is absent from `env`, the dev registry, and the +Worker's outbound service. The callback checks the capability before reading a +bounded body; host exceptions never enter Worker responses. The ordinary shared +loopback credential alone cannot authorize issuance. + +`createDevelopmentOidcApiTransport()` uses the existing Cloudflare API request +helper with explicit human OAuth credentials and redirect refusal. Its v4 routes +map to EWC's `/api/scripts/...` and `/api/oidc/...` handlers: + +- `POST /accounts/{account}/workers/scripts/{name}/oidc/development-sessions`, body + `{}`, returns server account, Worker, user, session, policy, default audience and + session expiry. +- `POST /accounts/{account}/workers/oidc/development-sessions/{session}/tokens`, body + `{"audience":"exact audience"}`, returns `token` and `expires_at`. + +Responses use the usual Cloudflare `success`/`result` envelope. Bodies are capped +at 128 KiB before JSON decoding. The adapter validates server identity, session +bounds, exact audience, workload JOSE header, development claims and lifetime. It +never fetches a token-supplied URL. Error responses are cancelled without decoding +or logging their body. A validated session's development subject is displayed in +the host dev server. Expiring sessions are recreated on the next getter. + +### Vitest and offline mocks + +Live issuance is disabled by default, independently of `remoteBindings`. Opt in +explicitly in the Workers pool options passed to `cloudflareTest()`: + +```ts +cloudflareTest({ + wrangler: { configPath: "./wrangler.jsonc" }, + developmentOidc: true, +}); +``` + +An offline test can instead supply a host-side broker through +`miniflare.developmentOidc` (modern Miniflare uses `workers[].dev.developmentOidc`): + +```ts +miniflare: { + developmentOidc: { + key: "offline-test", + create: () => ({ + getToken: async () => fixtureJwt, + dispose() {}, + }), + }, +} +``` + +The mock must return a compact JWT; it need not use Cloudflare or real keys. No +OAuth credentials or host callback are exposed to the test Worker. Explicit local +DO object/namespace subjects are rejected by workerd; default local DO getters +share the linked Worker's development identity. + +### Upstream release dependencies + +The checked-in workerd npm pin predates `oidcTokenService`. Until the matching +runtime is published, use its local build through `MINIFLARE_WORKERD_PATH` for +integration development. `scripts/generate-workerd-config.mjs` generates the +configuration serializer from the pinned schema plus this additive upstream +field; remove the supplement when updating the runtime pin. The native broker +suite runs when that override is provided. This does not enable the feature in +an older runtime. + +Cloudflare must publish the two v4 gateway routes and deploy EWC's development +session handlers, session migration, human OAuth verifier and constrained +Edgeworker signer. The backend draft now wires its OAuth verifier and checks +current Worker access and grant limits with IAM. Key provisioning, account opt-in +and IAM confirmation of the permission/scope mapping remain rollout dependencies +owned outside workers-sdk; mocked API and local native tests cannot prove live +service authorization. diff --git a/packages/remote-bindings/src/auth.test.ts b/packages/remote-bindings/src/auth.test.ts index f4c5f6a0757..072524fefb5 100644 --- a/packages/remote-bindings/src/auth.test.ts +++ b/packages/remote-bindings/src/auth.test.ts @@ -1,6 +1,10 @@ import assert from "node:assert"; import { afterEach, beforeEach, describe, it, vi } from "vitest"; -import { createRemoteBindingsAuth, getRemoteBindingsAuthHook } from "./auth"; +import { + createRemoteBindingsAuth, + getDevelopmentOidcAuthHook, + getRemoteBindingsAuthHook, +} from "./auth"; import type { RemoteBindingsLogger } from "./logger"; import type { CfAccount } from "@cloudflare/workers-utils"; @@ -10,18 +14,22 @@ const mocks = vi.hoisted(() => ({ setProfile: vi.fn(), requireAuth: vi.fn().mockResolvedValue("selected-account-id"), requireApiToken: vi.fn().mockReturnValue({ apiToken: "test-token" }), + getOAuthTokenFromLocalState: vi.fn(), + getOrSelectAccountId: vi.fn().mockResolvedValue("oauth-account"), }, wranglerAuth: { source: "wrangler", setProfile: vi.fn(), requireAuth: vi.fn().mockResolvedValue("selected-account-id"), requireApiToken: vi.fn().mockReturnValue({ apiToken: "test-token" }), + getOAuthTokenFromLocalState: vi.fn(), + getOrSelectAccountId: vi.fn().mockResolvedValue("oauth-account"), }, createCfAuth: vi.fn(), createWranglerAuth: vi.fn(), - cfProfileStore: { resolve: vi.fn().mockReturnValue({ name: "cf-profile" }) }, + cfProfileStore: { resolve: vi.fn().mockReturnValue("cf-profile") }, wranglerProfileStore: { - resolve: vi.fn().mockReturnValue({ name: "wrangler-profile" }), + resolve: vi.fn().mockReturnValue("wrangler-profile"), }, createCfProfileStore: vi.fn(), createWranglerProfileStore: vi.fn(), @@ -44,6 +52,7 @@ vi.mock("@cloudflare/workers-auth/wrangler", () => ({ })); const originalCfAuth = process.env.CLOUDFLARE_CF_AUTH; +const originalDevAuthProfile = process.env.CLOUDFLARE_DEV_AUTH_PROFILE; function createTestLogger(): RemoteBindingsLogger { return { @@ -59,6 +68,7 @@ function createTestLogger(): RemoteBindingsLogger { beforeEach(() => { delete process.env.CLOUDFLARE_CF_AUTH; + delete process.env.CLOUDFLARE_DEV_AUTH_PROFILE; }); afterEach(() => { @@ -68,6 +78,11 @@ afterEach(() => { } else { process.env.CLOUDFLARE_CF_AUTH = originalCfAuth; } + if (originalDevAuthProfile === undefined) { + delete process.env.CLOUDFLARE_DEV_AUTH_PROFILE; + } else { + process.env.CLOUDFLARE_DEV_AUTH_PROFILE = originalDevAuthProfile; + } }); describe("createRemoteBindingsAuth", () => { @@ -91,6 +106,33 @@ describe("createRemoteBindingsAuth", () => { }); describe("getRemoteBindingsAuthHook", () => { + it.for([true, false])( + "forwards the parent's selected profile only for cf auth (%s)", + (useCfAuth, { expect }) => { + if (useCfAuth) { + process.env.CLOUDFLARE_CF_AUTH = "true"; + } + process.env.CLOUDFLARE_DEV_AUTH_PROFILE = "selected-profile"; + const hook = getRemoteBindingsAuthHook( + undefined, + undefined, + "/project", + createTestLogger() + ); + expect(hook).toBeTypeOf("function"); + const profileStore = useCfAuth + ? mocks.cfProfileStore + : mocks.wranglerProfileStore; + expect(profileStore.resolve).toHaveBeenCalledWith({ + profile: useCfAuth ? "selected-profile" : undefined, + cwd: "/project", + }); + expect( + (useCfAuth ? mocks.cfAuth : mocks.wranglerAuth).setProfile + ).toHaveBeenCalledWith(useCfAuth ? "cf-profile" : "wrangler-profile"); + } + ); + it("uses provided auth without resolving a profile", ({ expect }) => { const auth: CfAccount = { accountId: "provided-account-id", @@ -141,3 +183,95 @@ describe("getRemoteBindingsAuthHook", () => { }); }); }); + +describe("getDevelopmentOidcAuthHook", () => { + it.for([true, false])( + "uses the selected human OAuth profile (cf: %s)", + async (useCfAuth, { expect }) => { + if (useCfAuth) { + process.env.CLOUDFLARE_CF_AUTH = "true"; + } + process.env.CLOUDFLARE_DEV_AUTH_PROFILE = "selected-profile"; + const auth = useCfAuth ? mocks.cfAuth : mocks.wranglerAuth; + const hook = getDevelopmentOidcAuthHook( + "configured-account", + "/project", + createTestLogger() + ); + expect(auth.getOAuthTokenFromLocalState).not.toHaveBeenCalled(); + auth.getOAuthTokenFromLocalState.mockResolvedValueOnce( + "human-oauth-token" + ); + expect(await hook()).toEqual({ + accountId: "oauth-account", + apiToken: { apiToken: "human-oauth-token" }, + }); + expect(auth.getOrSelectAccountId).toHaveBeenCalledWith( + { account_id: "configured-account" }, + { credentials: { apiToken: "human-oauth-token" } } + ); + expect(auth.requireApiToken).not.toHaveBeenCalled(); + expect(auth.requireAuth).not.toHaveBeenCalled(); + expect( + (useCfAuth ? mocks.cfProfileStore : mocks.wranglerProfileStore).resolve + ).toHaveBeenCalledWith({ + cwd: "/project", + profile: useCfAuth ? "selected-profile" : undefined, + }); + } + ); + + it("rereads credentials after login, refresh, or logout without restarting", async ({ + expect, + }) => { + const hook = getDevelopmentOidcAuthHook( + undefined, + undefined, + createTestLogger() + ); + mocks.wranglerAuth.getOAuthTokenFromLocalState + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce("signed-in") + .mockResolvedValueOnce("refreshed") + .mockResolvedValueOnce(undefined); + await expect(hook()).rejects.toThrow( + "wrangler auth create wrangler-profile" + ); + expect((await hook()).apiToken).toEqual({ apiToken: "signed-in" }); + expect((await hook()).apiToken).toEqual({ apiToken: "refreshed" }); + await expect(hook()).rejects.toThrow("signed-in developer"); + expect(mocks.wranglerAuth.getOrSelectAccountId).toHaveBeenNthCalledWith( + 1, + {}, + { credentials: { apiToken: "signed-in" } } + ); + expect(mocks.wranglerAuth.getOrSelectAccountId).toHaveBeenNthCalledWith( + 2, + {}, + { credentials: { apiToken: "refreshed" } } + ); + }); + + it.for([true, false])( + "does not fall back to API tokens (cf: %s)", + async (useCfAuth, { expect }) => { + if (useCfAuth) { + process.env.CLOUDFLARE_CF_AUTH = "true"; + } + const auth = useCfAuth ? mocks.cfAuth : mocks.wranglerAuth; + auth.getOAuthTokenFromLocalState.mockResolvedValueOnce(undefined); + const hook = getDevelopmentOidcAuthHook( + undefined, + undefined, + createTestLogger() + ); + await expect(hook()).rejects.toThrow( + useCfAuth + ? "cf auth create cf-profile" + : "wrangler auth create wrangler-profile" + ); + expect(auth.requireApiToken).not.toHaveBeenCalled(); + expect(auth.getOrSelectAccountId).not.toHaveBeenCalled(); + } + ); +}); diff --git a/packages/remote-bindings/src/auth.ts b/packages/remote-bindings/src/auth.ts index 8e0cb855389..5719712ecde 100644 --- a/packages/remote-bindings/src/auth.ts +++ b/packages/remote-bindings/src/auth.ts @@ -75,13 +75,7 @@ export function getRemoteBindingsAuthHook( const { auth: remoteBindingsAuth, useCfAuth } = createRemoteBindingsAuth(logger); - const profileStore = useCfAuth - ? createCfProfileStore({ logger }) - : createWranglerProfileStore({ logger }); - const profile = profileStore.resolve({ - cwd: profileDir ?? process.cwd(), - }); - remoteBindingsAuth.setProfile(profile); + selectDevelopmentProfile(remoteBindingsAuth, useCfAuth, profileDir, logger); return async () => ({ accountId: await remoteBindingsAuth.requireAuth( @@ -90,3 +84,63 @@ export function getRemoteBindingsAuthHook( apiToken: remoteBindingsAuth.requireApiToken(), }); } + +/** + * Resolve human OAuth credentials for development OIDC issuance. This + * deliberately reads the selected profile on every call, including when an + * API token or temporary account is active for other development features. + * Server-side authorization still determines which Worker the user can use. + * + * @param accountId Configured account selection, if known. + * @param profileDir Project directory used for profile selection. + * @param logger Development server logger. + * @returns A lazy hook; creating it never logs in or makes a network request. + */ +export function getDevelopmentOidcAuthHook( + accountId: Config["account_id"] | undefined, + profileDir: string | undefined, + logger: RemoteBindingsLogger +): () => Promise { + const { auth, useCfAuth } = createRemoteBindingsAuth(logger); + const profile = selectDevelopmentProfile(auth, useCfAuth, profileDir, logger); + return async () => { + const token = await auth.getOAuthTokenFromLocalState(); + if (!token) { + const cli = useCfAuth ? "cf" : "wrangler"; + const command = + profile === "default" + ? useCfAuth + ? "cf auth login" + : "wrangler login" + : `${cli} auth create ${profile}`; + throw new UserError( + `Development OIDC requires a signed-in developer. Run \`${command}\` for the selected profile, then retry the getter. If you are already signed in, check your connection so the OAuth token can refresh. API tokens and temporary accounts cannot issue development identities.`, + { telemetryMessage: "development oidc oauth required" } + ); + } + return { + accountId: await auth.getOrSelectAccountId( + accountId ? { account_id: accountId } : {}, + { credentials: { apiToken: token } } + ), + apiToken: { apiToken: token }, + }; + }; +} + +function selectDevelopmentProfile( + auth: ReturnType["auth"], + useCfAuth: boolean, + profileDir: string | undefined, + logger: RemoteBindingsLogger +): string { + const profileStore = useCfAuth + ? createCfProfileStore({ logger }) + : createWranglerProfileStore({ logger }); + const profile = profileStore.resolve({ + profile: useCfAuth ? process.env.CLOUDFLARE_DEV_AUTH_PROFILE : undefined, + cwd: profileDir ?? process.cwd(), + }); + auth.setProfile(profile); + return profile; +} diff --git a/packages/remote-bindings/src/development-oidc-api.test.ts b/packages/remote-bindings/src/development-oidc-api.test.ts new file mode 100644 index 00000000000..50642fbd684 --- /dev/null +++ b/packages/remote-bindings/src/development-oidc-api.test.ts @@ -0,0 +1,404 @@ +import { Buffer } from "node:buffer"; +import { performApiFetchBase } from "@cloudflare/workers-utils"; +import { Response } from "undici"; +import { afterEach, beforeEach, describe, it, vi } from "vitest"; +import { + createDevelopmentOidcApiTransport, + DevelopmentOidcApiError, + validateDevelopmentOidcApiAudience, + validateDevelopmentOidcApiToken, +} from "./development-oidc-api"; +import type { DevelopmentOidcSession } from "./development-oidc"; +import type { Logger } from "@cloudflare/workers-utils"; + +vi.mock("@cloudflare/workers-utils", async (original) => ({ + ...(await original()), + performApiFetchBase: vi.fn(), +})); +const now = 1800000000; +const accountId = "a".repeat(32), + workerId = "b".repeat(32), + userId = "c".repeat(32); +const sessionId = "aaaabbbb-cccc-4ddd-8eee-ffff00001111"; +const auth = { accountId, apiToken: { apiToken: "human-oauth" } }; +const audience = `https://workers.cloudflare.com/accounts/${accountId}`; +const binding = { binding: "IDENTITY", identity_id: "orders" }; +const session: DevelopmentOidcSession = { + binding: binding.binding, + allowedAudiences: [audience], + identity: { + accountId, + workerId, + userId, + developmentSessionId: sessionId, + policy: "workers-oidc-v2", + identityId: "orders", + assignmentId: "local-orders", + policyRevision: "1:1", + }, + defaultAudience: audience, + expiresAt: now + 28800, +}; +const logger = { debug: vi.fn(), info: vi.fn() } as unknown as Logger; +const rawSession = { + binding: binding.binding, + identity_id: "orders", + assignment_id: "local-orders", + policy_revision: "1:1", + allowed_audiences: [audience], + account_id: accountId, + worker_id: workerId, + user_id: userId, + development_session_id: sessionId, + policy: "workers-oidc-v2", + default_audience: audience, + expires_at: session.expiresAt, +}; +const claims = { + iss: `https://oidc.cloudflare.app/accounts/${accountId}`, + sub: "identity:orders", + account_id: accountId, + product: "workers", + jti: "1".repeat(32), + aud: audience, + execution: "local", + worker_id: workerId, + user_id: userId, + development_session_id: sessionId, + iat: now, + exp: now + 300, +}; +function token( + overrides: Record = {}, + header: Record = {} +) { + return [ + Buffer.from( + JSON.stringify({ + alg: "RS256", + typ: "cf-workload+jwt", + kid: "current", + ...header, + }) + ).toString("base64url"), + Buffer.from(JSON.stringify({ ...claims, ...overrides })).toString( + "base64url" + ), + "c2ln", + ].join("."); +} +function response(result: unknown) { + return Response.json({ success: true, result }); +} +const signal = new AbortController().signal; +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(now * 1000); + vi.mocked(performApiFetchBase).mockReset(); +}); +afterEach(() => vi.useRealTimers()); + +describe("development issuance API transport", () => { + it("uses explicit OAuth, existing Worker selection, fixed API routes and redirect refusal", async ({ + expect, + }) => { + vi.mocked(performApiFetchBase) + .mockResolvedValueOnce(response(rawSession)) + .mockResolvedValueOnce( + response({ token: token(), expires_at: now + 300 }) + ); + const transport = createDevelopmentOidcApiTransport({}, logger); + expect( + await transport.createSession(auth, "linked-worker", binding, signal) + ).toEqual(session); + expect( + await transport.issueToken(auth, session, audience, false, signal) + ).toEqual({ token: token(), expiresAt: now + 300 }); + const calls = vi.mocked(performApiFetchBase).mock.calls; + expect(calls[0]?.[1]).toBe( + `/accounts/${accountId}/workers/scripts/linked-worker/oidc/development-sessions` + ); + expect(calls[0]?.[2]).toMatchObject({ + method: "POST", + body: JSON.stringify(binding), + redirect: "error", + }); + expect(calls[1]?.[1]).toBe( + `/accounts/${accountId}/workers/oidc/development-sessions/${sessionId}/tokens` + ); + expect(calls[1]?.[2]?.body).toBe( + JSON.stringify({ audience, fresh: false }) + ); + expect(calls[1]?.[7]).toEqual(auth.apiToken); + expect(calls[1]?.[6]).toBe(signal); + }); + it.for([ + [401, "login_required"], + [403, "access_denied"], + [404, "invalid"], + [409, "session_renewal"], + [429, "rate_limited"], + [500, "unavailable"], + [503, "unavailable"], + [400, "invalid"], + ] as const)( + "classifies status %i without reading its error body", + async ([status, kind], { expect }) => { + const cancel = vi.fn(); + const body = new ReadableStream({ cancel }); + vi.mocked(performApiFetchBase).mockResolvedValue( + new Response(body, { status }) + ); + const pending = createDevelopmentOidcApiTransport( + {}, + logger + ).createSession(auth, "linked-worker", binding, signal); + await expect(pending).rejects.toMatchObject({ + message: expect.stringMatching(/^Development OIDC/), + kind, + }); + expect(cancel).toHaveBeenCalledOnce(); + } + ); + it.for([ + ["60", 60_000], + [new Date((now + 60) * 1000).toUTCString(), 60_000], + ["garbage", undefined], + ["999999999999999999999", undefined], + ["-1", undefined], + ] as const)( + "reads bounded Retry-After metadata (%s)", + async ([value, delay], { expect }) => { + vi.mocked(performApiFetchBase).mockResolvedValue( + new Response(null, { + status: 429, + headers: { "Retry-After": value }, + }) + ); + await expect( + createDevelopmentOidcApiTransport({}, logger).issueToken( + auth, + session, + audience, + false, + signal + ) + ).rejects.toMatchObject({ kind: "rate_limited", retryAfterMs: delay }); + } + ); + it("cancels oversized streaming bodies before JSON decoding", async ({ + expect, + }) => { + const cancel = vi.fn(); + vi.mocked(performApiFetchBase).mockResolvedValue( + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(131073)); + }, + cancel, + }), + { headers: { "Content-Type": "application/json" } } + ) + ); + await expect( + createDevelopmentOidcApiTransport({}, logger).createSession( + auth, + "linked-worker", + binding, + signal + ) + ).rejects.toThrow("invalid development OIDC response"); + expect(cancel).toHaveBeenCalledOnce(); + }); + it("does not include malformed JSON content or network exception secrets in errors", async ({ + expect, + }) => { + const transport = createDevelopmentOidcApiTransport({}, logger); + vi.mocked(performApiFetchBase) + .mockResolvedValueOnce( + new Response("bearer-secret", { + headers: { "Content-Type": "application/json" }, + }) + ) + .mockRejectedValueOnce(new Error("oauth-secret")); + await expect( + transport.createSession(auth, "linked-worker", binding, signal) + ).rejects.toThrow("invalid development OIDC response"); + await expect( + transport.createSession(auth, "linked-worker", binding, signal) + ).rejects.toMatchObject({ + message: expect.stringContaining("could not reach Cloudflare"), + kind: "unavailable", + }); + }); + it("preserves cancellation reasons instead of treating shutdown as a network failure", async ({ + expect, + }) => { + const controller = new AbortController(); + const reason = new Error("Provider disposed"); + controller.abort(reason); + vi.mocked(performApiFetchBase).mockRejectedValueOnce(new Error("aborted")); + await expect( + createDevelopmentOidcApiTransport({}, logger).createSession( + auth, + "linked-worker", + binding, + controller.signal + ) + ).rejects.toBe(reason); + expect(reason).not.toBeInstanceOf(DevelopmentOidcApiError); + }); + it("rejects invalid UTF-8 even in otherwise unused response fields", async ({ + expect, + }) => { + vi.mocked(performApiFetchBase).mockResolvedValue( + new Response( + Buffer.concat([ + Buffer.from( + `{"success":true,"result":${JSON.stringify(rawSession)},"ignored":"` + ), + Buffer.from([255]), + Buffer.from('"}'), + ]), + { headers: { "Content-Type": "application/json" } } + ) + ); + await expect( + createDevelopmentOidcApiTransport({}, logger).createSession( + auth, + "linked-worker", + binding, + signal + ) + ).rejects.toThrow("invalid development OIDC response"); + }); + it.for([ + { account_id: "d".repeat(32) }, + { worker_id: "bad" }, + { user_id: "bad" }, + { development_session_id: "../tokens" }, + { default_audience: "attacker" }, + { policy: "other" }, + { expires_at: now + 299 }, + { expires_at: now + 28801 }, + ])( + "rejects mismatched or malformed session authority", + async (overrides, { expect }) => { + vi.mocked(performApiFetchBase).mockResolvedValue( + response({ ...rawSession, ...overrides }) + ); + await expect( + createDevelopmentOidcApiTransport({}, logger).createSession( + auth, + "linked-worker", + binding, + signal + ) + ).rejects.toThrow("invalid development OIDC response"); + } + ); + it.for(["../worker", "x".repeat(64), "worker\n"])( + "rejects invalid Worker names before requesting", + async (name, { expect }) => { + await expect( + createDevelopmentOidcApiTransport({}, logger).createSession( + auth, + name, + binding, + signal + ) + ).rejects.toThrow(); + expect(performApiFetchBase).not.toHaveBeenCalled(); + } + ); +}); +describe("local token profile", () => { + it("accepts exact development claims and preserves the JWT", ({ expect }) => { + expect( + validateDevelopmentOidcApiToken( + { token: token(), expires_at: now + 300 }, + session, + audience + ) + ).toEqual({ token: token(), expiresAt: now + 300 }); + }); + it.for([120, 240])( + "accepts a server-cached token with the required remaining lifetime (age %i)", + (age, { expect }) => { + const expiresAt = now + 300 - age; + const cached = token({ iat: now - age, exp: expiresAt }); + expect( + validateDevelopmentOidcApiToken( + { token: cached, expires_at: expiresAt }, + session, + audience + ) + ).toEqual({ token: cached, expiresAt }); + } + ); + it.for([ + { iss: "attacker" }, + { sub: "worker:cloud" }, + { aud: [audience] }, + { execution: "deployed" }, + { worker_id: "wrong" }, + { user_id: "wrong" }, + { development_session_id: "wrong" }, + { version_id: "cloud" }, + { preview_id: "cloud" }, + { namespace_id: "object" }, + { object_id: "object" }, + { iat: now - 31 }, + { iat: now + 6 }, + { exp: now + 301 }, + ])( + "rejects token confusion and invalid lifetime", + (overrides, { expect }) => { + expect(() => + validateDevelopmentOidcApiToken( + { token: token(overrides), expires_at: now + 300 }, + session, + audience + ) + ).toThrow(); + } + ); + it.for([ + { alg: "none" }, + { typ: "JWT" }, + { kid: "" }, + { crit: ["unknown"] }, + { b64: false }, + ])("rejects unsupported JOSE profiles", (header, { expect }) => { + expect(() => + validateDevelopmentOidcApiToken( + { token: token({}, header), expires_at: now + 300 }, + session, + audience + ) + ).toThrow(); + }); + it.for([ + "plain", + 'quoted"雪', + "https://api.example/%22exact", + "urn:example:aud", + ])("accepts audiences without normalization", (value, { expect }) => { + expect(() => validateDevelopmentOidcApiAudience(value)).not.toThrow(); + }); + it.for([ + "", + " ", + "x".repeat(2049), + "with spaces:bad", + "1invalid:scheme", + "https://host/\\path", + "https://host/[]", + "\u0085", + "https://example/%xx", + "urn:雪", + ])("rejects invalid StringOrURI audiences", (value, { expect }) => { + expect(() => validateDevelopmentOidcApiAudience(value)).toThrow(); + }); +}); diff --git a/packages/remote-bindings/src/development-oidc-api.ts b/packages/remote-bindings/src/development-oidc-api.ts new file mode 100644 index 00000000000..0ffa417e179 --- /dev/null +++ b/packages/remote-bindings/src/development-oidc-api.ts @@ -0,0 +1,375 @@ +import { Buffer } from "node:buffer"; +import { TextDecoder } from "node:util"; +import { performApiFetchBase } from "@cloudflare/workers-utils"; +import { validOidcAudience } from "@cloudflare/workers-utils/oidc"; +import { version } from "../package.json"; +import { DevelopmentOidcApiError } from "./development-oidc-errors"; +import type { + DevelopmentOidcSession, + DevelopmentOidcTransport, +} from "./development-oidc"; +import type { + CfAccount, + ComplianceConfig, + Logger, +} from "@cloudflare/workers-utils"; +import type { Response } from "undici"; + +export { DevelopmentOidcApiError } from "./development-oidc-errors"; + +const PUBLIC_ID = /^[a-f0-9]{32}$/; +const SESSION_ID = + /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/; +const INVALID = + "Cloudflare returned an invalid development OIDC response. Retry the getter."; + +function object(value: unknown): Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new DevelopmentOidcApiError(INVALID); + } + return value as Record; +} + +/** Enforce the API's StringOrURI rules without fetching or normalizing audiences. */ +export function validateDevelopmentOidcApiAudience(audience: string): void { + if (!validOidcAudience(audience)) { + throw new DevelopmentOidcApiError( + "Development OIDC requires a non-whitespace StringOrURI audience of 1–2048 UTF-8 bytes. Values containing a colon must be valid URIs." + ); + } +} + +function retryAfterMs(response: Response): number | undefined { + const value = response.headers.get("Retry-After")?.trim(); + if (!value) { + return; + } + const delay = /^\d+$/.test(value) + ? Number(value) * 1000 + : Date.parse(value) - Date.now(); + return Number.isSafeInteger(delay) && delay >= 0 ? delay : undefined; +} + +async function readResult(response: Response): Promise { + // Never read or surface API error bodies, which could reflect credentials or bearer tokens. + if (!response.ok) { + await response.body?.cancel().catch(() => {}); + if (response.status === 401) { + throw new DevelopmentOidcApiError( + "Development OIDC login expired. Sign in again with cf auth login or wrangler login, then retry the getter.", + "login_required" + ); + } + if (response.status === 403) { + throw new DevelopmentOidcApiError( + "Development OIDC access denied. Check the selected account, existing Worker, OAuth grant, Worker edit permission, and approved local identity assignment.", + "access_denied" + ); + } + if (response.status === 404) { + throw new DevelopmentOidcApiError( + "Development OIDC Worker or session was not found. Check the linked Worker and retry the getter." + ); + } + if (response.status === 429) { + throw new DevelopmentOidcApiError( + "Development OIDC is rate limited. Retry the getter later.", + "rate_limited", + retryAfterMs(response) + ); + } + if (response.status === 409) { + throw new DevelopmentOidcApiError( + "Development OIDC session needs renewal. Retry the getter to create a new session.", + "session_renewal" + ); + } + throw new DevelopmentOidcApiError( + "Development OIDC issuance is unavailable. Check connectivity and service availability, then retry the getter.", + response.status >= 500 && response.status <= 599 + ? "unavailable" + : "invalid", + retryAfterMs(response) + ); + } + if ( + response.headers + .get("content-type") + ?.split(";")[0] + ?.trim() + .toLowerCase() !== "application/json" + ) { + await response.body?.cancel().catch(() => {}); + throw new DevelopmentOidcApiError(INVALID); + } + const reader = response.body?.getReader(); + if (!reader) { + throw new DevelopmentOidcApiError(INVALID); + } + // One bounded allocation also limits overhead from many tiny stream chunks. + const bytes = Buffer.alloc(131072); + let size = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) { + break; + } + if (value.byteLength > bytes.length - size) { + throw new DevelopmentOidcApiError(INVALID); + } + bytes.set(value, size); + size += value.byteLength; + } + const envelope = object( + JSON.parse( + new TextDecoder("utf-8", { fatal: true }).decode( + bytes.subarray(0, size) + ) + ) + ); + if (envelope.success !== true) { + throw new DevelopmentOidcApiError(INVALID); + } + return envelope.result; + } catch (error) { + if (error instanceof DevelopmentOidcApiError) { + throw error; + } + throw new DevelopmentOidcApiError(INVALID); + } finally { + await reader.cancel().catch(() => {}); + reader.releaseLock(); + } +} + +function decode(segment: string): Record { + const bytes = Buffer.from(segment, "base64url"); + if (bytes.toString("base64url") !== segment) { + throw new DevelopmentOidcApiError(INVALID); + } + try { + return object( + JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) + ); + } catch { + throw new DevelopmentOidcApiError(INVALID); + } +} + +/** Validate the development profile received over the authenticated Cloudflare API connection. */ +export function validateDevelopmentOidcApiToken( + value: unknown, + session: DevelopmentOidcSession, + audience: string +): { token: string; expiresAt: number } { + const result = object(value); + const token = result.token; + if ( + typeof token !== "string" || + token.length > 65536 || + !/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(token) + ) { + throw new DevelopmentOidcApiError(INVALID); + } + const [headerPart, claimsPart, signature] = token.split("."); + if ( + !headerPart || + !claimsPart || + !signature || + Buffer.from(signature, "base64url").toString("base64url") !== signature + ) { + throw new DevelopmentOidcApiError(INVALID); + } + const header = decode(headerPart), + claims = decode(claimsPart); + const { accountId, workerId, userId, developmentSessionId, identityId } = + session.identity; + const now = Date.now() / 1000; + const issued = claims.iat, + expires = claims.exp; + if ( + header.alg !== "RS256" || + header.typ !== "cf-workload+jwt" || + typeof header.kid !== "string" || + !header.kid || + header.kid.length > 128 || + "crit" in header || + "b64" in header || + claims.iss !== `https://oidc.cloudflare.app/accounts/${accountId}` || + claims.sub !== `identity:${identityId}` || + claims.account_id !== accountId || + claims.product !== "workers" || + typeof claims.jti !== "string" || + !/^[a-f0-9]{32}$/.test(claims.jti) || + claims.aud !== audience || + claims.execution !== "local" || + claims.worker_id !== workerId || + claims.user_id !== userId || + claims.development_session_id !== developmentSessionId || + ["version_id", "preview_id", "namespace_id", "object_id"].some( + (key) => key in claims + ) || + typeof issued !== "number" || + !Number.isSafeInteger(issued) || + typeof expires !== "number" || + !Number.isSafeInteger(expires) || + expires !== result.expires_at || + expires - issued !== 300 || + issued > now + 5 || + issued <= 0 || + expires < now + 60 || + expires > now + 300 || + expires > session.expiresAt + ) { + throw new DevelopmentOidcApiError(INVALID); + } + return { token, expiresAt: expires }; +} + +/** Cloudflare v4 development issuance transport; credentials never enter Worker code. */ +export function createDevelopmentOidcApiTransport( + compliance: ComplianceConfig, + logger: Logger +): DevelopmentOidcTransport { + async function request( + auth: CfAccount, + resource: string, + body: object, + signal: AbortSignal + ): Promise { + if ( + auth.accountId.length !== 32 || + !PUBLIC_ID.test(auth.accountId) || + !("apiToken" in auth.apiToken) + ) { + throw new DevelopmentOidcApiError( + "Development OIDC requires human OAuth and a valid account." + ); + } + let response: Response; + try { + response = await performApiFetchBase( + compliance, + resource, + { + method: "POST", + body: JSON.stringify(body), + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + redirect: "error", + }, + `remote-bindings/${version}`, + logger, + undefined, + signal, + auth.apiToken + ); + } catch { + signal.throwIfAborted(); + throw new DevelopmentOidcApiError( + "Development OIDC could not reach Cloudflare. Check connectivity and retry the getter.", + "unavailable" + ); + } + return readResult(response); + } + return { + async createSession(auth, workerName, binding, signal) { + if ( + workerName.trim() !== workerName || + !/^[A-Za-z0-9_-]{1,63}$/.test(workerName) + ) { + throw new DevelopmentOidcApiError( + "Development OIDC requires a valid existing Worker name." + ); + } + const data = object( + await request( + auth, + `/accounts/${encodeURIComponent(auth.accountId)}/workers/scripts/${encodeURIComponent(workerName)}/oidc/development-sessions`, + { binding: binding.binding, identity_id: binding.identity_id }, + signal + ) + ); + const now = Date.now() / 1000; + if ( + data.account_id !== auth.accountId || + typeof data.worker_id !== "string" || + data.worker_id.length !== 32 || + !PUBLIC_ID.test(data.worker_id) || + typeof data.user_id !== "string" || + data.user_id.length !== 32 || + !PUBLIC_ID.test(data.user_id) || + typeof data.development_session_id !== "string" || + data.development_session_id.length !== 36 || + !SESSION_ID.test(data.development_session_id) || + data.policy !== "workers-oidc-v2" || + data.identity_id !== binding.identity_id || + data.binding !== binding.binding || + typeof data.assignment_id !== "string" || + !/^[A-Za-z0-9_-]{1,128}$/.test(data.assignment_id) || + typeof data.policy_revision !== "string" || + !/^[1-9][0-9]*:[1-9][0-9]*$/.test(data.policy_revision) || + typeof data.default_audience !== "string" || + !Array.isArray(data.allowed_audiences) || + data.allowed_audiences.length < 1 || + data.allowed_audiences.length > 64 || + new Set(data.allowed_audiences).size !== + data.allowed_audiences.length || + !data.allowed_audiences.includes(data.default_audience) || + typeof data.expires_at !== "number" || + !Number.isSafeInteger(data.expires_at) || + data.expires_at < now + 300 || + data.expires_at > now + 28800 + ) { + throw new DevelopmentOidcApiError(INVALID); + } + validateDevelopmentOidcApiAudience(data.default_audience); + for (const audience of data.allowed_audiences) { + validateDevelopmentOidcApiAudience(audience); + } + logger.info( + `Development OIDC subject for ${workerName}: identity:${data.identity_id} (local execution)` + ); + return { + identity: { + accountId: auth.accountId, + workerId: data.worker_id, + userId: data.user_id, + developmentSessionId: data.development_session_id, + policy: data.policy, + identityId: data.identity_id, + assignmentId: data.assignment_id, + policyRevision: data.policy_revision, + }, + defaultAudience: data.default_audience, + binding: data.binding, + allowedAudiences: data.allowed_audiences, + expiresAt: data.expires_at, + }; + }, + async issueToken(auth, session, audience, fresh, signal) { + validateDevelopmentOidcApiAudience(audience); + if ( + auth.accountId !== session.identity.accountId || + session.identity.developmentSessionId.length !== 36 || + !SESSION_ID.test(session.identity.developmentSessionId) + ) { + throw new DevelopmentOidcApiError(INVALID); + } + return validateDevelopmentOidcApiToken( + await request( + auth, + `/accounts/${encodeURIComponent(auth.accountId)}/workers/oidc/development-sessions/${session.identity.developmentSessionId}/tokens`, + { audience, fresh }, + signal + ), + session, + audience + ); + }, + }; +} diff --git a/packages/remote-bindings/src/development-oidc-errors.ts b/packages/remote-bindings/src/development-oidc-errors.ts new file mode 100644 index 00000000000..a1f77080acb --- /dev/null +++ b/packages/remote-bindings/src/development-oidc-errors.ts @@ -0,0 +1,16 @@ +/** Sanitized issuance failures, classified without inspecting API error bodies. */ +export class DevelopmentOidcApiError extends Error { + constructor( + message: string, + readonly kind: + | "invalid" + | "login_required" + | "access_denied" + | "session_renewal" + | "rate_limited" + | "unavailable" = "invalid", + readonly retryAfterMs?: number + ) { + super(message); + } +} diff --git a/packages/remote-bindings/src/development-oidc-options.ts b/packages/remote-bindings/src/development-oidc-options.ts new file mode 100644 index 00000000000..56f46aeae1a --- /dev/null +++ b/packages/remote-bindings/src/development-oidc-options.ts @@ -0,0 +1,123 @@ +import path from "node:path"; +import { UserError } from "@cloudflare/workers-utils"; +import { getDevelopmentOidcAuthHook } from "./auth"; +import { DevelopmentOidcProvider } from "./development-oidc"; +import { + createDevelopmentOidcApiTransport, + DevelopmentOidcApiError, +} from "./development-oidc-api"; +import type { RemoteBindingsLogger } from "./logger"; +import type { Config } from "@cloudflare/workers-utils"; +import type { DevelopmentOidcBrokerFactory } from "miniflare"; + +/** + * Build a lazy host-only broker factory for a configured local Worker. + * No login or API request runs until its native getter is called. Miniflare + * retains equal keys across code reloads and disposes removed/changed factories. + * @param config Linked Worker, account, and compatibility configuration. + * @param logger Host development server logger. + * @param profileDir Directory used to select the developer's authentication profile. + * @returns Broker configuration, or undefined when Workers OIDC is not enabled. + */ +export function getDevelopmentOidcOptions( + config: Pick< + Config, + "name" | "account_id" | "compatibility_flags" | "compliance_region" | "oidc" + >, + logger: RemoteBindingsLogger, + profileDir = process.cwd() +): DevelopmentOidcBrokerFactory | undefined { + if ( + !config.compatibility_flags.includes("workers_oidc") || + !config.oidc?.length + ) { + return undefined; + } + const snapshot = { + name: config.name ?? "", + oidc: config.oidc.map((binding) => ({ + ...binding, + allowed_audiences: binding.allowed_audiences?.slice(), + })), + account_id: config.account_id, + compliance_region: config.compliance_region, + }; + const directory = path.resolve(profileDir); + const useCfAuth = "CLOUDFLARE_CF_AUTH" in process.env; + const profile = useCfAuth + ? process.env.CLOUDFLARE_DEV_AUTH_PROFILE + : undefined; + return { + key: JSON.stringify([ + snapshot.name, + snapshot.oidc, + snapshot.account_id, + snapshot.compliance_region, + directory, + useCfAuth, + profile, + ]), + create() { + let readAuth: ReturnType | undefined; + const providers = new Map( + snapshot.oidc.map((binding) => [ + binding.binding, + new DevelopmentOidcProvider( + snapshot.name, + async () => { + try { + readAuth ??= getDevelopmentOidcAuthHook( + snapshot.account_id, + directory, + logger + ); + return await readAuth(); + } catch (error) { + if ( + error instanceof UserError && + error.telemetryMessage === "development oidc oauth required" + ) { + throw new DevelopmentOidcApiError(error.message); + } + throw new DevelopmentOidcApiError( + "Development OIDC could not resolve the signed-in developer and account. Check your login, selected profile, account access, and connectivity." + ); + } + }, + createDevelopmentOidcApiTransport(snapshot, logger), + binding + ), + ]) + ); + return { + async getToken(options) { + try { + const provider = providers.get(options.assignment); + if (!provider) { + throw new Error("OIDC binding is not configured."); + } + return await provider.getToken({ + ...(options.audience === undefined + ? {} + : { audience: options.audience }), + fresh: options.fresh ?? false, + }); + } catch (error) { + // Log only errors created by trusted code; never serialize API bodies or adapters. + logger.error( + error instanceof DevelopmentOidcApiError + ? error.message + : "Development OIDC issuance failed. Check the linked Worker, login, and connectivity, then retry the getter." + ); + throw new Error("Development OIDC issuance failed."); + } + }, + dispose() { + for (const provider of providers.values()) { + provider.dispose(); + } + }, + }; + }, + }; +} diff --git a/packages/remote-bindings/src/development-oidc.test.ts b/packages/remote-bindings/src/development-oidc.test.ts new file mode 100644 index 00000000000..f34bce7bf5e --- /dev/null +++ b/packages/remote-bindings/src/development-oidc.test.ts @@ -0,0 +1,653 @@ +import { inspect } from "node:util"; +import { DevelopmentOidcTokenRequests } from "miniflare"; +import { afterEach, beforeEach, describe, it, vi } from "vitest"; +import { DevelopmentOidcProvider } from "./development-oidc"; +import { DevelopmentOidcApiError } from "./development-oidc-errors"; +import type { + DevelopmentOidcSession, + DevelopmentOidcBindingRequest, +} from "./development-oidc"; +import type { CfAccount } from "@cloudflare/workers-utils"; +import type { DevelopmentOidcToken } from "miniflare"; + +const binding = { binding: "IDENTITY", identity_id: "orders" }; +const session: DevelopmentOidcSession = { + binding: binding.binding, + allowedAudiences: ["https://workers.cloudflare.com/accounts/account"], + identity: { + accountId: "account", + workerId: "worker", + userId: "user", + developmentSessionId: "session", + policy: "policy", + identityId: "orders", + assignmentId: "local-orders", + policyRevision: "1:1", + }, + defaultAudience: "https://workers.cloudflare.com/accounts/account", + expiresAt: 1800000000 + 28800, +}; + +function token(value = "development-token"): DevelopmentOidcToken { + return { token: value, expiresAt: Math.floor(Date.now() / 1000) + 300 }; +} + +function setup(workerName = "linked-worker") { + const getAuth = vi.fn<() => Promise>().mockResolvedValue({ + accountId: "account", + apiToken: { apiToken: "human-oauth" }, + }); + const createSession = vi.fn( + async ( + _auth: CfAccount, + _name: string, + _binding: DevelopmentOidcBindingRequest, + _signal: AbortSignal + ) => session + ); + const issueToken = vi.fn( + async ( + _auth: CfAccount, + _session: DevelopmentOidcSession, + _audience: string, + _fresh: boolean, + _signal: AbortSignal + ) => token() + ); + const provider = new DevelopmentOidcProvider( + workerName, + getAuth, + { + createSession, + issueToken, + }, + binding + ); + return { provider, getAuth, createSession, issueToken }; +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(1_800_000_000_000); +}); +afterEach(() => vi.useRealTimers()); + +describe("development OIDC provider", () => { + it("does nothing until the getter, then shares auth and session while authorizing every issuance", async ({ + expect, + }) => { + const { provider, getAuth, createSession, issueToken } = setup(); + expect(getAuth).not.toHaveBeenCalled(); + expect(createSession).not.toHaveBeenCalled(); + expect(issueToken).not.toHaveBeenCalled(); + expect( + await Promise.all(Array.from({ length: 10 }, () => provider.getToken())) + ).toEqual(Array(10).fill("development-token")); + expect(getAuth).toHaveBeenCalledOnce(); + expect(createSession).toHaveBeenCalledOnce(); + expect(createSession).toHaveBeenCalledWith( + { accountId: "account", apiToken: { apiToken: "human-oauth" } }, + "linked-worker", + binding, + expect.any(AbortSignal) + ); + expect(issueToken).toHaveBeenCalledTimes(10); + expect(issueToken.mock.calls[0]?.[2]).toBe(session.defaultAudience); + await provider.getToken(); + expect(getAuth).toHaveBeenCalledTimes(2); + expect(createSession).toHaveBeenCalledOnce(); + expect(issueToken).toHaveBeenCalledTimes(11); + }); + + it("reads option getters once and ignores inherited authority options", async ({ + expect, + }) => { + const { provider, issueToken } = setup(); + let reads = 0; + await provider.getToken({ + get fresh() { + return ++reads === 1; + }, + }); + expect(reads).toBe(1); + expect(issueToken.mock.calls[0]?.[3]).toBe(true); + await expect( + provider.getToken({ + get fresh() { + ++reads; + return false; + }, + claims: {}, + } as never) + ).rejects.toThrow("only audience and boolean fresh"); + expect(reads).toBe(1); + await provider.getToken( + Object.create({ fresh: true, audience: "inherited" }) + ); + expect(issueToken.mock.calls[1]?.[2]).toBe(session.defaultAudience); + expect(issueToken.mock.calls[1]?.[3]).toBe(false); + provider.dispose(); + }); + + it.for([ + Array(65).fill(session.defaultAudience), + [session.defaultAudience, session.defaultAudience], + [session.defaultAudience, "https://host/\\path"], + ])( + "rejects malformed or oversized session audience policy", + async (allowedAudiences, { expect }) => { + const { provider, createSession, issueToken } = setup(); + createSession.mockResolvedValue({ ...session, allowedAudiences }); + await expect(provider.getToken()).rejects.toThrow(); + expect(issueToken).not.toHaveBeenCalled(); + provider.dispose(); + } + ); + + it("forwards every fresh request without coalescing", async ({ expect }) => { + const { provider, issueToken } = setup(); + let count = 0; + issueToken.mockImplementation(async () => token(`fresh-${++count}`)); + const tokens = await Promise.all( + Array.from({ length: 3 }, () => provider.getToken({ fresh: true })) + ); + expect(new Set(tokens).size).toBe(3); + expect(issueToken.mock.calls.map((call) => call[3])).toEqual([ + true, + true, + true, + ]); + }); + it.for([1, null, "true"])( + "rejects invalid fresh %s before authorization", + async (fresh, { expect }) => { + const { provider, getAuth } = setup(); + await expect(provider.getToken({ fresh } as never)).rejects.toThrow( + "only audience and boolean fresh" + ); + expect(getAuth).not.toHaveBeenCalled(); + } + ); + it("rejects configuration that differs from its approved local audience policy", async ({ + expect, + }) => { + const { getAuth, createSession, issueToken } = setup(); + const provider = new DevelopmentOidcProvider( + "linked-worker", + getAuth, + { createSession, issueToken }, + { ...binding, audience: "different-recipient" } + ); + await expect(provider.getToken()).rejects.toThrow( + "approved audience policy" + ); + expect(issueToken).not.toHaveBeenCalled(); + }); + + it("keeps audiences separate and preserves overrides exactly", async ({ + expect, + }) => { + const { provider, issueToken } = setup(); + await provider.getToken(); + await provider.getToken({ audience: "https://EXAMPLE.com/" }); + await provider.getToken({ audience: "https://example.com/" }); + await provider.getToken({ audience: "https://EXAMPLE.com/" }); + expect(issueToken.mock.calls.map((call) => call[2])).toEqual([ + session.defaultAudience, + "https://EXAMPLE.com/", + "https://example.com/", + "https://EXAMPLE.com/", + ]); + }); + + it("renews on demand without rebuilding the server-side session", async ({ + expect, + }) => { + const { provider, createSession, issueToken } = setup(); + await provider.getToken(); + vi.advanceTimersByTime(301_000); + expect(issueToken).toHaveBeenCalledOnce(); + await provider.getToken(); + expect(createSession).toHaveBeenCalledOnce(); + expect(issueToken).toHaveBeenCalledTimes(2); + }); + + it("invalidates cached tokens after logout, and recovers after login", async ({ + expect, + }) => { + const { provider, getAuth, createSession, issueToken } = setup(); + await provider.getToken(); + getAuth.mockRejectedValueOnce(new Error("Sign in again")); + await expect(provider.getToken()).rejects.toThrow("Sign in again"); + await provider.getToken(); + expect(createSession).toHaveBeenCalledTimes(2); + expect(issueToken).toHaveBeenCalledTimes(2); + }); + + it("invalidates cache and sessions when OAuth credentials change", async ({ + expect, + }) => { + const { provider, getAuth, createSession, issueToken } = setup(); + await provider.getToken(); + getAuth.mockResolvedValue({ + accountId: "account", + apiToken: { apiToken: "new-human-oauth" }, + }); + await provider.getToken(); + expect(createSession).toHaveBeenCalledTimes(2); + expect(issueToken).toHaveBeenCalledTimes(2); + expect(createSession.mock.calls[0]?.[3].aborted).toBe(true); + }); + + it("isolates account changes and refuses mismatched server identities", async ({ + expect, + }) => { + const { provider, getAuth, createSession } = setup(); + await provider.getToken(); + getAuth.mockResolvedValue({ + accountId: "other-account", + apiToken: { apiToken: "human-oauth" }, + }); + await expect(provider.getToken()).rejects.toThrow("different account"); + expect(createSession).toHaveBeenCalledTimes(2); + }); + + it("retries failed session establishment without restarting", async ({ + expect, + }) => { + const { provider, createSession, issueToken } = setup(); + createSession.mockRejectedValueOnce( + new Error("Worker edit permission required") + ); + await expect(provider.getToken()).rejects.toThrow( + "Worker edit permission required" + ); + expect(issueToken).not.toHaveBeenCalled(); + expect(await provider.getToken()).toBe("development-token"); + }); + + it.for(["unavailable", "rate_limited"] as const)( + "retains the session after %s without stale-token fallback", + async (kind, { expect }) => { + const { provider, createSession, issueToken } = setup(); + await provider.getToken(); + vi.advanceTimersByTime(241_000); + const error = new DevelopmentOidcApiError("Retry issuance", kind); + issueToken.mockRejectedValueOnce(error); + await expect(provider.getToken()).rejects.toBe(error); + expect(await provider.getToken()).toBe("development-token"); + expect(createSession).toHaveBeenCalledOnce(); + expect(issueToken).toHaveBeenCalledTimes(3); + expect(createSession.mock.calls[0]?.[3].aborted).toBe(false); + } + ); + + it("retains the session after the issuance deadline, then retries on demand", async ({ + expect, + }) => { + const { provider, createSession, issueToken } = setup(); + await provider.getToken(); + vi.advanceTimersByTime(241_000); + issueToken.mockImplementationOnce(() => new Promise(() => {})); + const rejected = expect(provider.getToken()).rejects.toThrow("timed out"); + await vi.advanceTimersByTimeAsync(30_000); + await rejected; + expect(issueToken.mock.calls[1]?.[4].aborted).toBe(true); + expect(await provider.getToken()).toBe("development-token"); + expect(createSession).toHaveBeenCalledOnce(); + expect(issueToken).toHaveBeenCalledTimes(3); + }); + + it.for([ + new DevelopmentOidcApiError("Sign in again", "login_required"), + new DevelopmentOidcApiError("Access denied", "access_denied"), + new DevelopmentOidcApiError("Renew session", "session_renewal"), + new DevelopmentOidcApiError("Invalid response"), + new Error("Unknown failure"), + ])( + "clears the session and all cached audiences after %s", + async (error, { expect }) => { + const { provider, createSession, issueToken } = setup(); + await provider.getToken(); + issueToken.mockRejectedValueOnce(error); + await expect(provider.getToken({ audience: "other-api" })).rejects.toBe( + error + ); + expect(createSession.mock.calls[0]?.[3].aborted).toBe(true); + expect(await provider.getToken()).toBe("development-token"); + expect(createSession).toHaveBeenCalledTimes(2); + expect(issueToken).toHaveBeenCalledTimes(3); + } + ); + + it("honors Retry-After for every audience without extending backoff", async ({ + expect, + }) => { + const { provider, createSession, issueToken } = setup(); + await provider.getToken(); + const error = new DevelopmentOidcApiError( + "Rate limited", + "rate_limited", + 60_000 + ); + issueToken.mockRejectedValueOnce(error); + await expect(provider.getToken({ audience: "other-api" })).rejects.toBe( + error + ); + await expect(provider.getToken()).rejects.toBe(error); + await vi.advanceTimersByTimeAsync(30_000); + await expect(provider.getToken({ audience: "third-api" })).rejects.toBe( + error + ); + expect(issueToken).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(30_000); + expect(await provider.getToken({ audience: "other-api" })).toBe( + "development-token" + ); + expect(createSession).toHaveBeenCalledOnce(); + expect(issueToken).toHaveBeenCalledTimes(3); + }); + + it("backs off failed session creation and releases its rejected promise", async ({ + expect, + }) => { + const { provider, createSession } = setup(); + const error = new DevelopmentOidcApiError( + "Rate limited", + "rate_limited", + 60_000 + ); + createSession.mockRejectedValueOnce(error); + await expect(provider.getToken()).rejects.toBe(error); + await vi.advanceTimersByTimeAsync(30_000); + await expect(provider.getToken()).rejects.toBe(error); + expect(createSession).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(30_000); + expect(await provider.getToken()).toBe("development-token"); + expect(createSession).toHaveBeenCalledTimes(2); + }); + + it("clears backoff when credentials change", async ({ expect }) => { + const { provider, getAuth, createSession, issueToken } = setup(); + const error = new DevelopmentOidcApiError( + "Rate limited", + "rate_limited", + 60_000 + ); + issueToken.mockRejectedValueOnce(error); + await expect(provider.getToken()).rejects.toBe(error); + getAuth.mockResolvedValue({ + accountId: "account", + apiToken: { apiToken: "new-oauth" }, + }); + expect(await provider.getToken()).toBe("development-token"); + expect(createSession).toHaveBeenCalledTimes(2); + }); + + it("requires a linked Worker and rejects explicit local DO identities", async ({ + expect, + }) => { + const { provider, getAuth } = setup(""); + await expect(provider.getToken()).rejects.toThrow("existing Worker name"); + expect(getAuth).not.toHaveBeenCalled(); + const configured = setup(); + for (const subject of ["object", "namespace"]) { + await expect( + configured.provider.getToken({ subject } as { audience?: string }) + ).rejects.toThrow("only audience and boolean fresh"); + } + expect(configured.getAuth).not.toHaveBeenCalled(); + }); + + it("refuses global-key credentials", async ({ expect }) => { + const { provider, getAuth, createSession } = setup(); + getAuth.mockResolvedValue({ + accountId: "account", + apiToken: { authKey: "key", authEmail: "person@example.com" }, + }); + await expect(provider.getToken()).rejects.toThrow("human OAuth"); + expect(createSession).not.toHaveBeenCalled(); + }); + + it("cancels pending issuance on shutdown and rejects its eventual result", async ({ + expect, + }) => { + const { provider, issueToken } = setup(); + let complete: ((value: DevelopmentOidcToken) => void) | undefined; + issueToken.mockImplementationOnce( + async () => + new Promise((resolve) => { + complete = resolve; + }) + ); + const pending = provider.getToken(); + await vi.waitFor(() => expect(issueToken).toHaveBeenCalledOnce()); + provider.dispose(); + expect(issueToken.mock.calls[0]?.[4].aborted).toBe(true); + complete?.(token()); + await expect(pending).rejects.toThrow("session changed"); + await expect(provider.getToken()).rejects.toThrow("disposed"); + }); +}); + +describe("development OIDC provider security boundaries", () => { + it("snapshots the requested audience before authentication", async ({ + expect, + }) => { + const { provider, issueToken } = setup(); + const options = { audience: "https://requested.example/" }; + const pending = provider.getToken(options); + options.audience = "https://changed.example/"; + await pending; + expect(issueToken.mock.calls[0]?.[2]).toBe("https://requested.example/"); + }); + + it("snapshots credentials and session metadata instead of retaining mutable responses", async ({ + expect, + }) => { + const { provider, getAuth, createSession, issueToken } = setup(); + const auth = { + accountId: "account", + apiToken: { apiToken: "original-oauth" }, + }; + const response = { + ...session, + identity: { ...session.identity }, + defaultAudience: session.defaultAudience, + expiresAt: session.expiresAt, + }; + getAuth.mockResolvedValue(auth); + createSession.mockResolvedValue(response); + await provider.getToken(); + const capturedAuth = createSession.mock.calls[0]?.[0]; + auth.apiToken.apiToken = "mutated-secret"; + expect(capturedAuth).toEqual({ + accountId: "account", + apiToken: { apiToken: "original-oauth" }, + }); + // Restore auth so the second getter keeps the same authorized session. + auth.apiToken.apiToken = "original-oauth"; + response.identity.workerId = "different-worker"; + response.defaultAudience = "https://different.example/"; + await provider.getToken({ audience: "another-audience" }); + expect(createSession).toHaveBeenCalledOnce(); + expect(issueToken.mock.calls[1]?.[1]).toEqual(session); + expect(Object.isFrozen(issueToken.mock.calls[1]?.[1].identity)).toBe(true); + expect(Object.isFrozen(capturedAuth?.apiToken)).toBe(true); + }); + + it("keeps credential-bearing state out of inspection and serialization", async ({ + expect, + }) => { + const { provider } = setup(); + await provider.getToken(); + expect(Object.keys(provider)).toEqual([]); + expect(JSON.stringify(provider)).toBe("{}"); + expect(inspect(provider, { showHidden: true })).not.toContain( + "human-oauth" + ); + }); + + it.for([ + null, + "secret-credential", + { apiToken: "" }, + { apiToken: "human-oauth\n" }, + { apiToken: "é" }, + ])( + "rejects malformed credentials without echoing them (%#)", + async (apiToken, { expect }) => { + const { provider, getAuth, createSession } = setup(); + getAuth.mockResolvedValue({ + accountId: "account", + apiToken, + } as unknown as CfAccount); + await expect(provider.getToken()).rejects.toThrow( + /^Development OIDC requires human OAuth credentials and a valid account\.$/ + ); + expect(createSession).not.toHaveBeenCalled(); + } + ); + + it("does not start authentication after immediate disposal", async ({ + expect, + }) => { + const { provider, getAuth } = setup(); + const pending = provider.getToken(); + const rejected = expect(pending).rejects.toThrow("disposed"); + provider.dispose(); + await rejected; + expect(getAuth).not.toHaveBeenCalled(); + }); + + it.for(["auth", "session"])( + "cancels a stalled %s immediately on shutdown", + async (stage, { expect }) => { + const { provider, getAuth, createSession } = setup(); + if (stage === "auth") { + getAuth.mockImplementationOnce(() => new Promise(() => {})); + } else { + createSession.mockImplementationOnce(() => new Promise(() => {})); + } + const pending = provider.getToken(); + const rejected = expect(pending).rejects.toThrow( + stage === "auth" ? "disposed" : "credentials changed" + ); + await vi.waitFor(() => + expect( + stage === "auth" ? getAuth : createSession + ).toHaveBeenCalledOnce() + ); + provider.dispose(); + await rejected; + } + ); + + it.for(["auth", "session"])( + "times out a stalled %s", + async (stage, { expect }) => { + const { provider, getAuth, createSession } = setup(); + if (stage === "auth") { + getAuth.mockImplementationOnce(() => new Promise(() => {})); + } else { + createSession.mockImplementationOnce(() => new Promise(() => {})); + } + const rejected = expect(provider.getToken()).rejects.toThrow("timed out"); + await vi.waitFor(() => + expect( + stage === "auth" ? getAuth : createSession + ).toHaveBeenCalledOnce() + ); + await vi.advanceTimersByTimeAsync(30_000); + await rejected; + expect(await provider.getToken()).toBe("development-token"); + } + ); + + it("bounds callers waiting for the same authentication request", async ({ + expect, + }) => { + const { provider, getAuth } = setup(); + getAuth.mockImplementationOnce(() => new Promise(() => {})); + const pending = Promise.allSettled( + Array.from({ length: 128 }, () => provider.getToken()) + ); + await expect(provider.getToken()).rejects.toThrow("Too many concurrent"); + expect(getAuth).toHaveBeenCalledOnce(); + provider.dispose(); + expect( + (await pending).every((result) => result.status === "rejected") + ).toBe(true); + }); + + it("rechecks shutdown after awaiting a cache hit", async ({ expect }) => { + const { provider } = setup(); + await provider.getToken(); + const spy = vi + .spyOn(DevelopmentOidcTokenRequests.prototype, "getToken") + .mockImplementationOnce(async () => { + queueMicrotask(() => provider.dispose()); + return "cached-token"; + }); + try { + await expect(provider.getToken()).rejects.toThrow("disposed"); + } finally { + spy.mockRestore(); + } + }); + + it("cancels a pending session when credentials change and ignores its late failure", async ({ + expect, + }) => { + const { provider, getAuth, createSession } = setup(); + let fail: ((reason: Error) => void) | undefined; + createSession.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + fail = reject; + }) + ); + const rejected = expect(provider.getToken()).rejects.toThrow( + "credentials changed" + ); + await vi.waitFor(() => expect(createSession).toHaveBeenCalledOnce()); + getAuth.mockResolvedValue({ + accountId: "account", + apiToken: { apiToken: "new-oauth" }, + }); + expect(await provider.getToken()).toBe("development-token"); + await rejected; + fail?.(new Error("late session failure")); + await provider.getToken(); + expect(createSession).toHaveBeenCalledTimes(2); + }); +}); + +it("rejects line terminators in account metadata before transport", async ({ + expect, +}) => { + const { provider, getAuth, createSession } = setup(); + getAuth.mockResolvedValue({ + accountId: "account\n", + apiToken: { apiToken: "human-oauth" }, + }); + await expect(provider.getToken()).rejects.toThrow("valid account"); + expect(createSession).not.toHaveBeenCalled(); +}); + +it("recreates an expiring server session only when the getter runs", async ({ + expect, +}) => { + const { provider, createSession, issueToken } = setup(); + await provider.getToken(); + await vi.advanceTimersByTimeAsync((28800 - 299) * 1000); + expect(createSession).toHaveBeenCalledOnce(); + createSession.mockResolvedValueOnce({ + ...session, + expiresAt: Math.floor(Date.now() / 1000) + 28800, + }); + await provider.getToken(); + expect(createSession).toHaveBeenCalledTimes(2); + expect(issueToken).toHaveBeenCalledTimes(2); + provider.dispose(); +}); diff --git a/packages/remote-bindings/src/development-oidc.ts b/packages/remote-bindings/src/development-oidc.ts new file mode 100644 index 00000000000..180d6c6c973 --- /dev/null +++ b/packages/remote-bindings/src/development-oidc.ts @@ -0,0 +1,405 @@ +import { + DevelopmentOidcRequestPool, + DevelopmentOidcRequestTimeoutError, + DevelopmentOidcTokenRequests, + snapshotDevelopmentOidcIdentity, + validateDevelopmentOidcAudience, +} from "miniflare"; +import { DevelopmentOidcApiError } from "./development-oidc-errors"; +import type { CfAccount } from "@cloudflare/workers-utils"; +import type { DevelopmentOidcIdentity, DevelopmentOidcToken } from "miniflare"; + +/** Session metadata returned by the authorized Cloudflare development API. */ +export interface DevelopmentOidcBindingRequest { + binding: string; + identity_id: string; + audience?: string; + allowed_audiences?: readonly string[]; +} + +export interface DevelopmentOidcSession { + binding: string; + allowedAudiences: readonly string[]; + identity: DevelopmentOidcIdentity; + defaultAudience: string; + /** Server session expiry in Unix seconds. */ + expiresAt: number; +} + +/** + * Trusted host transport for development issuance. Both operations must use + * human OAuth and server-side Worker authorization. No caller-supplied subject + * or identity may become signing authority. Bind the session to the requested + * Worker, validate the full token profile and audience rules, cap HTTP response + * bodies before decoding them, and return errors that contain no credentials. + * Honor cancellation and release response bodies/connections on every path. + */ +export interface DevelopmentOidcTransport { + createSession( + auth: CfAccount, + workerName: string, + binding: DevelopmentOidcBindingRequest, + signal: AbortSignal + ): Promise; + issueToken( + auth: CfAccount, + session: DevelopmentOidcSession, + audience: string, + fresh: boolean, + signal: AbortSignal + ): Promise; +} + +type OAuthAccount = CfAccount & { apiToken: { apiToken: string } }; + +interface SessionState { + accountId: string; + oauthToken: string; + controller: AbortController; + session: Promise; + monotonicExpiry?: number; + expiresAt?: number; +} + +/** + * Host-side session lifecycle shared by Wrangler, Vite, and opted-in tests. + * Construct one per linked Worker, retain it across code reloads, and dispose + * it when the dev server stops or the configured Worker changes. Credentials + * are read on every getter, so login/logout does not require a restart. + */ +export class DevelopmentOidcProvider { + #workerName: string; + #binding: Readonly; + #getAuth: (signal: AbortSignal) => Promise; + #transport: DevelopmentOidcTransport; + #tokens = new DevelopmentOidcTokenRequests(); + #requests = new DevelopmentOidcRequestPool(); + #shutdown = new AbortController(); + #state?: SessionState; + #authPromise?: Promise; + #retryAfter?: { + accountId: string; + oauthToken: string; + deadline: number; + error: DevelopmentOidcApiError; + }; + #callers = 0; + + constructor( + workerName: string, + getAuth: (signal: AbortSignal) => Promise, + transport: DevelopmentOidcTransport, + binding: DevelopmentOidcBindingRequest + ) { + this.#workerName = workerName; + this.#binding = Object.freeze({ + ...binding, + allowed_audiences: + binding.allowed_audiences && + Object.freeze([...binding.allowed_audiences]), + }); + this.#getAuth = getAuth; + // Snapshot adapter methods; keep them in native private fields with credentials. + this.#transport = Object.freeze({ + createSession: transport.createSession.bind(transport), + issueToken: transport.issueToken.bind(transport), + }); + } + + /** + * Get the linked Worker's development token, including for local DOs. + * + * @param options Audience override, preserved exactly as supplied. + * @returns A server-signed token with at least 60 seconds remaining. + */ + async getToken( + options: { audience?: string; fresh?: boolean } = {} + ): Promise { + this.#checkActive(); + if ( + typeof this.#workerName !== "string" || + this.#workerName.length > 63 || + !this.#workerName.trim() + ) { + throw new Error( + "Development OIDC requires an existing Worker name in your project configuration." + ); + } + if (options === null || typeof options !== "object") { + throw new Error("Development OIDC requires an options object."); + } + const keys = Reflect.ownKeys(options); + if (keys.some((key) => key !== "audience" && key !== "fresh")) { + throw new Error( + "Development OIDC accepts only audience and boolean fresh options." + ); + } + // Snapshot before the first await: callers may retain and mutate options. + const fresh = keys.includes("fresh") ? options.fresh : false; + if (typeof fresh !== "boolean") { + throw new Error( + "Development OIDC accepts only audience and boolean fresh options." + ); + } + const requestedAudience = keys.includes("audience") + ? options.audience + : undefined; + if (requestedAudience !== undefined) { + validateDevelopmentOidcAudience(requestedAudience); + } + if (this.#callers >= 128) { + throw new Error("Too many concurrent development OIDC requests."); + } + this.#callers++; + try { + const auth = await this.#readAuth(); + this.#checkActive(); + let state = this.#state; + if ( + state?.accountId !== auth.accountId || + state.oauthToken !== auth.apiToken.apiToken || + (state.expiresAt !== undefined && + (state.expiresAt * 1000 < Date.now() + 300000 || + (state.monotonicExpiry ?? 0) < performance.now() + 300000)) + ) { + this.#checkRetryAfter(auth); + this.#invalidate(); + const controller = new AbortController(); + state = { + accountId: auth.accountId, + oauthToken: auth.apiToken.apiToken, + controller, + session: this.#requests.run(async (signal) => { + const response = await this.#transport.createSession( + auth, + this.#workerName, + this.#binding, + signal + ); + signal.throwIfAborted(); + const identity = snapshotDevelopmentOidcIdentity( + response?.identity + ); + const defaultAudience = response?.defaultAudience; + validateDevelopmentOidcAudience(defaultAudience); + if ( + identity.accountId !== auth.accountId || + identity.identityId !== this.#binding.identity_id || + response.binding !== this.#binding.binding + ) { + throw new Error( + "Development OIDC returned a session for a different account or binding." + ); + } + if ( + !Array.isArray(response.allowedAudiences) || + response.allowedAudiences.length > 64 || + new Set(response.allowedAudiences).size !== + response.allowedAudiences.length || + !response.allowedAudiences.includes(defaultAudience) + ) { + throw new Error( + "Development OIDC returned an invalid audience policy. Retry the getter." + ); + } + for (const audience of response.allowedAudiences) { + validateDevelopmentOidcAudience(audience); + } + if (this.#binding.audience !== undefined) { + const requested = this.#binding.allowed_audiences ?? [ + this.#binding.audience, + ]; + if ( + defaultAudience !== this.#binding.audience || + requested.length !== response.allowedAudiences.length || + requested.some( + (audience) => !response.allowedAudiences.includes(audience) + ) + ) { + throw new Error( + "Development OIDC binding does not match its approved audience policy." + ); + } + } + const expiresAt = response?.expiresAt; + const remaining = expiresAt * 1000 - Date.now(); + if ( + !Number.isSafeInteger(expiresAt) || + remaining < 300000 || + remaining > 28800000 + ) { + throw new Error( + "Development OIDC returned an invalid session lifetime." + ); + } + if (this.#state?.controller === controller) { + this.#state.expiresAt = expiresAt; + this.#state.monotonicExpiry = performance.now() + remaining; + } + return Object.freeze({ + identity, + defaultAudience, + expiresAt, + binding: response.binding, + allowedAudiences: Object.freeze([...response.allowedAudiences]), + }); + }, controller.signal), + }; + this.#state = state; + } + let established = false; + try { + const session = await state.session; + this.#checkCurrent(state); + established = true; + const audience = requestedAudience ?? session.defaultAudience; + const token = await this.#tokens.getToken( + session.identity, + audience, + (signal) => { + this.#checkRetryAfter(auth); + return this.#transport.issueToken( + auth, + session, + audience, + fresh, + AbortSignal.any([signal, state.controller.signal]) + ); + } + ); + // Recheck the session after the issuance request completes. + this.#checkCurrent(state); + return token; + } catch (error) { + if (this.#state === state) { + const temporary = + error instanceof DevelopmentOidcRequestTimeoutError || + (error instanceof DevelopmentOidcApiError && + (error.kind === "unavailable" || error.kind === "rate_limited")); + // Failed creation must release its rejected promise. Only known + // temporary issuance failures can retain an established session. + if (!established || !temporary) { + this.#invalidate(); + } + if ( + temporary && + error instanceof DevelopmentOidcApiError && + error.retryAfterMs !== undefined && + this.#retryAfter?.error !== error + ) { + this.#retryAfter = { + accountId: auth.accountId, + oauthToken: auth.apiToken.apiToken, + deadline: performance.now() + error.retryAfterMs, + error, + }; + } + } + throw error; + } + } finally { + this.#callers--; + } + } + + #readAuth(): Promise { + if (this.#authPromise) { + return this.#authPromise; + } + const pending = this.#requests.run(async (signal) => { + const response = await this.#getAuth(signal); + signal.throwIfAborted(); + const accountId = response?.accountId; + const credentials = response?.apiToken; + const token = + credentials && + typeof credentials === "object" && + "apiToken" in credentials + ? credentials.apiToken + : undefined; + if ( + typeof accountId !== "string" || + accountId.length === 0 || + accountId.length > 4096 || + /[^A-Za-z0-9_-]/.test(accountId) || + typeof token !== "string" || + token.length === 0 || + token.length > 65_536 || + // RFC 6750 bearer syntax; trim also rejects a final line terminator, + // which JavaScript's `$` anchor otherwise permits. + token.trim() !== token || + !/^[A-Za-z0-9._~+/-]+=*$/.test(token) + ) { + throw new Error( + "Development OIDC requires human OAuth credentials and a valid account." + ); + } + return Object.freeze({ + accountId, + apiToken: Object.freeze({ apiToken: token }), + }); + }, this.#shutdown.signal); + this.#authPromise = pending; + // One handler per auth generation invalidates before a newer read can start. + void pending.then( + () => { + if (this.#authPromise === pending) { + this.#authPromise = undefined; + } + }, + () => { + if (this.#authPromise === pending) { + this.#authPromise = undefined; + this.#invalidate(); + } + } + ); + return pending; + } + + /** Stop the provider and cancel outstanding network requests on shutdown. */ + dispose(): void { + this.#shutdown.abort( + new Error("The development OIDC provider has been disposed.") + ); + this.#invalidate(); + this.#tokens.dispose(); + } + + #invalidate(): void { + const state = this.#state; + this.#state = undefined; + this.#retryAfter = undefined; + this.#tokens.clear(); + state?.controller.abort( + new Error( + "Development OIDC credentials changed during issuance. Retry the getter." + ) + ); + } + + #checkRetryAfter(auth: OAuthAccount): void { + const retry = this.#retryAfter; + if ( + retry?.accountId === auth.accountId && + retry.oauthToken === auth.apiToken.apiToken && + performance.now() < retry.deadline + ) { + throw retry.error; + } + } + + #checkCurrent(state: SessionState): void { + this.#checkActive(); + state.controller.signal.throwIfAborted(); + if (this.#state !== state) { + throw new Error( + "Development OIDC credentials changed during issuance. Retry the getter." + ); + } + } + + #checkActive(): void { + this.#shutdown.signal.throwIfAborted(); + } +} diff --git a/packages/remote-bindings/src/index.ts b/packages/remote-bindings/src/index.ts index df418124afb..22e82ae6505 100644 --- a/packages/remote-bindings/src/index.ts +++ b/packages/remote-bindings/src/index.ts @@ -8,6 +8,14 @@ export type { WorkerConfigObject, } from "./maybe-start-or-update-session"; export { startRemoteProxySession } from "./start-remote-proxy-session"; +export { getDevelopmentOidcAuthHook } from "./auth"; +export { getDevelopmentOidcOptions } from "./development-oidc-options"; +export { createDevelopmentOidcApiTransport } from "./development-oidc-api"; +export { DevelopmentOidcProvider } from "./development-oidc"; +export type { + DevelopmentOidcSession, + DevelopmentOidcTransport, +} from "./development-oidc"; export type { RemoteBindingsLogger } from "./logger"; export type { RemoteProxySession, diff --git a/packages/vite-plugin-cloudflare/src/__tests__/development-oidc.spec.ts b/packages/vite-plugin-cloudflare/src/__tests__/development-oidc.spec.ts new file mode 100644 index 00000000000..d5a21bb30e7 --- /dev/null +++ b/packages/vite-plugin-cloudflare/src/__tests__/development-oidc.spec.ts @@ -0,0 +1,62 @@ +import fs from "node:fs"; +import path from "node:path"; +import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; +import { resolveConfig } from "vite"; +import { test } from "vitest"; +import * as wrangler from "wrangler"; +import { getPreviewMiniflareOptions } from "../miniflare-options"; +import type { PreviewPluginContext } from "../context"; +import type { PreviewResolvedConfig } from "../plugin-config"; +import type * as vite from "vite"; + +runInTempDir(); +test("local Vite preview links each Worker's broker with remote bindings disabled", async ({ + expect, +}) => { + fs.writeFileSync("index.js", "export default {};"); + const configs = ["first-worker", "second-worker"].map((name) => { + const configPath = path.resolve(`${name}.jsonc`); + fs.writeFileSync( + configPath, + JSON.stringify({ + name, + main: "./index.js", + account_id: "a".repeat(32), + compatibility_date: "2026-01-01", + compatibility_flags: ["workers_oidc"], + oidc: [ + { binding: "IDENTITY", identity_id: "orders", audience: "recipient" }, + ], + }) + ); + return wrangler.unstable_readConfig({ config: configPath }); + }); + const resolvedViteConfig = await resolveConfig( + { logLevel: "silent", root: process.cwd() }, + "serve" + ); + const resolvedPluginConfig: PreviewResolvedConfig = { + type: "preview", + cliDisplayName: "Wrangler", + workers: configs.map((config) => ({ source: "legacy", config })), + persistState: false, + inspectorPort: false, + experimental: { headersAndRedirectsDevModeSupport: false }, + remoteBindings: false, + tunnel: { autoStart: false }, + }; + const ctx = { + resolvedPluginConfig, + resolvedViteConfig, + } as PreviewPluginContext; + const { miniflareOptions } = await getPreviewMiniflareOptions(ctx, { + config: resolvedViteConfig, + } as vite.PreviewServer); + const brokers = miniflareOptions.workers.map( + (worker) => worker.dev?.developmentOidc + ); + expect(brokers).toHaveLength(2); + expect(brokers[0]?.key).toContain("first-worker"); + expect(brokers[1]?.key).toContain("second-worker"); + expect(brokers[0]?.key).not.toBe(brokers[1]?.key); +}); diff --git a/packages/vite-plugin-cloudflare/src/miniflare-options.ts b/packages/vite-plugin-cloudflare/src/miniflare-options.ts index 416de77b6c0..3490a0dee4e 100644 --- a/packages/vite-plugin-cloudflare/src/miniflare-options.ts +++ b/packages/vite-plugin-cloudflare/src/miniflare-options.ts @@ -415,6 +415,7 @@ export async function getDevMiniflareOptions( remoteProxySessionData?.session ?.remoteProxyConnectionString, + developmentOidc: true, containerBuildId, } ); @@ -829,6 +830,7 @@ export async function getPreviewMiniflareOptions( remoteProxyConnectionString: remoteProxySessionData?.session?.remoteProxyConnectionString, + developmentOidc: true, containerBuildId, }); diff --git a/packages/vitest-plugin/src/pool/config.ts b/packages/vitest-plugin/src/pool/config.ts index 037c47deab4..630fb40c4e4 100644 --- a/packages/vitest-plugin/src/pool/config.ts +++ b/packages/vitest-plugin/src/pool/config.ts @@ -54,6 +54,8 @@ const WorkersPoolOptionsSchema = z.object({ * with `remote: true` in the wrangler configuration file. */ remoteBindings: z.boolean().default(true), + /** Allow authenticated live development OIDC issuance. Offline by default; independent of remote bindings. */ + developmentOidc: z.boolean().default(false), /** * Enables verbose workerd logging. Defaults to `true`. */ @@ -450,6 +452,7 @@ async function parseCustomPoolOptions( const { workerOptions, externalWorkers, define, main } = wrangler.unstable_getMiniflareWorkerOptions(config, environment, { + developmentOidc: options.developmentOidc, overrides: { assets: options.miniflare.assets, // doesn't work with containers yet so let's just disable it diff --git a/packages/vitest-plugin/test/development-oidc.test.ts b/packages/vitest-plugin/test/development-oidc.test.ts new file mode 100644 index 00000000000..bb3fe8b3a2a --- /dev/null +++ b/packages/vitest-plugin/test/development-oidc.test.ts @@ -0,0 +1,63 @@ +import fs from "node:fs"; +import path from "node:path"; +import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; +import { describe, it } from "vitest"; +import { parseProjectOptions } from "../src/pool/config"; +import type { TestProject } from "vitest/node"; + +describe("live development OIDC opt-in", () => { + runInTempDir(); + it.for([undefined, false, true])( + "is independent of remote bindings and lazy until a getter", + async (developmentOidc, { expect }) => { + const configPath = path.resolve("wrangler.jsonc"); + fs.writeFileSync( + configPath, + JSON.stringify({ + name: "linked-worker", + account_id: "a".repeat(32), + compatibility_date: "2026-01-01", + compatibility_flags: ["workers_oidc"], + oidc: [ + { + binding: "IDENTITY", + identity_id: "orders", + audience: "recipient", + }, + ], + }) + ); + const project = { + config: { root: process.cwd(), environment: "node" }, + vite: { config: { mode: "test" } }, + } as TestProject; + const result = await parseProjectOptions(project, { + wrangler: { configPath }, + remoteBindings: false, + developmentOidc, + }); + expect(result.developmentOidc).toBe(developmentOidc ?? false); + expect(Boolean(result.miniflare?.developmentOidc)).toBe( + developmentOidc ?? false + ); + } + ); + it("preserves explicit offline mocks when live issuance is disabled", async ({ + expect, + }) => { + const create = () => ({ + getToken: async () => "e30.e30.c2ln", + dispose() {}, + }); + const mock = { key: "offline-test", create }; + const project = { + config: { root: process.cwd(), environment: "node" }, + vite: { config: { mode: "test" } }, + } as TestProject; + const result = await parseProjectOptions(project, { + miniflare: { developmentOidc: mock }, + }); + expect(result.developmentOidc).toBe(false); + expect(result.miniflare?.developmentOidc).toEqual(mock); + }); +}); diff --git a/packages/workers-auth/src/core/factory.ts b/packages/workers-auth/src/core/factory.ts index 8bc99333c3f..619d7078652 100644 --- a/packages/workers-auth/src/core/factory.ts +++ b/packages/workers-auth/src/core/factory.ts @@ -119,14 +119,20 @@ export interface CloudflareAuth { /** Returns the active account ID without side effects (config → env → cache). */ getActiveAccountId: (config: { account_id?: string }) => string | undefined; - /** Resolve the account ID to use for API requests (fetching / prompting if needed). */ - getOrSelectAccountId: (config: RequireAuthConfig) => Promise; + /** + * Resolve the account ID (fetching / prompting if needed). When credentials + * are supplied, account discovery uses only that principal, ignoring env auth. + */ + getOrSelectAccountId: ( + config: RequireAuthConfig, + options?: { credentials?: ApiCredentials } + ) => Promise; /** Retrieve the cached account for the active profile, if any. */ getAccountFromCache: () => Account | undefined; /** Fetch the accounts the current login auth can actually use. */ fetchAllAccounts: ( complianceConfig: ComplianceConfig, - options?: { throwOnEmpty?: boolean } + options?: { throwOnEmpty?: boolean; credentials?: ApiCredentials } ) => Promise; /** Ensure the user is logged in and resolve a valid account ID. */ @@ -423,17 +429,30 @@ export function createCloudflareAuth( async function fetchAllAccounts( complianceConfig: ComplianceConfig, - options: { throwOnEmpty?: boolean } = {} + options: { throwOnEmpty?: boolean; credentials?: ApiCredentials } = {} ): Promise { const { throwOnEmpty = true } = options; - const loginResult = await loginOrRefreshIfRequired(complianceConfig); - if (!loginResult.loggedIn) { - throw new UserError( - `Not logged in. ${NOT_LOGGED_IN_ERROR_BODIES[loginResult.reason]}${NOT_LOGGED_IN_WHOAMI_TIP}`, - { telemetryMessage: "cfetch auth login required" } - ); + let credentials = + options.credentials === undefined + ? undefined + : Object.freeze( + "apiToken" in options.credentials + ? { apiToken: options.credentials.apiToken } + : { + authKey: options.credentials.authKey, + authEmail: options.credentials.authEmail, + } + ); + if (credentials === undefined) { + const loginResult = await loginOrRefreshIfRequired(complianceConfig); + if (!loginResult.loggedIn) { + throw new UserError( + `Not logged in. ${NOT_LOGGED_IN_ERROR_BODIES[loginResult.reason]}${NOT_LOGGED_IN_WHOAMI_TIP}`, + { telemetryMessage: "cfetch auth login required" } + ); + } + credentials = requireApiToken(); } - const credentials = requireApiToken(); const [accountsRes, membershipsRes] = await Promise.allSettled([ fetchAccountsPaged(complianceConfig, `/accounts`, credentials), @@ -522,7 +541,8 @@ Alternatively, try running \`${descriptor.commands.login}\` to re-authenticate.` } async function getOrSelectAccountId( - config: RequireAuthConfig + config: RequireAuthConfig, + options: { credentials?: ApiCredentials } = {} ): Promise { // TODO: v5 we should prioritise the env var instead of the config value here, // for consistency with other env vars. @@ -531,7 +551,7 @@ Alternatively, try running \`${descriptor.commands.login}\` to re-authenticate.` return activeAccountId; } - const accounts = await fetchAllAccounts(config); + const accounts = await fetchAllAccounts(config, options); if (accounts.length === 1) { saveAccountToCache({ id: accounts[0].id, name: accounts[0].name }); return accounts[0].id; diff --git a/packages/workers-auth/tests/core/factory.test.ts b/packages/workers-auth/tests/core/factory.test.ts index 2321a9d3c96..7eefd2653cc 100644 --- a/packages/workers-auth/tests/core/factory.test.ts +++ b/packages/workers-auth/tests/core/factory.test.ts @@ -102,6 +102,31 @@ describe("per-CLI login defaults", () => { expect(fetchInternalBase).toHaveBeenCalledTimes(2); }); + it("uses explicit OAuth credentials for all account discovery requests", async ({ + expect, + }) => { + const account = { id: "oauth-account", name: "OAuth account" }; + const credentials = { apiToken: "selected-profile-oauth" }; + fetchInternalBase.mockImplementation(async (_config, resource) => ({ + response: { + success: true, + result: resource === "/accounts" ? [account] : [{ account }], + result_info: { page: 1, total_pages: 1 }, + }, + status: 200, + })); + const auth = createCloudflareAuth(CF_CLI, createTestContext()); + await expect( + auth.getOrSelectAccountId(COMPLIANCE_CONFIG, { credentials }) + ).resolves.toBe(account.id); + expect(flow.requireApiToken).not.toHaveBeenCalled(); + expect(flow.loginOrRefreshIfRequired).not.toHaveBeenCalled(); + expect(fetchInternalBase).toHaveBeenCalledTimes(2); + for (const call of fetchInternalBase.mock.calls) { + expect(call[7]).toEqual(credentials); + } + }); + it("honours an explicit device-flow opt-out", async ({ expect }) => { const auth = createCloudflareAuth(CF_CLI, createTestContext()); diff --git a/packages/workers-utils/package.json b/packages/workers-utils/package.json index bc651b1951f..bcfe2194fb2 100644 --- a/packages/workers-utils/package.json +++ b/packages/workers-utils/package.json @@ -62,6 +62,10 @@ "./zod-format": { "import": "./dist/zod-format.mjs", "types": "./dist/zod-format.d.mts" + }, + "./oidc": { + "import": "./dist/oidc.mjs", + "types": "./dist/oidc.d.mts" } }, "scripts": { diff --git a/packages/workers-utils/src/binding-utils.ts b/packages/workers-utils/src/binding-utils.ts index 5513138a305..c6693f841a0 100644 --- a/packages/workers-utils/src/binding-utils.ts +++ b/packages/workers-utils/src/binding-utils.ts @@ -322,6 +322,12 @@ export function convertConfigToBindings( } break; } + case "oidc": { + for (const { binding, ...x } of info) { + output[binding] = { type: "oidc", ...x }; + } + break; + } case "ratelimits": { for (const { name, ...x } of info) { output[name] = { type: "ratelimit", ...x }; diff --git a/packages/workers-utils/src/config/binding-local-support.ts b/packages/workers-utils/src/config/binding-local-support.ts index 724bb7f2619..d79d8c2ce71 100644 --- a/packages/workers-utils/src/config/binding-local-support.ts +++ b/packages/workers-utils/src/config/binding-local-support.ts @@ -42,6 +42,7 @@ const BINDING_LOCAL_SUPPORT: Record< fetcher: "local-only", analytics_engine: "local-only", secrets_store_secret: "local-only", + oidc: "local-only", ratelimit: "local-only", worker_loader: "local-only", diff --git a/packages/workers-utils/src/config/config.ts b/packages/workers-utils/src/config/config.ts index 62d52cd8b71..ed499122c32 100644 --- a/packages/workers-utils/src/config/config.ts +++ b/packages/workers-utils/src/config/config.ts @@ -392,6 +392,7 @@ export const defaultWranglerConfig: Config = { version_metadata: undefined, unsafe_hello_world: [], flagship: [], + oidc: [], ratelimits: [], worker_loaders: [], diff --git a/packages/workers-utils/src/config/environment.ts b/packages/workers-utils/src/config/environment.ts index fcb65f7569f..8c88495c686 100644 --- a/packages/workers-utils/src/config/environment.ts +++ b/packages/workers-utils/src/config/environment.ts @@ -1048,6 +1048,17 @@ type ConnectHandlerConfig = TcpConnectHandlerConfig | UdpConnectHandlerConfig; * for each named environment. */ export interface EnvironmentNonInheritable { + /** Experimental workload identity bindings. Configuration requests an existing administrator-approved assignment. + * @default [] + * @nonInheritable + */ + oidc: { + binding: string; + identity_id: string; + audience: string; + allowed_audiences?: string[]; + }[]; + /** * A map of values to substitute when deploying your Worker. * diff --git a/packages/workers-utils/src/config/oidc.ts b/packages/workers-utils/src/config/oidc.ts new file mode 100644 index 00000000000..07a8807c50e --- /dev/null +++ b/packages/workers-utils/src/config/oidc.ts @@ -0,0 +1,195 @@ +/** Validate an exact StringOrURI audience without fetching or normalizing it. */ +export function validOidcAudience(value: unknown): value is string { + if ( + typeof value !== "string" || + value.length === 0 || + value.length > 2048 || + !value.isWellFormed() || + /^[\s\u0085]*$/.test(value) || + new TextEncoder().encode(value).length > 2048 + ) { + return false; + } + const colon = value.indexOf(":"); + if (colon < 0) { + return true; + } + if (/[^\x21-\x7e]/.test(value)) { + return false; + } + if (!/^[A-Za-z][A-Za-z0-9+.-]*$/.test(value.slice(0, colon))) { + return false; + } + // RFC 3986 components match the EWC and native signer grammar. WHATWG URL + // parsing repairs invalid spellings and rejects valid URIs such as IPvFuture. + let rest = value.slice(colon + 1); + const hash = rest.indexOf("#"); + if (hash >= 0) { + if (!uriComponent(rest.slice(hash + 1), ":@/?")) { + return false; + } + rest = rest.slice(0, hash); + } + const question = rest.indexOf("?"); + if (question >= 0) { + if (!uriComponent(rest.slice(question + 1), ":@/?")) { + return false; + } + rest = rest.slice(0, question); + } + if (rest.startsWith("//")) { + rest = rest.slice(2); + const slash = rest.indexOf("/"); + const end = slash < 0 ? rest.length : slash; + if (!uriAuthority(rest.slice(0, end))) { + return false; + } + rest = rest.slice(end); + } + return uriComponent(rest, ":@/"); +} + +function uriComponent(value: string, extra: string): boolean { + for (let i = 0; i < value.length; i++) { + const c = value[i]; + if (c === "%") { + if (!/^[A-Fa-f0-9]{2}$/.test(value.slice(i + 1, i + 3))) { + return false; + } + i += 2; + } else if ( + !/[A-Za-z0-9]/.test(c) && + !"-._~!$&'()*+,;=".includes(c) && + !extra.includes(c) + ) { + return false; + } + } + return true; +} + +function uriAuthority(value: string): boolean { + const at = value.indexOf("@"); + if (at >= 0) { + if (!uriComponent(value.slice(0, at), ":")) { + return false; + } + value = value.slice(at + 1); + } + if (value.startsWith("[")) { + const end = value.indexOf("]"); + if (end < 0 || !ipLiteral(value.slice(1, end))) { + return false; + } + value = value.slice(end + 1); + if (value === "") { + return true; + } + if (!value.startsWith(":")) { + return false; + } + } else { + const colon = value.indexOf(":"); + if (colon < 0) { + return uriComponent(value, ""); + } + if (!uriComponent(value.slice(0, colon), "")) { + return false; + } + value = value.slice(colon); + } + return !/[^0-9]/.test(value.slice(1)); +} + +function ipLiteral(value: string): boolean { + if (/^[vV]/.test(value)) { + const dot = value.indexOf("."); + return ( + dot > 1 && + dot + 1 < value.length && + /^[A-Fa-f0-9]+$/.test(value.slice(1, dot)) && + !value.includes("%") && + uriComponent(value.slice(dot + 1), ":") + ); + } + let groups = 0; + let compressed = value.startsWith("::"); + if (compressed) { + value = value.slice(2); + } + while (value.length > 0) { + const colon = value.indexOf(":"); + const end = colon < 0 ? value.length : colon; + const group = value.slice(0, end); + if (group.includes(".")) { + const octets = group.split("."); + return ( + end === value.length && + (compressed ? groups < 6 : groups === 6) && + octets.length === 4 && + octets.every( + (octet) => /^(0|[1-9][0-9]{0,2})$/.test(octet) && Number(octet) <= 255 + ) + ); + } + if (!/^[A-Fa-f0-9]{1,4}$/.test(group) || ++groups > 8) { + return false; + } + if (end === value.length) { + break; + } + value = value.slice(end + 1); + if (value.startsWith(":")) { + if (compressed) { + return false; + } + compressed = true; + value = value.slice(1); + } else if (value === "") { + return false; + } + } + return compressed ? groups < 8 : groups === 8; +} + +/** Validate a binding request. The API independently resolves its approved assignment. */ +export function isOidcBinding(value: unknown): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const v = value as Record; + if ( + Reflect.ownKeys(v).some( + (k) => + !["binding", "identity_id", "audience", "allowed_audiences"].includes( + String(k) + ) + ) + ) { + return false; + } + if ( + ![v.binding, v.identity_id].every( + (x) => + typeof x === "string" && + x.length > 0 && + x.length <= 128 && + !/[^A-Za-z0-9_-]/.test(x) + ) + ) { + return false; + } + if (!validOidcAudience(v.audience)) { + return false; + } + const a = v.allowed_audiences; + return ( + a === undefined || + (Array.isArray(a) && + a.length > 0 && + a.length <= 64 && + a.every(validOidcAudience) && + new Set(a).size === a.length && + a.includes(v.audience)) + ); +} diff --git a/packages/workers-utils/src/config/validation.ts b/packages/workers-utils/src/config/validation.ts index e77bfe9b2b5..70d469912e1 100644 --- a/packages/workers-utils/src/config/validation.ts +++ b/packages/workers-utils/src/config/validation.ts @@ -18,6 +18,7 @@ import { } from "./durable-object-exports"; import { ARTIFACTS_EVENT_TYPES } from "./environment"; import { partitionExports } from "./exports"; +import { isOidcBinding } from "./oidc"; import { all, appendEnvName, @@ -120,6 +121,7 @@ export type ConfigBindingFieldName = | "k2" | "secrets_store_secrets" | "artifacts" + | "oidc" | "ratelimits" | "assets" | "unsafe_hello_world" @@ -162,6 +164,7 @@ export const friendlyBindingNames: Record = { k2: "K2 Stream", secrets_store_secrets: "Secrets Store Secret", artifacts: "Artifacts", + oidc: "OIDC Identity", ratelimits: "Rate Limit", assets: "Assets", unsafe_hello_world: "Hello World", @@ -213,6 +216,7 @@ const bindingTypeFriendlyNames: Record = { logfwdr: "logfwdr", unsafe_hello_world: "Hello World", flagship: "Flagship", + oidc: "OIDC Identity", ratelimit: "Rate Limit", worker_loader: "Worker Loader", vpc_service: "VPC Service", @@ -2089,6 +2093,16 @@ function normalizeAndValidateEnvironment( validateBindingArray(envName, validateWorkerLoaderBinding), [] ), + oidc: notInheritable( + diagnostics, + topLevelEnv, + rawConfig, + rawEnv, + envName, + "oidc", + validateBindingArray(envName, validateOidcBinding), + [] + ), ratelimits: notInheritable( diagnostics, topLevelEnv, @@ -3638,6 +3652,7 @@ const validateUnsafeBinding: ValidatorFn = (diagnostics, field, value) => { } if (isRequiredProperty(value, "type", "string")) { const safeBindings = [ + "oidc", "plain_text", "secret_text", "json", @@ -6422,6 +6437,16 @@ const validateWorkerLoaderBinding: ValidatorFn = ( return isValid; }; +const validateOidcBinding: ValidatorFn = (diagnostics, field, value) => { + if (!isOidcBinding(value)) { + diagnostics.errors.push( + `"${field}" requires binding, identity_id, a valid audience, and optionally a nonempty allowed_audiences list containing the default. OIDC configuration cannot grant identity access or select a subject.` + ); + return false; + } + return true; +}; + const validateRateLimitBinding: ValidatorFn = (diagnostics, field, value) => { if (typeof value !== "object" || value === null) { diagnostics.errors.push( @@ -6587,6 +6612,7 @@ const validatePreviewsConfig = "unsafe_hello_world", "flagship", "worker_loaders", + "oidc", "ratelimits", "vpc_services", "version_metadata", @@ -6886,6 +6912,14 @@ const validatePreviewsConfig = undefined ) && isValid; + isValid = + validateBindingArray(envName, validateOidcBinding)( + diagnostics, + `${field}.oidc`, + previews.oidc, + undefined + ) && isValid; + isValid = validateBindingArray(envName, validateRateLimitBinding)( diagnostics, diff --git a/packages/workers-utils/src/index.ts b/packages/workers-utils/src/index.ts index fb253573436..2ef5173f4f0 100644 --- a/packages/workers-utils/src/index.ts +++ b/packages/workers-utils/src/index.ts @@ -1,3 +1,4 @@ +export { validOidcAudience, isOidcBinding } from "./config/oidc"; export type { RawConfig, Config, diff --git a/packages/workers-utils/src/map-worker-metadata-bindings.ts b/packages/workers-utils/src/map-worker-metadata-bindings.ts index 50fcd52b59c..564c3cca554 100644 --- a/packages/workers-utils/src/map-worker-metadata-bindings.ts +++ b/packages/workers-utils/src/map-worker-metadata-bindings.ts @@ -388,6 +388,11 @@ export function mapWorkerMetadataBindings( ]; } break; + case "oidc": { + const { name, type: _type, ...request } = binding; + (configObj.oidc ??= []).push({ binding: name, ...request }); + break; + } case "ratelimit": { configObj.ratelimits = [ diff --git a/packages/workers-utils/src/oidc.ts b/packages/workers-utils/src/oidc.ts new file mode 100644 index 00000000000..d0e92d557f2 --- /dev/null +++ b/packages/workers-utils/src/oidc.ts @@ -0,0 +1 @@ +export { validOidcAudience, isOidcBinding } from "./config/oidc"; diff --git a/packages/workers-utils/src/print-bindings.ts b/packages/workers-utils/src/print-bindings.ts index f07de4ce3bb..9e1f02a0b1e 100644 --- a/packages/workers-utils/src/print-bindings.ts +++ b/packages/workers-utils/src/print-bindings.ts @@ -692,6 +692,17 @@ export function printBindings( ); } + for (const { binding, identity_id } of extractBindingsOfType( + "oidc", + bindings + )) { + output.push({ + name: binding, + type: "OIDC Identity", + value: identity_id, + mode: getMode({ isSimulatedLocally: false }), + }); + } if (ratelimits.length > 0) { output.push( ...ratelimits.map(({ name, simple }) => ({ diff --git a/packages/workers-utils/src/types.ts b/packages/workers-utils/src/types.ts index 6aec886a7b2..d59ae41467f 100644 --- a/packages/workers-utils/src/types.ts +++ b/packages/workers-utils/src/types.ts @@ -69,6 +69,13 @@ export type Json = | { [id: string]: Json }; export type WorkerMetadataBinding = + | { + type: "oidc"; + name: string; + identity_id: string; + audience: string; + allowed_audiences?: string[]; + } // If you add any new binding types here, also add it to safeBindings // under validateUnsafeBinding in config/validation.ts @@ -465,6 +472,12 @@ type DistributiveOmit = T extends unknown type BindingOmit = DistributiveOmit; type NameOmit = DistributiveOmit; export type Binding = + | { + type: "oidc"; + identity_id: string; + audience: string; + allowed_audiences?: string[]; + } | { type: "plain_text"; value: string; diff --git a/packages/workers-utils/tests/config/oidc.test.ts b/packages/workers-utils/tests/config/oidc.test.ts new file mode 100644 index 00000000000..953896ae591 --- /dev/null +++ b/packages/workers-utils/tests/config/oidc.test.ts @@ -0,0 +1,88 @@ +import { it } from "vitest"; +import { isOidcBinding, validOidcAudience } from "../../src/config/oidc"; + +// The same cases exercise the Go policy and native signer grammar. +const accepted = [ + "recipient", + "two words", + " \u96ea \ud83d\udd10 ", + "a\nb", + "urn:example:recipient", + "mailto:User@Example.COM", + "custom:", + "custom:/path", + "https://EXAMPLE.com:443/a/../b?x=%2f#part", + "file:///tmp/key", + "custom://user:password@host:123/path", + "custom://host:", + "custom://reg!$&'()*+,;=name/", + "https://[::1]/", + "https://[2001:db8::1]:443/", + "custom://[1:2:3:4:5:6:7:8]/", + "custom://[::]/", + "custom://[::ffff:192.0.2.1]/", + "custom://[1:2:3:4:5:6:192.0.2.1]/", + "custom://[v1.future:address]/", + "urn:example:%E9%9B%AA", +]; +const rejected = [ + "", + " \t\r\n", + ":missing-scheme", + "1scheme:recipient", + "bad scheme:recipient", + "https://host/a b", + 'https://host/"quote', + "urn:example:\u96ea", + "urn:example:%", + "urn:example:%0", + "urn:example:%zz", + "https://host/?a=%0g", + "https://host/#a#b", + "https://host/[]", + "https://user@@host/", + "https://host:abc/", + "https://host:80:90/", + "https://[::1", + "https://[garbage]/", + "https://[1:2:3]/", + "https://[1:2:3:4:5:6:7:8:9]/", + "https://[1:2:3:4:5:6:7:8::]/", + "https://[1::2::3]/", + "https://[1:2:3:4:5:6:7:]/", + "https://[::ffff:256.0.0.1]/", + "https://[::ffff:192.00.2.1]/", + "https://[::ffff:192.0.2]/", + "https://[v1.]/", + "https://[vg.host]/", + "https://[::1]suffix/", + "https://host/\\path", + "\u0085", + "\ud800", + "https://host/\\path", + "recipient:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", +]; +it.for(accepted)("preserves valid StringOrURI %j", (audience, { expect }) => { + expect(validOidcAudience(audience)).toBe(true); +}); +it.for(rejected)("rejects malformed StringOrURI %j", (audience, { expect }) => { + expect(validOidcAudience(audience)).toBe(false); +}); +it("bounds UTF-8 and rejects ambiguous binding fields", ({ expect }) => { + expect(validOidcAudience("a".repeat(2048))).toBe(true); + expect(validOidcAudience("a".repeat(2049))).toBe(false); + expect(validOidcAudience("雪".repeat(683))).toBe(false); + const binding = { + binding: "IDENTITY", + identity_id: "orders", + audience: "recipient", + }; + expect(isOidcBinding(binding)).toBe(true); + expect(isOidcBinding({ ...binding, identity_id: "orders\n" })).toBe(false); + expect(isOidcBinding({ ...binding, [Symbol("claims")]: {} })).toBe(false); + expect( + isOidcBinding( + Object.defineProperty({ ...binding }, "claims", { value: {} }) + ) + ).toBe(false); +}); diff --git a/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts b/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts index ad00d5d41ad..13f40938e68 100644 --- a/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts +++ b/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts @@ -33,6 +33,7 @@ describe("normalizeAndValidateConfig()", () => { }, compatibility_date: undefined, compatibility_flags: [], + oidc: [], configPath: undefined, d1_databases: [], vectorize: [], diff --git a/packages/workers-utils/tsup.config.ts b/packages/workers-utils/tsup.config.ts index 3e79a489832..bdfa00f77ba 100644 --- a/packages/workers-utils/tsup.config.ts +++ b/packages/workers-utils/tsup.config.ts @@ -36,6 +36,7 @@ export default defineConfig(() => [ "src/global-wrangler-config-path.ts", "src/local-env.ts", "src/zod-format.ts", + "src/oidc.ts", ], platform: "node", // Provide require for bundled CommonJS dependencies. The __filename diff --git a/packages/wrangler/src/__tests__/unstable-get-miniflare-worker-options.test.ts b/packages/wrangler/src/__tests__/unstable-get-miniflare-worker-options.test.ts index a98fc670927..56dfeb89446 100644 --- a/packages/wrangler/src/__tests__/unstable-get-miniflare-worker-options.test.ts +++ b/packages/wrangler/src/__tests__/unstable-get-miniflare-worker-options.test.ts @@ -553,3 +553,40 @@ describe("unstable_getMiniflareWorkerOptions", () => { }); }); }); + +describe("development OIDC integration", () => { + runInTempDir(); + it("keeps programmatic/offline consumers opt-in and requires the runtime flag", ({ + expect, + }) => { + writeWranglerConfig( + { + name: "linked-worker", + account_id: "a".repeat(32), + compatibility_date: "2026-01-01", + compatibility_flags: ["workers_oidc"], + }, + "./wrangler.json" + ); + expect( + unstable_getMiniflareWorkerOptions("./wrangler.json").workerOptions + .developmentOidc + ).toBeUndefined(); + const configured = asRecord( + unstable_getMiniflareWorkerOptions("./wrangler.json", undefined, { + developmentOidc: true, + }).workerOptions.developmentOidc + ); + expect(configured?.key).toContain("linked-worker"); + expect(configured?.create).toBeTypeOf("function"); + writeWranglerConfig( + { name: "linked-worker", compatibility_date: "2026-01-01" }, + "./wrangler.json" + ); + expect( + unstable_getMiniflareWorkerOptions("./wrangler.json", undefined, { + developmentOidc: true, + }).workerOptions.developmentOidc + ).toBeUndefined(); + }); +}); diff --git a/packages/wrangler/src/api/integrations/platform/index.ts b/packages/wrangler/src/api/integrations/platform/index.ts index ab69aac00ce..dcd8e9aa3df 100644 --- a/packages/wrangler/src/api/integrations/platform/index.ts +++ b/packages/wrangler/src/api/integrations/platform/index.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { createContainerDevPlan } from "@cloudflare/containers-shared"; +import { getDevelopmentOidcOptions } from "@cloudflare/remote-bindings"; import { DEFAULT_COMPAT_DATE, extractBindingsOfType, @@ -404,6 +405,8 @@ export function unstable_getMiniflareWorkerOptions( configPath: string, env?: string, options?: { + /** Enable authenticated local OIDC. Disabled for programmatic/offline consumers by default. */ + developmentOidc?: boolean; remoteProxyConnectionString?: RemoteProxyConnectionString; overrides?: { assets?: Partial; @@ -416,6 +419,8 @@ export function unstable_getMiniflareWorkerOptions( config: Config, env?: string, options?: { + /** Enable authenticated local OIDC. Disabled for programmatic/offline consumers by default. */ + developmentOidc?: boolean; remoteProxyConnectionString?: RemoteProxyConnectionString; overrides?: { assets?: Partial; @@ -429,6 +434,8 @@ export function unstable_getMiniflareWorkerOptions( env?: string, options?: { envFiles?: string[]; + /** Enable authenticated local OIDC. Disabled for programmatic/offline consumers by default. */ + developmentOidc?: boolean; remoteProxyConnectionString?: RemoteProxyConnectionString; overrides?: { assets?: Partial; @@ -520,6 +527,9 @@ export function unstable_getMiniflareWorkerOptions( modulesRules, zone: getZoneFromConfig(config), access: config.access?.dev, + developmentOidc: options?.developmentOidc + ? getDevelopmentOidcOptions(config, logger, projectRoot) + : undefined, connectHandlers: config.connect.map((handler) => ({ protocol: handler.protocol, port: handler.port, diff --git a/packages/wrangler/src/api/startDevWorker/ConfigController.ts b/packages/wrangler/src/api/startDevWorker/ConfigController.ts index 7508643763e..1b2abda1c08 100644 --- a/packages/wrangler/src/api/startDevWorker/ConfigController.ts +++ b/packages/wrangler/src/api/startDevWorker/ConfigController.ts @@ -4,6 +4,7 @@ import { createContainerDevPlan, resolveDockerHost, } from "@cloudflare/containers-shared"; +import { getDevelopmentOidcOptions } from "@cloudflare/remote-bindings"; import { configFileName, DEFAULT_COMPAT_DATE, @@ -145,6 +146,18 @@ async function resolveDevConfig( return { auth, + developmentOidc: getDevelopmentOidcOptions( + { + ...config, + name: input.name ?? config.name, + compatibility_flags: + input.compatibilityFlags ?? config.compatibility_flags, + }, + logger, + config.userConfigPath + ? path.dirname(config.userConfigPath) + : process.cwd() + ), remote: input.dev?.remote, server: { hostname: input.dev?.server?.hostname || config.dev.ip, diff --git a/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts b/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts index 0f9d3c85073..2a63149ef1c 100644 --- a/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts +++ b/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts @@ -426,6 +426,7 @@ export class LocalRuntimeController extends RuntimeController { } ); options.handleUncaughtError = this.dispatchRuntimeError; + options.workers[0].developmentOidc = data.config.dev.developmentOidc; // Bail out if a newer bundle arrived while we were building // miniflare options — avoid a redundant local server reload. diff --git a/packages/wrangler/src/api/startDevWorker/types.ts b/packages/wrangler/src/api/startDevWorker/types.ts index 920caec1a69..4402c4822ce 100644 --- a/packages/wrangler/src/api/startDevWorker/types.ts +++ b/packages/wrangler/src/api/startDevWorker/types.ts @@ -80,6 +80,8 @@ export type StartDevWorkerOptions = Omit< }; dev: StartDevWorkerInput["dev"] & { persist: string | false; + /** Host-only broker created from the linked Worker configuration. */ + developmentOidc?: import("miniflare").DevelopmentOidcBrokerFactory; auth?: AsyncHook; // redefine without config.account_id hook param (can only be provided by ConfigController with access to the Wrangler configuration file, not by other controllers eg RemoteRuntimeContoller) /** Handles structured runtime logs. */ structuredLogsHandler?: (log: WorkerdStructuredLog) => void; diff --git a/packages/wrangler/src/dev/miniflare/index.ts b/packages/wrangler/src/dev/miniflare/index.ts index da6c613fc7d..c85298aa270 100644 --- a/packages/wrangler/src/dev/miniflare/index.ts +++ b/packages/wrangler/src/dev/miniflare/index.ts @@ -464,6 +464,7 @@ type WorkerOptionsBindings = Pick< | "hyperdrives" | "durableObjects" | "serviceBindings" + | "oidc" | "ratelimits" | "workflows" | "workflowExports" @@ -1092,6 +1093,11 @@ export function buildMiniflareBindingOptions( ), additionalUnboundDurableObjects, + oidc: Object.fromEntries( + extractBindingsOfType("oidc", bindings).map( + ({ binding, type: _type, ...x }) => [binding, x] + ) + ), ratelimits: Object.fromEntries(ratelimits.map(ratelimitEntry)), mtlsCertificates: Object.fromEntries( diff --git a/packages/wrangler/src/preview/preview-config.ts b/packages/wrangler/src/preview/preview-config.ts index 8c7b1004d71..e2376867541 100644 --- a/packages/wrangler/src/preview/preview-config.ts +++ b/packages/wrangler/src/preview/preview-config.ts @@ -730,6 +730,20 @@ export function convertBinding( ], }; break; + case "oidc": + config = { + oidc: [ + { + binding: name, + identity_id: String(binding.identity_id ?? REPLACE_ME), + audience: String(binding.audience ?? REPLACE_ME), + ...(Array.isArray(binding.allowed_audiences) + ? { allowed_audiences: binding.allowed_audiences as string[] } + : {}), + }, + ], + }; + break; case "ratelimit": if (binding.namespace_id === undefined || binding.simple === undefined) { break; diff --git a/packages/wrangler/src/type-generation/index.ts b/packages/wrangler/src/type-generation/index.ts index d8bd5346e42..203e57acf1c 100644 --- a/packages/wrangler/src/type-generation/index.ts +++ b/packages/wrangler/src/type-generation/index.ts @@ -2846,6 +2846,15 @@ function collectCoreBindings( addBinding(flagshipBinding.binding, "Flagship", "flagship", envName); } + for (const binding of env.oidc ?? []) { + addBinding( + binding.binding, + "{ getToken(options?: { audience?: string; fresh?: boolean }): Promise }", + "oidc", + envName + ); + } + for (const [index, ratelimit] of (env.ratelimits ?? []).entries()) { if (!ratelimit.name) { throwMissingBindingError({ @@ -3909,6 +3918,14 @@ function collectCoreBindingsPerEnvironment( }); } + for (const binding of env.oidc ?? []) { + bindings.push({ + bindingCategory: "oidc", + name: binding.binding, + type: "{ getToken(options?: { audience?: string; fresh?: boolean }): Promise }", + }); + } + for (const [index, ratelimit] of (env.ratelimits ?? []).entries()) { if (!ratelimit.name) { throwMissingBindingError({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f627dcf0ba..3d2dd65ba93 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1810,6 +1810,9 @@ importers: packages/config: dependencies: + '@cloudflare/workers-utils': + specifier: workspace:* + version: link:../workers-utils zod: specifier: catalog:default version: 4.6.5 @@ -1820,9 +1823,6 @@ importers: '@cloudflare/workers-types': specifier: catalog:default version: 5.20261006.1 - '@cloudflare/workers-utils': - specifier: workspace:* - version: link:../workers-utils ts-dedent: specifier: ^2.2.0 version: 2.2.0 diff --git a/turbo.json b/turbo.json index 13cf7874f60..ca242ba4cfc 100644 --- a/turbo.json +++ b/turbo.json @@ -27,6 +27,8 @@ "WRANGLER_LOG", "CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_ID", + "CLOUDFLARE_CF_AUTH", + "CLOUDFLARE_DEV_AUTH_PROFILE", "WRANGLER_WRITE_LOGS", "X_LOCAL_OBSERVABILITY", "GITHUB_HEAD_REF",