From 6f2ac4d4d96fb2a32b5d05812be768550405bd58 Mon Sep 17 00:00:00 2001 From: Brendan Irvine-Broque Date: Thu, 22 Oct 2026 22:12:19 -0700 Subject: [PATCH 1/7] [miniflare] Add shared development OIDC primitives --- .changeset/development-oidc-primitives.md | 7 + .../miniflare/src/shared/development-oidc.ts | 170 ++++++++++++ packages/miniflare/src/shared/index.ts | 1 + .../test/shared/development-oidc.spec.ts | 259 ++++++++++++++++++ packages/remote-bindings/README.md | 59 ++++ packages/remote-bindings/src/auth.test.ts | 130 ++++++++- packages/remote-bindings/src/auth.ts | 67 ++++- .../src/development-oidc.test.ts | 215 +++++++++++++++ .../remote-bindings/src/development-oidc.ts | 161 +++++++++++ packages/remote-bindings/src/index.ts | 6 + turbo.json | 2 + 11 files changed, 1067 insertions(+), 10 deletions(-) create mode 100644 .changeset/development-oidc-primitives.md create mode 100644 packages/miniflare/src/shared/development-oidc.ts create mode 100644 packages/miniflare/test/shared/development-oidc.spec.ts create mode 100644 packages/remote-bindings/README.md create mode 100644 packages/remote-bindings/src/development-oidc.test.ts create mode 100644 packages/remote-bindings/src/development-oidc.ts diff --git a/.changeset/development-oidc-primitives.md b/.changeset/development-oidc-primitives.md new file mode 100644 index 00000000000..4607124afe1 --- /dev/null +++ b/.changeset/development-oidc-primitives.md @@ -0,0 +1,7 @@ +--- +"miniflare": minor +"wrangler": patch +"@cloudflare/vite-plugin": patch +--- + +Add shared host-side development OIDC session and token-cache 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. diff --git a/packages/miniflare/src/shared/development-oidc.ts b/packages/miniflare/src/shared/development-oidc.ts new file mode 100644 index 00000000000..12e3de2386d --- /dev/null +++ b/packages/miniflare/src/shared/development-oidc.ts @@ -0,0 +1,170 @@ +/** The server-authorized identity and policy that own a development token. */ +export interface DevelopmentOidcIdentity { + accountId: string; + workerId: string; + userId: string; + developmentSessionId: string; + policy: 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; + +interface Entry { + controller: AbortController; + result: Promise; + token?: DevelopmentOidcToken; + monotonicExpiry?: number; +} + +/** + * Bounded, in-memory development token cache shared by dev-server consumers. + * Only the trusted host transport may supply identity and policy values. It + * must authorize every fresh issuance and validate the returned token profile. + * No timer renews tokens: renewal happens when the getter is called. + */ +export class DevelopmentOidcTokenCache { + #entries = new Map(); + #disposed = false; + + /** + * Return a token with at least one minute remaining, coalescing fresh issuance. + * + * @param identity Server-authorized account, Worker, user, session, and policy. + * @param audience Effective audience, including the resolved account default. + * @param issue Host callback used when no reusable token exists. + * @returns The bearer token. Never log or persist the returned value. + */ + async getToken( + identity: DevelopmentOidcIdentity, + audience: string, + issue: DevelopmentOidcTokenIssuer + ): Promise { + if (this.#disposed) { + throw new Error("The development OIDC session has been disposed."); + } + const values = [ + identity.accountId, + identity.workerId, + identity.userId, + identity.developmentSessionId, + identity.policy, + audience, + ]; + if ( + values.some((value) => typeof value !== "string" || value.length === 0) + ) { + throw new Error( + "Development OIDC requires a complete authorized identity and audience." + ); + } + const key = JSON.stringify(values); + let entry = this.#entries.get(key); + if (entry?.token) { + if (isReusable(entry)) { + this.#entries.delete(key); + this.#entries.set(key, entry); + return entry.token.token; + } + this.#entries.delete(key); + entry = undefined; + } + if (entry === undefined) { + if (this.#entries.size >= MAXIMUM_ENTRIES) { + // Evict a completed LRU entry, never an in-flight request: otherwise + // an unbounded number of requests could escape the concurrency limit. + const candidate = [...this.#entries].find(([, value]) => value.token); + if (candidate === undefined) { + throw new Error( + "Too many concurrent development OIDC requests. Try again after an issuance completes." + ); + } + this.#entries.delete(candidate[0]); + } + const controller = new AbortController(); + const next: Entry = { + controller, + // Defer the callback so synchronous throws also release the entry. + result: Promise.resolve().then(() => issue(controller.signal)), + }; + entry = next; + this.#entries.set(key, entry); + } else { + this.#entries.delete(key); + this.#entries.set(key, entry); + } + try { + const token = await entry.result; + if (entry.controller.signal.aborted) { + throw new Error( + "The development OIDC session changed during issuance. Retry the getter." + ); + } + const remaining = Math.min( + token.expiresAt * 1000 - Date.now(), + entry.monotonicExpiry === undefined + ? Infinity + : entry.monotonicExpiry - performance.now() + ); + if ( + typeof token.token !== "string" || + token.token.length === 0 || + token.token.length > 65_536 || + !Number.isSafeInteger(token.expiresAt) || + remaining < MINIMUM_LIFETIME_MS || + remaining > MAXIMUM_LIFETIME_MS + ) { + throw new Error( + "Development OIDC issuance returned an invalid token or lifetime. Retry the getter." + ); + } + entry.monotonicExpiry ??= performance.now() + remaining; + entry.token = { token: token.token, expiresAt: token.expiresAt }; + return token.token; + } catch (error) { + if (this.#entries.get(key) === entry) { + this.#entries.delete(key); + } + throw error; + } + } + + /** Invalidate tokens and cancel pending issuance after an auth/session change. */ + clear(): void { + const entries = this.#entries; + this.#entries = new Map(); + for (const entry of entries.values()) { + entry.controller.abort(); + } + } + + /** Cancel pending issuance and prevent reuse after the dev server stops. */ + dispose(): void { + this.#disposed = true; + this.clear(); + } +} + +function isReusable(entry: Entry): boolean { + return ( + entry.token !== undefined && + entry.monotonicExpiry !== undefined && + Math.min( + entry.token.expiresAt * 1000 - Date.now(), + entry.monotonicExpiry - performance.now() + ) >= MINIMUM_LIFETIME_MS + ); +} diff --git a/packages/miniflare/src/shared/index.ts b/packages/miniflare/src/shared/index.ts index 564fd7f10d3..818e3f006e1 100644 --- a/packages/miniflare/src/shared/index.ts +++ b/packages/miniflare/src/shared/index.ts @@ -1,4 +1,5 @@ export * from "./error"; +export * from "./development-oidc"; export * from "./event"; export * from "./forwarded-proto"; export * from "./log"; 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..9c59f81db6e --- /dev/null +++ b/packages/miniflare/test/shared/development-oidc.spec.ts @@ -0,0 +1,259 @@ +import { afterEach, beforeEach, describe, it, vi } from "vitest"; +import { DevelopmentOidcTokenCache } 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", +}; +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 cache", () => { + it.for(Object.keys(identity) as (keyof DevelopmentOidcIdentity)[])( + "rejects missing %s before issuance", + async (field, { expect }) => { + const cache = new DevelopmentOidcTokenCache(); + const issue = vi.fn(async () => issued()); + await expect( + cache.getToken({ ...identity, [field]: "" }, audience, issue) + ).rejects.toThrow("complete authorized identity"); + expect(issue).not.toHaveBeenCalled(); + } + ); + + it("coalesces concurrent requests and performs no idle renewal", async ({ + expect, + }) => { + const cache = new DevelopmentOidcTokenCache(); + 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).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000); + expect(issue).toHaveBeenCalledOnce(); + expect(await cache.getToken(identity, audience, issue)).toBe( + "development-token" + ); + expect(issue).toHaveBeenCalledTimes(2); + }); + + it("reuses at exactly 60 seconds remaining and renews below it", async ({ + expect, + }) => { + const cache = new DevelopmentOidcTokenCache(); + const issue = vi.fn(async () => issued()); + await cache.getToken(identity, audience, issue); + vi.advanceTimersByTime(240_000); + await cache.getToken(identity, audience, issue); + expect(issue).toHaveBeenCalledOnce(); + vi.advanceTimersByTime(1); + await cache.getToken(identity, audience, issue); + expect(issue).toHaveBeenCalledTimes(2); + }); + + it("does not extend cached lifetime when the wall clock moves backwards", async ({ + expect, + }) => { + const cache = new DevelopmentOidcTokenCache(); + 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 DevelopmentOidcTokenCache(); + 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 DevelopmentOidcTokenCache(); + 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 DevelopmentOidcTokenCache(); + 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 DevelopmentOidcTokenCache(); + 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 DevelopmentOidcTokenCache(); + 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 DevelopmentOidcTokenCache(); + const token = issued(); + await expect( + cache.getToken(identity, audience, async () => { + vi.advanceTimersByTime(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 DevelopmentOidcTokenCache(); + 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("does not delete a replacement entry when an invalidated issuance fails", async ({ + expect, + }) => { + const cache = new DevelopmentOidcTokenCache(); + 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("old request failed"); + await cache.getToken(identity, audience, fresh); + expect(fresh).toHaveBeenCalledOnce(); + }); + + it("bounds completed entries with LRU eviction", async ({ expect }) => { + const cache = new DevelopmentOidcTokenCache(); + const issue = vi.fn(async () => issued()); + for (let i = 0; i < 128; i++) { + await cache.getToken(identity, String(i), issue); + } + await cache.getToken(identity, "0", issue); + await cache.getToken(identity, "128", issue); + await cache.getToken(identity, "0", issue); + expect(issue).toHaveBeenCalledTimes(129); + await cache.getToken(identity, "1", issue); + expect(issue).toHaveBeenCalledTimes(130); + }); + + it("bounds pending issuance and cancels it on disposal", async ({ + expect, + }) => { + const cache = new DevelopmentOidcTokenCache(); + 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"); + }); +}); diff --git a/packages/remote-bindings/README.md b/packages/remote-bindings/README.md new file mode 100644 index 00000000000..8e6cbde2a08 --- /dev/null +++ b/packages/remote-bindings/README.md @@ -0,0 +1,59 @@ +# Shared authenticated development tooling + +This package supplies host-side remote binding sessions and authentication to +Wrangler, the Cloudflare Vite plugin, and the Vitest plugin. + +## Development OIDC primitives + +`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. + +`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. + +`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. + +### Remaining integration dependencies + +These primitives alone do **not** enable `ctx.oidc.getToken()` in local workerd. +The following integration work requires the matching upstream contracts: + +- Add the concrete Cloudflare transport when the development session/issuance + route and request/response contract are available. Map sign-in, Worker permission, + network, rate-limit, and signing failures without logging response tokens. +- Connect Miniflare's per-Worker native getter callback using the matching workerd + service configuration and protocol. Use its authenticated internal loopback; + expose no issuance route on the application's public dev server. Update the + workerd pin and regenerate schema/types from that release. +- Retain one provider per user Worker in Wrangler's local and multiworker runtime + controllers and the Vite dev-server lifecycle. Exclude internal proxy, runner, + asset, and helper Workers. Show the server-authorized development subject. +- Add a separate, default-off live-issuance opt-in in Vitest. Offline tests inject + mocks. Enabling remote bindings must not implicitly enable live OIDC issuance. +- Prove the native getter end to end for Workers and local Durable Objects, + including service/RPC calls, Worker isolation, credential changes, cancellation, + and renewal/failure behavior. Vite core requires no change. + +Until those integrations are complete, native local issuance remains unsupported +through Wrangler, Vite, and Vitest. The mock transport tests cover only the shared +host-side provider and cache, not the runtime or a live Cloudflare endpoint. diff --git a/packages/remote-bindings/src/auth.test.ts b/packages/remote-bindings/src/auth.test.ts index f4c5f6a0757..09843b20a27 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,85 @@ 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", + }); + 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).toHaveBeenCalledWith({}); + }); + + 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..613e5fdee9d 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,62 @@ 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 } : {} + ), + 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.test.ts b/packages/remote-bindings/src/development-oidc.test.ts new file mode 100644 index 00000000000..cefe5af1242 --- /dev/null +++ b/packages/remote-bindings/src/development-oidc.test.ts @@ -0,0 +1,215 @@ +import { afterEach, beforeEach, describe, it, vi } from "vitest"; +import { DevelopmentOidcProvider } from "./development-oidc"; +import type { DevelopmentOidcSession } from "./development-oidc"; +import type { CfAccount } from "@cloudflare/workers-utils"; +import type { DevelopmentOidcToken } from "miniflare"; + +const session: DevelopmentOidcSession = { + identity: { + accountId: "account", + workerId: "worker", + userId: "user", + developmentSessionId: "session", + policy: "policy", + }, + defaultAudience: "https://workers.cloudflare.com/accounts/account", +}; + +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, _signal: AbortSignal) => session + ); + const issueToken = vi.fn( + async ( + _auth: CfAccount, + _session: DevelopmentOidcSession, + _audience: string, + _signal: AbortSignal + ) => token() + ); + const provider = new DevelopmentOidcProvider(workerName, getAuth, { + createSession, + issueToken, + }); + 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, session, and 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", + expect.any(AbortSignal) + ); + expect(issueToken).toHaveBeenCalledOnce(); + expect(issueToken.mock.calls[0]?.[2]).toBe(session.defaultAudience); + await provider.getToken(); + expect(getAuth).toHaveBeenCalledTimes(2); + expect(createSession).toHaveBeenCalledOnce(); + expect(issueToken).toHaveBeenCalledOnce(); + }); + + 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/", + ]); + }); + + 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]?.[2].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("reports fresh issuance failures without stale-token fallback", async ({ + expect, + }) => { + const { provider, issueToken } = setup(); + await provider.getToken(); + vi.advanceTimersByTime(241_000); + issueToken.mockRejectedValueOnce(new Error("Cloudflare is unreachable")); + await expect(provider.getToken()).rejects.toThrow( + "Cloudflare is unreachable" + ); + expect(await provider.getToken()).toBe("development-token"); + }); + + 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("cloud Preview"); + } + 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]?.[3].aborted).toBe(true); + complete?.(token()); + await expect(pending).rejects.toThrow("session changed"); + await expect(provider.getToken()).rejects.toThrow("disposed"); + }); +}); diff --git a/packages/remote-bindings/src/development-oidc.ts b/packages/remote-bindings/src/development-oidc.ts new file mode 100644 index 00000000000..4e2acc08e67 --- /dev/null +++ b/packages/remote-bindings/src/development-oidc.ts @@ -0,0 +1,161 @@ +import { DevelopmentOidcTokenCache } from "miniflare"; +import type { CfAccount } from "@cloudflare/workers-utils"; +import type { DevelopmentOidcIdentity, DevelopmentOidcToken } from "miniflare"; + +/** Session metadata returned by the authorized Cloudflare development API. */ +export interface DevelopmentOidcSession { + identity: DevelopmentOidcIdentity; + defaultAudience: string; +} + +/** + * 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. + */ +export interface DevelopmentOidcTransport { + createSession( + auth: CfAccount, + workerName: string, + signal: AbortSignal + ): Promise; + issueToken( + auth: CfAccount, + session: DevelopmentOidcSession, + audience: string, + signal: AbortSignal + ): Promise; +} + +interface SessionState { + accountId: string; + oauthToken: string; + controller: AbortController; + session: Promise; +} + +/** + * 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 { + #cache = new DevelopmentOidcTokenCache(); + #state?: SessionState; + #authPromise?: Promise; + #disposed = false; + + constructor( + private readonly workerName: string, + private readonly getAuth: () => Promise, + private readonly transport: DevelopmentOidcTransport + ) {} + + /** + * 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 } = {}): Promise { + this.#checkActive(); + if (!this.workerName) { + throw new Error( + "Development OIDC requires an existing Worker name in your project configuration." + ); + } + // The runtime also checks options. Retain the host-side restriction so + // neither an adapter nor a test mock can turn a local DO into a cloud DO. + if ("subject" in options) { + throw new Error( + "Local Durable Objects use the Worker's development identity. Explicit object and namespace subjects require a cloud Preview." + ); + } + let auth: CfAccount; + try { + this.#authPromise ??= Promise.resolve().then(() => this.getAuth()); + const pending = this.#authPromise; + try { + auth = await pending; + } finally { + if (this.#authPromise === pending) { + this.#authPromise = undefined; + } + } + } catch (error) { + this.#invalidate(); + throw error; + } + this.#checkActive(); + if (!("apiToken" in auth.apiToken) || !auth.apiToken.apiToken) { + this.#invalidate(); + throw new Error("Development OIDC requires human OAuth credentials."); + } + let state = this.#state; + if ( + state?.accountId !== auth.accountId || + state.oauthToken !== auth.apiToken.apiToken + ) { + this.#invalidate(); + const controller = new AbortController(); + state = { + accountId: auth.accountId, + oauthToken: auth.apiToken.apiToken, + controller, + session: Promise.resolve().then(() => + this.transport.createSession(auth, this.workerName, controller.signal) + ), + }; + this.#state = state; + } + try { + const session = await state.session; + this.#checkActive(); + if (state.controller.signal.aborted) { + throw new Error( + "Development OIDC credentials changed during issuance. Retry the getter." + ); + } + if (session.identity.accountId !== auth.accountId) { + throw new Error( + "Development OIDC returned a session for a different account." + ); + } + const audience = options.audience ?? session.defaultAudience; + return await this.#cache.getToken(session.identity, audience, (signal) => + this.transport.issueToken( + auth, + session, + audience, + AbortSignal.any([signal, state.controller.signal]) + ) + ); + } catch (error) { + if (this.#state === state) { + this.#invalidate(); + } + throw error; + } + } + + /** Stop the provider and cancel outstanding network requests on shutdown. */ + dispose(): void { + this.#disposed = true; + this.#invalidate(); + this.#cache.dispose(); + } + + #invalidate(): void { + const state = this.#state; + this.#state = undefined; + this.#cache.clear(); + state?.controller.abort(); + } + + #checkActive(): void { + if (this.#disposed) { + throw new Error("The development OIDC provider has been disposed."); + } + } +} diff --git a/packages/remote-bindings/src/index.ts b/packages/remote-bindings/src/index.ts index df418124afb..1217f998c0c 100644 --- a/packages/remote-bindings/src/index.ts +++ b/packages/remote-bindings/src/index.ts @@ -8,6 +8,12 @@ export type { WorkerConfigObject, } from "./maybe-start-or-update-session"; export { startRemoteProxySession } from "./start-remote-proxy-session"; +export { getDevelopmentOidcAuthHook } from "./auth"; +export { DevelopmentOidcProvider } from "./development-oidc"; +export type { + DevelopmentOidcSession, + DevelopmentOidcTransport, +} from "./development-oidc"; export type { RemoteBindingsLogger } from "./logger"; export type { RemoteProxySession, 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", From 9c9bcf4d8e602871baad96c60bdd226194e4a849 Mon Sep 17 00:00:00 2001 From: Brendan Irvine-Broque Date: Thu, 22 Oct 2026 23:04:59 -0700 Subject: [PATCH 2/7] [miniflare] Harden development OIDC request lifecycle --- .changeset/development-oidc-primitives.md | 3 + .../miniflare/src/shared/development-oidc.ts | 231 ++++++++++++---- .../test/shared/development-oidc.spec.ts | 157 ++++++++++- packages/remote-bindings/README.md | 18 +- packages/remote-bindings/src/auth.test.ts | 18 +- packages/remote-bindings/src/auth.ts | 3 +- .../src/development-oidc.test.ts | 205 +++++++++++++++ .../remote-bindings/src/development-oidc.ts | 247 +++++++++++++----- packages/workers-auth/src/core/factory.ts | 46 +++- .../workers-auth/tests/core/factory.test.ts | 25 ++ 10 files changed, 805 insertions(+), 148 deletions(-) diff --git a/.changeset/development-oidc-primitives.md b/.changeset/development-oidc-primitives.md index 4607124afe1..ddf0d41ca24 100644 --- a/.changeset/development-oidc-primitives.md +++ b/.changeset/development-oidc-primitives.md @@ -1,7 +1,10 @@ --- "miniflare": minor +"@cloudflare/workers-auth": minor "wrangler": patch "@cloudflare/vite-plugin": patch --- Add shared host-side development OIDC session and token-cache 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 cache 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/packages/miniflare/src/shared/development-oidc.ts b/packages/miniflare/src/shared/development-oidc.ts index 12e3de2386d..c3ae543712f 100644 --- a/packages/miniflare/src/shared/development-oidc.ts +++ b/packages/miniflare/src/shared/development-oidc.ts @@ -1,3 +1,6 @@ +import { Buffer } from "node:buffer"; +import { addAbortListener } from "node:events"; + /** The server-authorized identity and policy that own a development token. */ export interface DevelopmentOidcIdentity { accountId: string; @@ -22,12 +25,132 @@ export type DevelopmentOidcTokenIssuer = ( const MINIMUM_LIFETIME_MS = 60_000; const MAXIMUM_LIFETIME_MS = 300_000; const MAXIMUM_ENTRIES = 128; +const REQUEST_TIMEOUT_MS = 30_000; + +/** + * 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 Error("The development OIDC request timed out. Retry the getter.") + ); + }, 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 host-side audience bounds without normalizing the value. The trusted + * transport must also enforce the signing API's StringOrURI rules. + * + * @param audience Audience received from a getter or authorized session. + */ +export function validateDevelopmentOidcAudience( + audience: unknown +): asserts audience is string { + if (!isBoundedString(audience, 2048) || audience.trim().length === 0) { + throw new Error( + "Development OIDC requires an 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, + }; + 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; +} interface Entry { controller: AbortController; - result: Promise; - token?: DevelopmentOidcToken; - monotonicExpiry?: number; + result: Promise>; + token?: Readonly; } /** @@ -38,6 +161,8 @@ interface Entry { */ export class DevelopmentOidcTokenCache { #entries = new Map(); + #requests = new DevelopmentOidcRequestPool(); + #waiters = 0; #disposed = false; /** @@ -56,25 +181,14 @@ export class DevelopmentOidcTokenCache { if (this.#disposed) { throw new Error("The development OIDC session has been disposed."); } - const values = [ - identity.accountId, - identity.workerId, - identity.userId, - identity.developmentSessionId, - identity.policy, + validateDevelopmentOidcAudience(audience); + const key = JSON.stringify([ + ...Object.values(snapshotDevelopmentOidcIdentity(identity)), audience, - ]; - if ( - values.some((value) => typeof value !== "string" || value.length === 0) - ) { - throw new Error( - "Development OIDC requires a complete authorized identity and audience." - ); - } - const key = JSON.stringify(values); + ]); let entry = this.#entries.get(key); if (entry?.token) { - if (isReusable(entry)) { + if (isReusable(entry.token)) { this.#entries.delete(key); this.#entries.set(key, entry); return entry.token.token; @@ -82,63 +196,68 @@ export class DevelopmentOidcTokenCache { this.#entries.delete(key); entry = undefined; } + if (this.#waiters >= MAXIMUM_ENTRIES) { + throw new Error("Too many concurrent development OIDC requests."); + } if (entry === undefined) { if (this.#entries.size >= MAXIMUM_ENTRIES) { - // Evict a completed LRU entry, never an in-flight request: otherwise - // an unbounded number of requests could escape the concurrency limit. const candidate = [...this.#entries].find(([, value]) => value.token); if (candidate === undefined) { - throw new Error( - "Too many concurrent development OIDC requests. Try again after an issuance completes." - ); + throw new Error("Too many concurrent development OIDC requests."); } this.#entries.delete(candidate[0]); } const controller = new AbortController(); - const next: Entry = { + entry = { controller, - // Defer the callback so synchronous throws also release the entry. - result: Promise.resolve().then(() => issue(controller.signal)), + result: this.#requests.run(async (signal) => { + const response = await issue(signal); + signal.throwIfAborted(); + const token = response?.token; + const expiresAt = response?.expiresAt; + const remaining = + typeof expiresAt === "number" ? expiresAt * 1000 - Date.now() : NaN; + if ( + !isBoundedString(token, 65_536) || + !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." + ); + } + // Retain only this snapshot, never the mutable transport response. + return Object.freeze({ + token, + expiresAt, + monotonicExpiry: performance.now() + remaining, + }); + }, controller.signal), }; - entry = next; this.#entries.set(key, entry); } else { this.#entries.delete(key); this.#entries.set(key, entry); } + this.#waiters++; try { const token = await entry.result; - if (entry.controller.signal.aborted) { - throw new Error( - "The development OIDC session changed during issuance. Retry the getter." - ); - } - const remaining = Math.min( - token.expiresAt * 1000 - Date.now(), - entry.monotonicExpiry === undefined - ? Infinity - : entry.monotonicExpiry - performance.now() - ); - if ( - typeof token.token !== "string" || - token.token.length === 0 || - token.token.length > 65_536 || - !Number.isSafeInteger(token.expiresAt) || - remaining < MINIMUM_LIFETIME_MS || - remaining > MAXIMUM_LIFETIME_MS - ) { + entry.controller.signal.throwIfAborted(); + if (!isReusable(token)) { throw new Error( "Development OIDC issuance returned an invalid token or lifetime. Retry the getter." ); } - entry.monotonicExpiry ??= performance.now() + remaining; - entry.token = { token: token.token, expiresAt: token.expiresAt }; + entry.token = token; return token.token; } catch (error) { if (this.#entries.get(key) === entry) { this.#entries.delete(key); } throw error; + } finally { + this.#waiters--; } } @@ -147,7 +266,11 @@ export class DevelopmentOidcTokenCache { const entries = this.#entries; this.#entries = new Map(); for (const entry of entries.values()) { - entry.controller.abort(); + entry.controller.abort( + new Error( + "The development OIDC session changed during issuance. Retry the getter." + ) + ); } } @@ -158,13 +281,11 @@ export class DevelopmentOidcTokenCache { } } -function isReusable(entry: Entry): boolean { +function isReusable(token: CachedToken): boolean { return ( - entry.token !== undefined && - entry.monotonicExpiry !== undefined && Math.min( - entry.token.expiresAt * 1000 - Date.now(), - entry.monotonicExpiry - performance.now() + token.expiresAt * 1000 - Date.now(), + token.monotonicExpiry - performance.now() ) >= MINIMUM_LIFETIME_MS ); } diff --git a/packages/miniflare/test/shared/development-oidc.spec.ts b/packages/miniflare/test/shared/development-oidc.spec.ts index 9c59f81db6e..19a98352b9e 100644 --- a/packages/miniflare/test/shared/development-oidc.spec.ts +++ b/packages/miniflare/test/shared/development-oidc.spec.ts @@ -161,7 +161,7 @@ describe("development OIDC token cache", () => { const token = issued(); await expect( cache.getToken(identity, audience, async () => { - vi.advanceTimersByTime(241_000); + vi.setSystemTime(Date.now() + 241_000); return token; }) ).rejects.toThrow("invalid token or lifetime"); @@ -210,7 +210,7 @@ describe("development OIDC token cache", () => { const fresh = vi.fn(async () => issued()); await cache.getToken(identity, audience, fresh); fail?.(new Error("old request failed")); - await expect(pending).rejects.toThrow("old request failed"); + await expect(pending).rejects.toThrow("session changed"); await cache.getToken(identity, audience, fresh); expect(fresh).toHaveBeenCalledOnce(); }); @@ -257,3 +257,156 @@ describe("development OIDC token cache", () => { ).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 DevelopmentOidcTokenCache().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 DevelopmentOidcTokenCache(); + 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 DevelopmentOidcTokenCache(); + 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 DevelopmentOidcTokenCache(); + 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 DevelopmentOidcTokenCache(); + 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.toThrow("timed out"); + 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 coalesced waiters as well as distinct issuances", async ({ + expect, + }) => { + const cache = new DevelopmentOidcTokenCache(); + 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).toHaveBeenCalledOnce(); + 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 DevelopmentOidcTokenCache(); + 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 DevelopmentOidcTokenCache(); + 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(10).fill("token-1")); + expect(reads).toBe(1); + response.expiresAt = 0; + expect(await cache.getToken(identity, audience, async () => issued())).toBe( + "token-1" + ); + }); +}); diff --git a/packages/remote-bindings/README.md b/packages/remote-bindings/README.md index 8e6cbde2a08..9f88d592ecb 100644 --- a/packages/remote-bindings/README.md +++ b/packages/remote-bindings/README.md @@ -10,7 +10,8 @@ reads its human OAuth credentials on demand. API tokens, global keys, and tempor 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. +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 @@ -24,7 +25,20 @@ 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. +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 diff --git a/packages/remote-bindings/src/auth.test.ts b/packages/remote-bindings/src/auth.test.ts index 09843b20a27..072524fefb5 100644 --- a/packages/remote-bindings/src/auth.test.ts +++ b/packages/remote-bindings/src/auth.test.ts @@ -206,9 +206,10 @@ describe("getDevelopmentOidcAuthHook", () => { accountId: "oauth-account", apiToken: { apiToken: "human-oauth-token" }, }); - expect(auth.getOrSelectAccountId).toHaveBeenCalledWith({ - account_id: "configured-account", - }); + expect(auth.getOrSelectAccountId).toHaveBeenCalledWith( + { account_id: "configured-account" }, + { credentials: { apiToken: "human-oauth-token" } } + ); expect(auth.requireApiToken).not.toHaveBeenCalled(); expect(auth.requireAuth).not.toHaveBeenCalled(); expect( @@ -239,7 +240,16 @@ describe("getDevelopmentOidcAuthHook", () => { 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).toHaveBeenCalledWith({}); + expect(mocks.wranglerAuth.getOrSelectAccountId).toHaveBeenNthCalledWith( + 1, + {}, + { credentials: { apiToken: "signed-in" } } + ); + expect(mocks.wranglerAuth.getOrSelectAccountId).toHaveBeenNthCalledWith( + 2, + {}, + { credentials: { apiToken: "refreshed" } } + ); }); it.for([true, false])( diff --git a/packages/remote-bindings/src/auth.ts b/packages/remote-bindings/src/auth.ts index 613e5fdee9d..5719712ecde 100644 --- a/packages/remote-bindings/src/auth.ts +++ b/packages/remote-bindings/src/auth.ts @@ -120,7 +120,8 @@ export function getDevelopmentOidcAuthHook( } return { accountId: await auth.getOrSelectAccountId( - accountId ? { account_id: accountId } : {} + accountId ? { account_id: accountId } : {}, + { credentials: { apiToken: token } } ), apiToken: { apiToken: token }, }; diff --git a/packages/remote-bindings/src/development-oidc.test.ts b/packages/remote-bindings/src/development-oidc.test.ts index cefe5af1242..4520718f1e8 100644 --- a/packages/remote-bindings/src/development-oidc.test.ts +++ b/packages/remote-bindings/src/development-oidc.test.ts @@ -1,3 +1,5 @@ +import { inspect } from "node:util"; +import { DevelopmentOidcTokenCache } from "miniflare"; import { afterEach, beforeEach, describe, it, vi } from "vitest"; import { DevelopmentOidcProvider } from "./development-oidc"; import type { DevelopmentOidcSession } from "./development-oidc"; @@ -213,3 +215,206 @@ describe("development OIDC provider", () => { 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 = { + identity: { ...session.identity }, + defaultAudience: session.defaultAudience, + }; + 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(DevelopmentOidcTokenCache.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(); +}); diff --git a/packages/remote-bindings/src/development-oidc.ts b/packages/remote-bindings/src/development-oidc.ts index 4e2acc08e67..26d3c389490 100644 --- a/packages/remote-bindings/src/development-oidc.ts +++ b/packages/remote-bindings/src/development-oidc.ts @@ -1,4 +1,9 @@ -import { DevelopmentOidcTokenCache } from "miniflare"; +import { + DevelopmentOidcRequestPool, + DevelopmentOidcTokenCache, + snapshotDevelopmentOidcIdentity, + validateDevelopmentOidcAudience, +} from "miniflare"; import type { CfAccount } from "@cloudflare/workers-utils"; import type { DevelopmentOidcIdentity, DevelopmentOidcToken } from "miniflare"; @@ -11,7 +16,10 @@ export interface DevelopmentOidcSession { /** * 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. + * 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( @@ -27,6 +35,8 @@ export interface DevelopmentOidcTransport { ): Promise; } +type OAuthAccount = CfAccount & { apiToken: { apiToken: string } }; + interface SessionState { accountId: string; oauthToken: string; @@ -41,16 +51,29 @@ interface SessionState { * are read on every getter, so login/logout does not require a restart. */ export class DevelopmentOidcProvider { + #workerName: string; + #getAuth: (signal: AbortSignal) => Promise; + #transport: DevelopmentOidcTransport; #cache = new DevelopmentOidcTokenCache(); + #requests = new DevelopmentOidcRequestPool(); + #shutdown = new AbortController(); #state?: SessionState; - #authPromise?: Promise; - #disposed = false; + #authPromise?: Promise; + #callers = 0; constructor( - private readonly workerName: string, - private readonly getAuth: () => Promise, - private readonly transport: DevelopmentOidcTransport - ) {} + workerName: string, + getAuth: (signal: AbortSignal) => Promise, + transport: DevelopmentOidcTransport + ) { + this.#workerName = workerName; + 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. @@ -60,88 +83,158 @@ export class DevelopmentOidcProvider { */ async getToken(options: { audience?: string } = {}): Promise { this.#checkActive(); - if (!this.workerName) { + if ( + typeof this.#workerName !== "string" || + this.#workerName.length > 255 || + !this.#workerName.trim() + ) { throw new Error( "Development OIDC requires an existing Worker name in your project configuration." ); } - // The runtime also checks options. Retain the host-side restriction so - // neither an adapter nor a test mock can turn a local DO into a cloud DO. + if (options === null || typeof options !== "object") { + throw new Error("Development OIDC requires an options object."); + } + // Retain this restriction in the host even when an adapter bypasses workerd. if ("subject" in options) { throw new Error( "Local Durable Objects use the Worker's development identity. Explicit object and namespace subjects require a cloud Preview." ); } - let auth: CfAccount; - try { - this.#authPromise ??= Promise.resolve().then(() => this.getAuth()); - const pending = this.#authPromise; - try { - auth = await pending; - } finally { - if (this.#authPromise === pending) { - this.#authPromise = undefined; - } - } - } catch (error) { - this.#invalidate(); - throw error; - } - this.#checkActive(); - if (!("apiToken" in auth.apiToken) || !auth.apiToken.apiToken) { - this.#invalidate(); - throw new Error("Development OIDC requires human OAuth credentials."); + // Snapshot before the first await: callers may retain and mutate options. + const requestedAudience = options.audience; + if (requestedAudience !== undefined) { + validateDevelopmentOidcAudience(requestedAudience); } - let state = this.#state; - if ( - state?.accountId !== auth.accountId || - state.oauthToken !== auth.apiToken.apiToken - ) { - this.#invalidate(); - const controller = new AbortController(); - state = { - accountId: auth.accountId, - oauthToken: auth.apiToken.apiToken, - controller, - session: Promise.resolve().then(() => - this.transport.createSession(auth, this.workerName, controller.signal) - ), - }; - this.#state = state; + if (this.#callers >= 128) { + throw new Error("Too many concurrent development OIDC requests."); } + this.#callers++; try { - const session = await state.session; + const auth = await this.#readAuth(); this.#checkActive(); - if (state.controller.signal.aborted) { - throw new Error( - "Development OIDC credentials changed during issuance. Retry the getter." + let state = this.#state; + if ( + state?.accountId !== auth.accountId || + state.oauthToken !== auth.apiToken.apiToken + ) { + 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, + signal + ); + signal.throwIfAborted(); + const identity = snapshotDevelopmentOidcIdentity( + response?.identity + ); + const defaultAudience = response?.defaultAudience; + validateDevelopmentOidcAudience(defaultAudience); + if (identity.accountId !== auth.accountId) { + throw new Error( + "Development OIDC returned a session for a different account." + ); + } + return Object.freeze({ identity, defaultAudience }); + }, controller.signal), + }; + this.#state = state; + } + try { + const session = await state.session; + this.#checkCurrent(state); + const audience = requestedAudience ?? session.defaultAudience; + const token = await this.#cache.getToken( + session.identity, + audience, + (signal) => + this.#transport.issueToken( + auth, + session, + audience, + AbortSignal.any([signal, state.controller.signal]) + ) ); + // Invalidation can occur while awaiting even an already-cached token. + this.#checkCurrent(state); + return token; + } catch (error) { + if (this.#state === state) { + this.#invalidate(); + } + throw error; } - if (session.identity.accountId !== auth.accountId) { + } 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 returned a session for a different account." + "Development OIDC requires human OAuth credentials and a valid account." ); } - const audience = options.audience ?? session.defaultAudience; - return await this.#cache.getToken(session.identity, audience, (signal) => - this.transport.issueToken( - auth, - session, - audience, - AbortSignal.any([signal, state.controller.signal]) - ) - ); - } catch (error) { - if (this.#state === state) { - this.#invalidate(); + 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(); + } } - throw error; - } + ); + return pending; } /** Stop the provider and cancel outstanding network requests on shutdown. */ dispose(): void { - this.#disposed = true; + this.#shutdown.abort( + new Error("The development OIDC provider has been disposed.") + ); this.#invalidate(); this.#cache.dispose(); } @@ -150,12 +243,24 @@ export class DevelopmentOidcProvider { const state = this.#state; this.#state = undefined; this.#cache.clear(); - state?.controller.abort(); + state?.controller.abort( + new Error( + "Development OIDC credentials changed during issuance. Retry the getter." + ) + ); } - #checkActive(): void { - if (this.#disposed) { - throw new Error("The development OIDC provider has been disposed."); + #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/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()); From db016083ed200e60a2a5b58d8169723050e65d4f Mon Sep 17 00:00:00 2001 From: Brendan Irvine-Broque Date: Thu, 22 Oct 2026 23:33:21 -0700 Subject: [PATCH 3/7] [miniflare] Connect authenticated local OIDC issuance --- .changeset/development-oidc-native.md | 12 + packages/miniflare/package.json | 2 +- .../scripts/generate-workerd-config.mjs | 42 +++ packages/miniflare/src/config/schema.ts | 13 + packages/miniflare/src/config/v4-convert.ts | 1 + packages/miniflare/src/config/v4-schema.ts | 15 + packages/miniflare/src/index.ts | 65 ++++ packages/miniflare/src/plugins/core/index.ts | 33 ++ .../miniflare/src/plugins/shared/index.ts | 1 + .../src/runtime/config/generated/workerd.ts | 28 +- .../miniflare/src/runtime/config/workerd.ts | 1 + .../src/shared/development-oidc-broker.ts | 123 +++++++ packages/miniflare/src/shared/index.ts | 1 + .../miniflare/src/workers/core/oidc.worker.ts | 16 + .../plugins/core/development-oidc.spec.ts | 185 ++++++++++ .../shared/development-oidc-broker.spec.ts | 98 +++++ packages/remote-bindings/README.md | 109 ++++-- .../src/development-oidc-api.test.ts | 322 +++++++++++++++++ .../src/development-oidc-api.ts | 339 ++++++++++++++++++ .../src/development-oidc-options.ts | 98 +++++ .../src/development-oidc.test.ts | 19 + .../remote-bindings/src/development-oidc.ts | 28 +- packages/remote-bindings/src/index.ts | 2 + .../src/__tests__/development-oidc.spec.ts | 59 +++ .../src/miniflare-options.ts | 2 + packages/vitest-plugin/src/pool/config.ts | 3 + .../test/development-oidc.test.ts | 56 +++ ...table-get-miniflare-worker-options.test.ts | 37 ++ .../src/api/integrations/platform/index.ts | 10 + .../api/startDevWorker/ConfigController.ts | 13 + .../startDevWorker/LocalRuntimeController.ts | 1 + .../wrangler/src/api/startDevWorker/types.ts | 2 + 32 files changed, 1704 insertions(+), 32 deletions(-) create mode 100644 .changeset/development-oidc-native.md create mode 100644 packages/miniflare/scripts/generate-workerd-config.mjs create mode 100644 packages/miniflare/src/shared/development-oidc-broker.ts create mode 100644 packages/miniflare/src/workers/core/oidc.worker.ts create mode 100644 packages/miniflare/test/plugins/core/development-oidc.spec.ts create mode 100644 packages/miniflare/test/shared/development-oidc-broker.spec.ts create mode 100644 packages/remote-bindings/src/development-oidc-api.test.ts create mode 100644 packages/remote-bindings/src/development-oidc-api.ts create mode 100644 packages/remote-bindings/src/development-oidc-options.ts create mode 100644 packages/vite-plugin-cloudflare/src/__tests__/development-oidc.spec.ts create mode 100644 packages/vitest-plugin/test/development-oidc.test.ts diff --git a/.changeset/development-oidc-native.md b/.changeset/development-oidc-native.md new file mode 100644 index 00000000000..f3bac3a505d --- /dev/null +++ b/.changeset/development-oidc-native.md @@ -0,0 +1,12 @@ +--- +"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, caching 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. + +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/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..f285617924a --- /dev/null +++ b/packages/miniflare/scripts/generate-workerd-config.mjs @@ -0,0 +1,42 @@ +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}` + ); +} +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..aa3c10814c8 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; diff --git a/packages/miniflare/src/config/v4-schema.ts b/packages/miniflare/src/config/v4-schema.ts index 01c32bac455..a4b15234ba0 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"; @@ -385,6 +386,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(), @@ -831,6 +844,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..62387c5f0a3 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,61 @@ 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) => key !== "audience") || + ("audience" in options && typeof options.audience !== "string") + ) { + throw new Error("Invalid callback."); + } + const token = await this.#developmentOidc.getToken(capability, options); + 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 +2379,7 @@ export class Miniflare { workerNames, loopbackHost, loopbackPort, + developmentOidcSecret: this.#developmentOidc.capability(workerIndex), durableObjectClassNames, unsafeEphemeralDurableObjects, workflowExporters, @@ -3156,6 +3217,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 +3922,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..b608b26b004 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"; @@ -490,6 +491,7 @@ export const CORE_PLUGIN: Plugin = { additionalModules, loopbackHost, loopbackPort, + developmentOidcSecret, }) { const { config, dev } = options; // Define regular user worker @@ -610,6 +612,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 +658,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..63ef3aa316a 100644 --- a/packages/miniflare/src/runtime/config/generated/workerd.ts +++ b/packages/miniflare/src/runtime/config/generated/workerd.ts @@ -3429,7 +3429,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 +3494,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 +3591,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 +3957,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..0ebca51b1a0 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; }; 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..989598c4c35 --- /dev/null +++ b/packages/miniflare/src/shared/development-oidc-broker.ts @@ -0,0 +1,123 @@ +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: { audience?: string }): 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: { audience?: string } + ): 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/index.ts b/packages/miniflare/src/shared/index.ts index 818e3f006e1..825fb6b07e9 100644 --- a/packages/miniflare/src/shared/index.ts +++ b/packages/miniflare/src/shared/index.ts @@ -1,5 +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..2db9076b0c1 --- /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 audience. + 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..3350606b339 --- /dev/null +++ b/packages/miniflare/test/plugins/core/development-oidc.spec.ts @@ -0,0 +1,185 @@ +import { + Miniflare, + type DevelopmentOidcBrokerFactory, + type WorkerOptions, +} from "miniflare"; +import { describe, test, vi } from "vitest"; +import { singleModuleManifest, useDispose } from "../../test-shared"; + +const token = "eyJhbGciOiJSUzI1NiJ9.eyJleGVjdXRpb24iOiJsb2NhbCJ9.c2ln"; + +function worker(developmentOidc?: DevelopmentOidcBrokerFactory): WorkerOptions { + return { + config: { + name: "linked", + 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 ctx.oidc.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("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.ctx.oidc.getToken();} + async explicit() {return this.ctx.oidc.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 ctx.oidc.getToken(), one: await one.token(), two: await two.token(), target: await env.OTHER.token(), explicitRejected}); + }}; + `), + env: { + 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", + compatibilityDate: "2026-01-01", + compatibilityFlags: ["workers_oidc"], + manifest: singleModuleManifest(` + import {WorkerEntrypoint} from "cloudflare:workers"; + export class Issuer extends WorkerEntrypoint {async token() {return this.ctx.oidc.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 without exposing a binding; 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({}); + expect( + await ( + await mf.dispatchFetch("http://localhost/?audience=exact%22%E9%9B%AA") + ).text() + ).toBe(token); + expect(getToken).toHaveBeenLastCalledWith({ audience: 'exact"雪' }); + expect( + await (await mf.dispatchFetch("http://localhost/env")).json() + ).toEqual([]); + 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..02370c17243 --- /dev/null +++ b/packages/miniflare/test/shared/development-oidc-broker.spec.ts @@ -0,0 +1,98 @@ +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, {})).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, {})).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) ?? "", {}); + 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) ?? "", {}); + 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) ?? "", {}) + ).rejects.toThrow("invalid token"); + manager.dispose(); + } + ); +}); diff --git a/packages/remote-bindings/README.md b/packages/remote-bindings/README.md index 9f88d592ecb..f8d7efa015d 100644 --- a/packages/remote-bindings/README.md +++ b/packages/remote-bindings/README.md @@ -3,7 +3,7 @@ This package supplies host-side remote binding sessions and authentication to Wrangler, the Cloudflare Vite plugin, and the Vitest plugin. -## Development OIDC primitives +## 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 @@ -47,27 +47,86 @@ 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. -### Remaining integration dependencies - -These primitives alone do **not** enable `ctx.oidc.getToken()` in local workerd. -The following integration work requires the matching upstream contracts: - -- Add the concrete Cloudflare transport when the development session/issuance - route and request/response contract are available. Map sign-in, Worker permission, - network, rate-limit, and signing failures without logging response tokens. -- Connect Miniflare's per-Worker native getter callback using the matching workerd - service configuration and protocol. Use its authenticated internal loopback; - expose no issuance route on the application's public dev server. Update the - workerd pin and regenerate schema/types from that release. -- Retain one provider per user Worker in Wrangler's local and multiworker runtime - controllers and the Vite dev-server lifecycle. Exclude internal proxy, runner, - asset, and helper Workers. Show the server-authorized development subject. -- Add a separate, default-off live-issuance opt-in in Vitest. Offline tests inject - mocks. Enabling remote bindings must not implicitly enable live OIDC issuance. -- Prove the native getter end to end for Workers and local Durable Objects, - including service/RPC calls, Worker isolation, credential changes, cancellation, - and renewal/failure behavior. Vite core requires no change. - -Until those integrations are complete, native local issuance remains unsupported -through Wrangler, Vite, and Vitest. The mock transport tests cover only the shared -host-side provider and cache, not the runtime or a live Cloudflare endpoint. +### 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/development-oidc-api.test.ts b/packages/remote-bindings/src/development-oidc-api.test.ts new file mode 100644 index 00000000000..bd3642372c4 --- /dev/null +++ b/packages/remote-bindings/src/development-oidc-api.test.ts @@ -0,0 +1,322 @@ +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, + 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 session: DevelopmentOidcSession = { + identity: { + accountId, + workerId, + userId, + developmentSessionId: sessionId, + policy: "workers-oidc-v1", + }, + defaultAudience: audience, + expiresAt: now + 28800, +}; +const logger = { debug: vi.fn(), info: vi.fn() } as unknown as Logger; +const rawSession = { + account_id: accountId, + worker_id: workerId, + user_id: userId, + development_session_id: sessionId, + policy: "workers-oidc-v1", + default_audience: audience, + expires_at: session.expiresAt, +}; +const claims = { + iss: `https://oidc.cloudflare.app/accounts/${accountId}`, + sub: `worker:${workerId}:development:user:${userId}`, + 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", signal) + ).toEqual(session); + expect(await transport.issueToken(auth, session, audience, 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: "{}", + redirect: "error", + }); + expect(calls[1]?.[1]).toBe( + `/accounts/${accountId}/workers/oidc/development-sessions/${sessionId}/tokens` + ); + expect(calls[1]?.[2]?.body).toBe(JSON.stringify({ audience })); + expect(calls[1]?.[7]).toEqual(auth.apiToken); + expect(calls[1]?.[6]).toBe(signal); + }); + it.for([401, 403, 404, 429, 503])( + "suppresses API error bodies and cancels them (%i)", + async (status, { 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", signal); + await expect(pending).rejects.toThrow(/Development OIDC/); + expect(cancel).toHaveBeenCalledOnce(); + } + ); + 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", + 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", signal) + ).rejects.toThrow("invalid development OIDC response"); + await expect( + transport.createSession(auth, "linked-worker", signal) + ).rejects.toThrow("could not reach Cloudflare"); + }); + 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", + 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", + 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, + 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://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..b7997a1c2d7 --- /dev/null +++ b/packages/remote-bindings/src/development-oidc-api.ts @@ -0,0 +1,339 @@ +import { Buffer } from "node:buffer"; +import { TextDecoder } from "node:util"; +import { performApiFetchBase } from "@cloudflare/workers-utils"; +import { validateDevelopmentOidcAudience } from "miniflare"; +import { version } from "../package.json"; +import type { + DevelopmentOidcSession, + DevelopmentOidcTransport, +} from "./development-oidc"; +import type { + CfAccount, + ComplianceConfig, + Logger, +} from "@cloudflare/workers-utils"; +import type { Response } from "undici"; + +/** Sanitized issuance errors that are safe to show in the host dev server. */ +export class DevelopmentOidcApiError extends Error {} + +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 { + validateDevelopmentOidcAudience(audience); + if (audience.includes(":")) { + // StringOrURI requires a URI scheme when a colon occurs. Preserve the original string. + if ( + !/^[A-Za-z][A-Za-z0-9+.-]*:/.test(audience) || + /[^\x21-\x7e]/.test(audience) || + /%(?![A-Fa-f0-9]{2})/.test(audience) + ) { + throw new DevelopmentOidcApiError( + "Development OIDC audiences containing a colon must be valid URIs." + ); + } + try { + new URL(audience); + } catch { + throw new DevelopmentOidcApiError( + "Development OIDC audiences containing a colon must be valid URIs." + ); + } + } +} + +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." + ); + } + if (response.status === 403) { + throw new DevelopmentOidcApiError( + "Development OIDC access denied. Check the selected account, existing Worker, OAuth grant, and Worker edit permission." + ); + } + 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." + ); + } + throw new DevelopmentOidcApiError( + "Development OIDC issuance is unavailable. Check connectivity and service availability, then retry the getter." + ); + } + 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 } = + 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 !== `worker:${workerId}:development:user:${userId}` || + 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 { + throw new DevelopmentOidcApiError( + "Development OIDC could not reach Cloudflare. Check connectivity and retry the getter." + ); + } + return readResult(response); + } + return { + async createSession(auth, workerName, 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`, + {}, + 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-v1" || + data.default_audience !== + `https://workers.cloudflare.com/accounts/${auth.accountId}` || + typeof data.expires_at !== "number" || + !Number.isSafeInteger(data.expires_at) || + data.expires_at < now + 300 || + data.expires_at > now + 28800 + ) { + throw new DevelopmentOidcApiError(INVALID); + } + logger.info( + `Development OIDC subject for ${workerName}: worker:${data.worker_id}:development:user:${data.user_id} (local execution)` + ); + return { + identity: { + accountId: auth.accountId, + workerId: data.worker_id, + userId: data.user_id, + developmentSessionId: data.development_session_id, + policy: data.policy, + }, + defaultAudience: data.default_audience, + expiresAt: data.expires_at, + }; + }, + async issueToken(auth, session, audience, 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 }, + signal + ), + session, + audience + ); + }, + }; +} 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..391acf2a4a0 --- /dev/null +++ b/packages/remote-bindings/src/development-oidc-options.ts @@ -0,0 +1,98 @@ +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" + >, + logger: RemoteBindingsLogger, + profileDir = process.cwd() +): DevelopmentOidcBrokerFactory | undefined { + if (!config.compatibility_flags.includes("workers_oidc")) { + return undefined; + } + const snapshot = { + name: config.name ?? "", + 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.account_id, + snapshot.compliance_region, + directory, + useCfAuth, + profile, + ]), + create() { + let readAuth: ReturnType | undefined; + const provider = 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) + ); + return { + async getToken(options) { + try { + return await provider.getToken(options); + } 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() { + provider.dispose(); + }, + }; + }, + }; +} diff --git a/packages/remote-bindings/src/development-oidc.test.ts b/packages/remote-bindings/src/development-oidc.test.ts index 4520718f1e8..094af30b89f 100644 --- a/packages/remote-bindings/src/development-oidc.test.ts +++ b/packages/remote-bindings/src/development-oidc.test.ts @@ -15,6 +15,7 @@ const session: DevelopmentOidcSession = { policy: "policy", }, defaultAudience: "https://workers.cloudflare.com/accounts/account", + expiresAt: 1800000000 + 28800, }; function token(value = "development-token"): DevelopmentOidcToken { @@ -239,6 +240,7 @@ describe("development OIDC provider security boundaries", () => { const response = { identity: { ...session.identity }, defaultAudience: session.defaultAudience, + expiresAt: session.expiresAt, }; getAuth.mockResolvedValue(auth); createSession.mockResolvedValue(response); @@ -418,3 +420,20 @@ it("rejects line terminators in account metadata before transport", async ({ 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 index 26d3c389490..4e2efec7898 100644 --- a/packages/remote-bindings/src/development-oidc.ts +++ b/packages/remote-bindings/src/development-oidc.ts @@ -11,6 +11,8 @@ import type { DevelopmentOidcIdentity, DevelopmentOidcToken } from "miniflare"; export interface DevelopmentOidcSession { identity: DevelopmentOidcIdentity; defaultAudience: string; + /** Server session expiry in Unix seconds. */ + expiresAt: number; } /** @@ -42,6 +44,8 @@ interface SessionState { oauthToken: string; controller: AbortController; session: Promise; + monotonicExpiry?: number; + expiresAt?: number; } /** @@ -85,7 +89,7 @@ export class DevelopmentOidcProvider { this.#checkActive(); if ( typeof this.#workerName !== "string" || - this.#workerName.length > 255 || + this.#workerName.length > 63 || !this.#workerName.trim() ) { throw new Error( @@ -116,7 +120,10 @@ export class DevelopmentOidcProvider { let state = this.#state; if ( state?.accountId !== auth.accountId || - state.oauthToken !== auth.apiToken.apiToken + state.oauthToken !== auth.apiToken.apiToken || + (state.expiresAt !== undefined && + (state.expiresAt * 1000 < Date.now() + 300000 || + (state.monotonicExpiry ?? 0) < performance.now() + 300000)) ) { this.#invalidate(); const controller = new AbortController(); @@ -141,7 +148,22 @@ export class DevelopmentOidcProvider { "Development OIDC returned a session for a different account." ); } - return Object.freeze({ identity, defaultAudience }); + 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 }); }, controller.signal), }; this.#state = state; diff --git a/packages/remote-bindings/src/index.ts b/packages/remote-bindings/src/index.ts index 1217f998c0c..22e82ae6505 100644 --- a/packages/remote-bindings/src/index.ts +++ b/packages/remote-bindings/src/index.ts @@ -9,6 +9,8 @@ export type { } 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, 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..92564335d3e --- /dev/null +++ b/packages/vite-plugin-cloudflare/src/__tests__/development-oidc.spec.ts @@ -0,0 +1,59 @@ +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"], + }) + ); + 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..801501b1b5f --- /dev/null +++ b/packages/vitest-plugin/test/development-oidc.test.ts @@ -0,0 +1,56 @@ +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"], + }) + ); + 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/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; From 329bd73608a265063b86206bedb8203c09fd96ee Mon Sep 17 00:00:00 2001 From: Brendan Irvine-Broque Date: Fri, 23 Oct 2026 00:32:08 -0700 Subject: [PATCH 4/7] [miniflare] Preserve development OIDC sessions after temporary errors --- .changeset/development-oidc-native.md | 2 + .../miniflare/src/shared/development-oidc.ts | 11 +- .../test/shared/development-oidc.spec.ts | 9 +- .../src/development-oidc-api.test.ts | 67 +++++++++- .../src/development-oidc-api.ts | 41 +++++-- .../src/development-oidc-errors.ts | 16 +++ .../src/development-oidc.test.ts | 116 +++++++++++++++++- .../remote-bindings/src/development-oidc.ts | 54 +++++++- 8 files changed, 290 insertions(+), 26 deletions(-) create mode 100644 packages/remote-bindings/src/development-oidc-errors.ts diff --git a/.changeset/development-oidc-native.md b/.changeset/development-oidc-native.md index f3bac3a505d..09e37180876 100644 --- a/.changeset/development-oidc-native.md +++ b/.changeset/development-oidc-native.md @@ -9,4 +9,6 @@ Connect authenticated local Workers OIDC to the native token getter Share lazy per-Worker brokers, authenticated development sessions, bounded API responses, token profile validation, caching 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 fresh requests, and clear sessions and cached tokens 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/packages/miniflare/src/shared/development-oidc.ts b/packages/miniflare/src/shared/development-oidc.ts index c3ae543712f..9f2fd88d8ec 100644 --- a/packages/miniflare/src/shared/development-oidc.ts +++ b/packages/miniflare/src/shared/development-oidc.ts @@ -27,6 +27,13 @@ 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 @@ -54,9 +61,7 @@ export class DevelopmentOidcRequestPool { const controller = new AbortController(); const signal = AbortSignal.any([parent, controller.signal]); const timer = setTimeout(() => { - controller.abort( - new Error("The development OIDC request timed out. Retry the getter.") - ); + controller.abort(new DevelopmentOidcRequestTimeoutError()); }, REQUEST_TIMEOUT_MS); timer.unref(); const result = Promise.resolve().then(() => { diff --git a/packages/miniflare/test/shared/development-oidc.spec.ts b/packages/miniflare/test/shared/development-oidc.spec.ts index 19a98352b9e..4c182b32554 100644 --- a/packages/miniflare/test/shared/development-oidc.spec.ts +++ b/packages/miniflare/test/shared/development-oidc.spec.ts @@ -1,5 +1,8 @@ import { afterEach, beforeEach, describe, it, vi } from "vitest"; -import { DevelopmentOidcTokenCache } from "../../src/shared/development-oidc"; +import { + DevelopmentOidcRequestTimeoutError, + DevelopmentOidcTokenCache, +} from "../../src/shared/development-oidc"; import type { DevelopmentOidcIdentity, DevelopmentOidcToken, @@ -323,7 +326,9 @@ describe("development OIDC resource and cancellation boundaries", () => { fail = reject; }); }); - const rejected = expect(pending).rejects.toThrow("timed out"); + const rejected = expect(pending).rejects.toBeInstanceOf( + DevelopmentOidcRequestTimeoutError + ); await vi.advanceTimersByTimeAsync(30_000); await rejected; expect(signal?.aborted).toBe(true); diff --git a/packages/remote-bindings/src/development-oidc-api.test.ts b/packages/remote-bindings/src/development-oidc-api.test.ts index bd3642372c4..b6c857983c5 100644 --- a/packages/remote-bindings/src/development-oidc-api.test.ts +++ b/packages/remote-bindings/src/development-oidc-api.test.ts @@ -4,6 +4,7 @@ import { Response } from "undici"; import { afterEach, beforeEach, describe, it, vi } from "vitest"; import { createDevelopmentOidcApiTransport, + DevelopmentOidcApiError, validateDevelopmentOidcApiAudience, validateDevelopmentOidcApiToken, } from "./development-oidc-api"; @@ -115,9 +116,18 @@ describe("development issuance API transport", () => { expect(calls[1]?.[7]).toEqual(auth.apiToken); expect(calls[1]?.[6]).toBe(signal); }); - it.for([401, 403, 404, 429, 503])( - "suppresses API error bodies and cancels them (%i)", - async (status, { expect }) => { + 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( @@ -127,10 +137,38 @@ describe("development issuance API transport", () => { {}, logger ).createSession(auth, "linked-worker", signal); - await expect(pending).rejects.toThrow(/Development OIDC/); + 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, + signal + ) + ).rejects.toMatchObject({ kind: "rate_limited", retryAfterMs: delay }); + } + ); it("cancels oversized streaming bodies before JSON decoding", async ({ expect, }) => { @@ -171,7 +209,26 @@ describe("development issuance API transport", () => { ).rejects.toThrow("invalid development OIDC response"); await expect( transport.createSession(auth, "linked-worker", signal) - ).rejects.toThrow("could not reach Cloudflare"); + ).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", + controller.signal + ) + ).rejects.toBe(reason); + expect(reason).not.toBeInstanceOf(DevelopmentOidcApiError); }); it("rejects invalid UTF-8 even in otherwise unused response fields", async ({ expect, diff --git a/packages/remote-bindings/src/development-oidc-api.ts b/packages/remote-bindings/src/development-oidc-api.ts index b7997a1c2d7..388ec164f82 100644 --- a/packages/remote-bindings/src/development-oidc-api.ts +++ b/packages/remote-bindings/src/development-oidc-api.ts @@ -3,6 +3,7 @@ import { TextDecoder } from "node:util"; import { performApiFetchBase } from "@cloudflare/workers-utils"; import { validateDevelopmentOidcAudience } from "miniflare"; import { version } from "../package.json"; +import { DevelopmentOidcApiError } from "./development-oidc-errors"; import type { DevelopmentOidcSession, DevelopmentOidcTransport, @@ -14,8 +15,7 @@ import type { } from "@cloudflare/workers-utils"; import type { Response } from "undici"; -/** Sanitized issuance errors that are safe to show in the host dev server. */ -export class DevelopmentOidcApiError extends Error {} +export { DevelopmentOidcApiError } from "./development-oidc-errors"; const PUBLIC_ID = /^[a-f0-9]{32}$/; const SESSION_ID = @@ -54,18 +54,31 @@ export function validateDevelopmentOidcApiAudience(audience: string): void { } } +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." + "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, and Worker edit permission." + "Development OIDC access denied. Check the selected account, existing Worker, OAuth grant, and Worker edit permission.", + "access_denied" ); } if (response.status === 404) { @@ -75,11 +88,23 @@ async function readResult(response: Response): Promise { } if (response.status === 429) { throw new DevelopmentOidcApiError( - "Development OIDC is rate limited. Retry the getter later." + "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." + "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 ( @@ -254,8 +279,10 @@ export function createDevelopmentOidcApiTransport( auth.apiToken ); } catch { + signal.throwIfAborted(); throw new DevelopmentOidcApiError( - "Development OIDC could not reach Cloudflare. Check connectivity and retry the getter." + "Development OIDC could not reach Cloudflare. Check connectivity and retry the getter.", + "unavailable" ); } return readResult(response); 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.test.ts b/packages/remote-bindings/src/development-oidc.test.ts index 094af30b89f..10381a75ab9 100644 --- a/packages/remote-bindings/src/development-oidc.test.ts +++ b/packages/remote-bindings/src/development-oidc.test.ts @@ -2,6 +2,7 @@ import { inspect } from "node:util"; import { DevelopmentOidcTokenCache } from "miniflare"; import { afterEach, beforeEach, describe, it, vi } from "vitest"; import { DevelopmentOidcProvider } from "./development-oidc"; +import { DevelopmentOidcApiError } from "./development-oidc-errors"; import type { DevelopmentOidcSession } from "./development-oidc"; import type { CfAccount } from "@cloudflare/workers-utils"; import type { DevelopmentOidcToken } from "miniflare"; @@ -158,17 +159,122 @@ describe("development OIDC provider", () => { expect(await provider.getToken()).toBe("development-token"); }); - it("reports fresh issuance failures without stale-token fallback", async ({ + 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]?.[2].aborted).toBe(false); + } + ); + + it("retains the session after the issuance deadline, then retries on demand", async ({ expect, }) => { - const { provider, issueToken } = setup(); + const { provider, createSession, issueToken } = setup(); await provider.getToken(); vi.advanceTimersByTime(241_000); - issueToken.mockRejectedValueOnce(new Error("Cloudflare is unreachable")); - await expect(provider.getToken()).rejects.toThrow( - "Cloudflare is unreachable" + 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]?.[3].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]?.[2].aborted).toBe(true); + expect(await provider.getToken()).toBe("development-token"); + expect(createSession).toHaveBeenCalledTimes(2); + expect(issueToken).toHaveBeenCalledTimes(3); + } + ); + + it("honors Retry-After across audiences without delaying cached reads or 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 + ); + expect(await provider.getToken()).toBe("development-token"); + 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 ({ diff --git a/packages/remote-bindings/src/development-oidc.ts b/packages/remote-bindings/src/development-oidc.ts index 4e2efec7898..da3ea2e7aba 100644 --- a/packages/remote-bindings/src/development-oidc.ts +++ b/packages/remote-bindings/src/development-oidc.ts @@ -1,9 +1,11 @@ import { DevelopmentOidcRequestPool, + DevelopmentOidcRequestTimeoutError, DevelopmentOidcTokenCache, snapshotDevelopmentOidcIdentity, validateDevelopmentOidcAudience, } from "miniflare"; +import { DevelopmentOidcApiError } from "./development-oidc-errors"; import type { CfAccount } from "@cloudflare/workers-utils"; import type { DevelopmentOidcIdentity, DevelopmentOidcToken } from "miniflare"; @@ -63,6 +65,12 @@ export class DevelopmentOidcProvider { #shutdown = new AbortController(); #state?: SessionState; #authPromise?: Promise; + #retryAfter?: { + accountId: string; + oauthToken: string; + deadline: number; + error: DevelopmentOidcApiError; + }; #callers = 0; constructor( @@ -125,6 +133,7 @@ export class DevelopmentOidcProvider { (state.expiresAt * 1000 < Date.now() + 300000 || (state.monotonicExpiry ?? 0) < performance.now() + 300000)) ) { + this.#checkRetryAfter(auth); this.#invalidate(); const controller = new AbortController(); state = { @@ -168,27 +177,52 @@ export class DevelopmentOidcProvider { }; 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.#cache.getToken( session.identity, audience, - (signal) => - this.#transport.issueToken( + (signal) => { + this.#checkRetryAfter(auth); + return this.#transport.issueToken( auth, session, audience, AbortSignal.any([signal, state.controller.signal]) - ) + ); + } ); // Invalidation can occur while awaiting even an already-cached token. this.#checkCurrent(state); return token; } catch (error) { if (this.#state === state) { - this.#invalidate(); + 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; } @@ -264,6 +298,7 @@ export class DevelopmentOidcProvider { #invalidate(): void { const state = this.#state; this.#state = undefined; + this.#retryAfter = undefined; this.#cache.clear(); state?.controller.abort( new Error( @@ -272,6 +307,17 @@ export class DevelopmentOidcProvider { ); } + #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(); From 8a3c7a68c549b39ac1afeb5ccbd54030434ee802 Mon Sep 17 00:00:00 2001 From: Brendan Irvine-Broque Date: Fri, 23 Oct 2026 16:37:43 -0700 Subject: [PATCH 5/7] Support approved OIDC identity bindings across local development --- .changeset/development-oidc-native.md | 4 +- .changeset/development-oidc-primitives.md | 4 +- .changeset/workload-identity-bindings.md | 17 ++ packages/config/package.json | 2 +- packages/config/src/__tests__/convert.test.ts | 33 ++++ packages/config/src/__tests__/schema.test.ts | 57 +++++++ packages/config/src/bindings.ts | 12 ++ packages/config/src/convert.ts | 11 ++ packages/config/src/inference.ts | 3 + packages/config/src/public.ts | 2 + packages/config/src/schema.ts | 20 +++ packages/config/src/types.ts | 2 + .../src/deploy/helpers/config-diffs.ts | 1 + .../helpers/create-worker-upload-form.ts | 14 ++ packages/deploy-helpers/src/preview/api.ts | 3 + packages/deploy-helpers/src/preview/shared.ts | 3 + .../deploy-helpers/src/startup-profile.ts | 8 + .../scripts/generate-workerd-config.mjs | 12 ++ packages/miniflare/src/config/v4-convert.ts | 3 + packages/miniflare/src/config/v4-schema.ts | 16 ++ packages/miniflare/src/index.ts | 16 +- packages/miniflare/src/plugins/core/index.ts | 3 + .../src/runtime/config/generated/workerd.ts | 21 +++ .../miniflare/src/runtime/config/workerd.ts | 1 + .../src/shared/development-oidc-broker.ts | 8 +- .../miniflare/src/shared/development-oidc.ts | 150 +++++------------- .../miniflare/src/workers/core/oidc.worker.ts | 2 +- .../plugins/core/development-oidc.spec.ts | 78 +++++++-- .../shared/development-oidc-broker.spec.ts | 20 ++- .../test/shared/development-oidc.spec.ts | 118 +++++++------- .../src/development-oidc-api.test.ts | 46 ++++-- .../src/development-oidc-api.ts | 44 +++-- .../src/development-oidc-options.ts | 79 +++++---- .../src/development-oidc.test.ts | 101 +++++++++--- .../remote-bindings/src/development-oidc.ts | 82 ++++++++-- .../src/__tests__/development-oidc.spec.ts | 3 + .../test/development-oidc.test.ts | 7 + packages/workers-utils/package.json | 4 + packages/workers-utils/src/binding-utils.ts | 6 + .../src/config/binding-local-support.ts | 1 + packages/workers-utils/src/config/config.ts | 1 + .../workers-utils/src/config/environment.ts | 11 ++ packages/workers-utils/src/config/oidc.ts | 63 ++++++++ .../workers-utils/src/config/validation.ts | 34 ++++ packages/workers-utils/src/index.ts | 1 + .../src/map-worker-metadata-bindings.ts | 5 + packages/workers-utils/src/oidc.ts | 1 + packages/workers-utils/src/print-bindings.ts | 11 ++ packages/workers-utils/src/types.ts | 13 ++ .../normalize-and-validate-config.test.ts | 1 + packages/workers-utils/tsup.config.ts | 1 + packages/wrangler/src/dev/miniflare/index.ts | 6 + .../wrangler/src/preview/preview-config.ts | 14 ++ .../wrangler/src/type-generation/index.ts | 17 ++ pnpm-lock.yaml | 6 +- 55 files changed, 928 insertions(+), 274 deletions(-) create mode 100644 .changeset/workload-identity-bindings.md create mode 100644 packages/workers-utils/src/config/oidc.ts create mode 100644 packages/workers-utils/src/oidc.ts diff --git a/.changeset/development-oidc-native.md b/.changeset/development-oidc-native.md index 09e37180876..3eb592def36 100644 --- a/.changeset/development-oidc-native.md +++ b/.changeset/development-oidc-native.md @@ -7,8 +7,8 @@ Connect authenticated local Workers OIDC to the native token getter -Share lazy per-Worker brokers, authenticated development sessions, bounded API responses, token profile validation, caching 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. +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 fresh requests, and clear sessions and cached tokens after login, permission, renewal, or invalid-response failures. +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 index ddf0d41ca24..757306a782b 100644 --- a/.changeset/development-oidc-primitives.md +++ b/.changeset/development-oidc-primitives.md @@ -5,6 +5,6 @@ "@cloudflare/vite-plugin": patch --- -Add shared host-side development OIDC session and token-cache 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. +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 cache 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. +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/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/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/scripts/generate-workerd-config.mjs b/packages/miniflare/scripts/generate-workerd-config.mjs index f285617924a..5a650403c79 100644 --- a/packages/miniflare/scripts/generate-workerd-config.mjs +++ b/packages/miniflare/scripts/generate-workerd-config.mjs @@ -23,6 +23,18 @@ if (!schema.includes("oidcTokenService @21")) { ${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"); diff --git a/packages/miniflare/src/config/v4-convert.ts b/packages/miniflare/src/config/v4-convert.ts index aa3c10814c8..b0c1b328407 100644 --- a/packages/miniflare/src/config/v4-convert.ts +++ b/packages/miniflare/src/config/v4-convert.ts @@ -711,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 a4b15234ba0..4fb69a24ee8 100644 --- a/packages/miniflare/src/config/v4-schema.ts +++ b/packages/miniflare/src/config/v4-schema.ts @@ -369,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 @@ -819,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; diff --git a/packages/miniflare/src/index.ts b/packages/miniflare/src/index.ts index 62387c5f0a3..e0908a48c5d 100644 --- a/packages/miniflare/src/index.ts +++ b/packages/miniflare/src/index.ts @@ -1665,12 +1665,24 @@ export class Miniflare { options === null || typeof options !== "object" || Array.isArray(options) || - Object.keys(options).some((key) => key !== "audience") || + 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, options); + 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", diff --git a/packages/miniflare/src/plugins/core/index.ts b/packages/miniflare/src/plugins/core/index.ts index b608b26b004..c1e96060779 100644 --- a/packages/miniflare/src/plugins/core/index.ts +++ b/packages/miniflare/src/plugins/core/index.ts @@ -370,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) }); diff --git a/packages/miniflare/src/runtime/config/generated/workerd.ts b/packages/miniflare/src/runtime/config/generated/workerd.ts index 63ef3aa316a..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; } diff --git a/packages/miniflare/src/runtime/config/workerd.ts b/packages/miniflare/src/runtime/config/workerd.ts index 0ebca51b1a0..0d129809aaa 100644 --- a/packages/miniflare/src/runtime/config/workerd.ts +++ b/packages/miniflare/src/runtime/config/workerd.ts @@ -146,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 index 989598c4c35..6e78f152c3b 100644 --- a/packages/miniflare/src/shared/development-oidc-broker.ts +++ b/packages/miniflare/src/shared/development-oidc-broker.ts @@ -6,7 +6,11 @@ import { /** Host-side broker. Never place it or its credentials in Worker bindings. */ export interface DevelopmentOidcBroker { - getToken(options: { audience?: string }): Promise; + getToken(options: { + assignment: string; + audience?: string; + fresh?: boolean; + }): Promise; dispose(): void; } @@ -88,7 +92,7 @@ export class DevelopmentOidcBrokers { /** Handle a capability-authorized callback. Body limits must be applied before decoding. */ async getToken( capability: string, - options: { audience?: string } + options: { assignment: string; audience?: string; fresh?: boolean } ): Promise { const entry = this.#find(capability); if (!entry) { diff --git a/packages/miniflare/src/shared/development-oidc.ts b/packages/miniflare/src/shared/development-oidc.ts index 9f2fd88d8ec..6efbf3e0fb9 100644 --- a/packages/miniflare/src/shared/development-oidc.ts +++ b/packages/miniflare/src/shared/development-oidc.ts @@ -8,6 +8,9 @@ export interface DevelopmentOidcIdentity { userId: string; developmentSessionId: string; policy: string; + identityId: string; + assignmentId: string; + policyRevision: string; } /** A token returned by the trusted development issuance transport. */ @@ -139,6 +142,9 @@ export function snapshotDevelopmentOidcIdentity( 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( @@ -152,32 +158,11 @@ interface CachedToken extends DevelopmentOidcToken { monotonicExpiry: number; } -interface Entry { - controller: AbortController; - result: Promise>; - token?: Readonly; -} - -/** - * Bounded, in-memory development token cache shared by dev-server consumers. - * Only the trusted host transport may supply identity and policy values. It - * must authorize every fresh issuance and validate the returned token profile. - * No timer renews tokens: renewal happens when the getter is called. - */ -export class DevelopmentOidcTokenCache { - #entries = new Map(); +/** Bound and validate each issuance. Only the server caches assertions, after current authorization. */ +export class DevelopmentOidcTokenRequests { #requests = new DevelopmentOidcRequestPool(); - #waiters = 0; + #controller = new AbortController(); #disposed = false; - - /** - * Return a token with at least one minute remaining, coalescing fresh issuance. - * - * @param identity Server-authorized account, Worker, user, session, and policy. - * @param audience Effective audience, including the resolved account default. - * @param issue Host callback used when no reusable token exists. - * @returns The bearer token. Never log or persist the returned value. - */ async getToken( identity: DevelopmentOidcIdentity, audience: string, @@ -186,100 +171,47 @@ export class DevelopmentOidcTokenCache { if (this.#disposed) { throw new Error("The development OIDC session has been disposed."); } + snapshotDevelopmentOidcIdentity(identity); validateDevelopmentOidcAudience(audience); - const key = JSON.stringify([ - ...Object.values(snapshotDevelopmentOidcIdentity(identity)), - audience, - ]); - let entry = this.#entries.get(key); - if (entry?.token) { - if (isReusable(entry.token)) { - this.#entries.delete(key); - this.#entries.set(key, entry); - return entry.token.token; - } - this.#entries.delete(key); - entry = undefined; - } - if (this.#waiters >= MAXIMUM_ENTRIES) { - throw new Error("Too many concurrent development OIDC requests."); - } - if (entry === undefined) { - if (this.#entries.size >= MAXIMUM_ENTRIES) { - const candidate = [...this.#entries].find(([, value]) => value.token); - if (candidate === undefined) { - throw new Error("Too many concurrent development OIDC requests."); - } - this.#entries.delete(candidate[0]); - } - const controller = new AbortController(); - entry = { - controller, - result: this.#requests.run(async (signal) => { - const response = await issue(signal); - signal.throwIfAborted(); - const token = response?.token; - const expiresAt = response?.expiresAt; - const remaining = - typeof expiresAt === "number" ? expiresAt * 1000 - Date.now() : NaN; - if ( - !isBoundedString(token, 65_536) || - !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." - ); - } - // Retain only this snapshot, never the mutable transport response. - return Object.freeze({ - token, - expiresAt, - monotonicExpiry: performance.now() + remaining, - }); - }, controller.signal), - }; - this.#entries.set(key, entry); - } else { - this.#entries.delete(key); - this.#entries.set(key, entry); - } - this.#waiters++; - try { - const token = await entry.result; - entry.controller.signal.throwIfAborted(); - if (!isReusable(token)) { + 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." ); } - entry.token = token; - return token.token; - } catch (error) { - if (this.#entries.get(key) === entry) { - this.#entries.delete(key); - } - throw error; - } finally { - this.#waiters--; + 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; } - - /** Invalidate tokens and cancel pending issuance after an auth/session change. */ clear(): void { - const entries = this.#entries; - this.#entries = new Map(); - for (const entry of entries.values()) { - entry.controller.abort( - new Error( - "The development OIDC session changed during issuance. Retry the getter." - ) - ); - } + this.#controller.abort( + new Error( + "The development OIDC session changed during issuance. Retry the getter." + ) + ); + this.#controller = new AbortController(); } - - /** Cancel pending issuance and prevent reuse after the dev server stops. */ dispose(): void { this.#disposed = true; this.clear(); diff --git a/packages/miniflare/src/workers/core/oidc.worker.ts b/packages/miniflare/src/workers/core/oidc.worker.ts index 2db9076b0c1..e3180c1359c 100644 --- a/packages/miniflare/src/workers/core/oidc.worker.ts +++ b/packages/miniflare/src/workers/core/oidc.worker.ts @@ -5,7 +5,7 @@ interface Env { 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 audience. + // 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", diff --git a/packages/miniflare/test/plugins/core/development-oidc.spec.ts b/packages/miniflare/test/plugins/core/development-oidc.spec.ts index 3350606b339..4765024fb86 100644 --- a/packages/miniflare/test/plugins/core/development-oidc.spec.ts +++ b/packages/miniflare/test/plugins/core/development-oidc.spec.ts @@ -6,12 +6,18 @@ import { 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(` @@ -19,7 +25,7 @@ function worker(developmentOidc?: DevelopmentOidcBrokerFactory): WorkerOptions { 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 ctx.oidc.getToken(audience === null ? undefined : {audience})); + return new Response(await env.IDENTITY.getToken(audience === null ? undefined : {audience})); } catch (error) { return new Response(error.message, {status: 500}); } } }; `), @@ -33,6 +39,51 @@ function worker(developmentOidc?: DevelopmentOidcBrokerFactory): WorkerOptions { 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, }) => { @@ -49,17 +100,18 @@ describe.skipIf(!process.env.MINIFLARE_WORKERD_PATH)( manifest: singleModuleManifest(` import { DurableObject } from "cloudflare:workers"; export class LocalObject extends DurableObject { - async token() {return this.ctx.oidc.getToken();} - async explicit() {return this.ctx.oidc.getToken({subject: "object"});} + 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 ctx.oidc.getToken(), one: await one.token(), two: await two.token(), target: await env.OTHER.token(), explicitRejected}); + 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", @@ -85,11 +137,12 @@ describe.skipIf(!process.env.MINIFLARE_WORKERD_PATH)( { 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.ctx.oidc.getToken();}} + export class Issuer extends WorkerEntrypoint {async token() {return this.env.IDENTITY.getToken();}} export default {fetch() {return new Response("second");}}; `), }, @@ -116,7 +169,7 @@ describe.skipIf(!process.env.MINIFLARE_WORKERD_PATH)( expect(otherGetToken).toHaveBeenCalledOnce(); }); - test("routes default/explicit audiences without exposing a binding; retains reloads and disposes changes", async ({ + test("routes default/explicit audiences through explicit bindings; retains reloads and disposes changes", async ({ expect, }) => { const getToken = vi.fn(async () => token), @@ -130,16 +183,23 @@ describe.skipIf(!process.env.MINIFLARE_WORKERD_PATH)( expect(await (await mf.dispatchFetch("http://localhost/")).text()).toBe( token ); - expect(getToken).toHaveBeenLastCalledWith({}); + 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({ audience: 'exact"雪' }); + expect(getToken).toHaveBeenLastCalledWith({ + assignment: "IDENTITY", + audience: 'exact"雪', + fresh: false, + }); expect( await (await mf.dispatchFetch("http://localhost/env")).json() - ).toEqual([]); + ).toEqual(["IDENTITY"]); await mf.setOptions({ workers: [worker({ ...factory })] }); expect(await (await mf.dispatchFetch("http://localhost/")).text()).toBe( token diff --git a/packages/miniflare/test/shared/development-oidc-broker.spec.ts b/packages/miniflare/test/shared/development-oidc-broker.spec.ts index 02370c17243..a0ce32c8456 100644 --- a/packages/miniflare/test/shared/development-oidc-broker.spec.ts +++ b/packages/miniflare/test/shared/development-oidc-broker.spec.ts @@ -23,12 +23,16 @@ describe("development OIDC capability lifecycle", () => { expect(first).not.toBe(second); expect(other.isAuthorized(first)).toBe(false); expect(manager.isAuthorized("é".repeat(64))).toBe(false); - expect(await manager.getToken(first, {})).toBe(token); + 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, {})).rejects.toThrow("unavailable"); + await expect( + manager.getToken(first, { assignment: "IDENTITY" }) + ).rejects.toThrow("unavailable"); manager.dispose(); }); it("cancels pending getters on shutdown and observes late failures", async ({ @@ -49,7 +53,9 @@ describe("development OIDC capability lifecycle", () => { }), }, ]); - const pending = manager.getToken(manager.capability(0) ?? "", {}); + const pending = manager.getToken(manager.capability(0) ?? "", { + assignment: "IDENTITY", + }); await Promise.resolve(); manager.dispose(); await expect(pending).rejects.toThrow("configuration changed"); @@ -71,7 +77,9 @@ describe("development OIDC capability lifecycle", () => { .spyOn(DevelopmentOidcRequestPool.prototype, "run") .mockResolvedValueOnce(token); try { - const pending = manager.getToken(manager.capability(0) ?? "", {}); + const pending = manager.getToken(manager.capability(0) ?? "", { + assignment: "IDENTITY", + }); manager.update([]); await expect(pending).rejects.toThrow("configuration changed"); } finally { @@ -90,7 +98,9 @@ describe("development OIDC capability lifecycle", () => { }, ]); await expect( - manager.getToken(manager.capability(0) ?? "", {}) + 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 index 4c182b32554..c182041b743 100644 --- a/packages/miniflare/test/shared/development-oidc.spec.ts +++ b/packages/miniflare/test/shared/development-oidc.spec.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, it, vi } from "vitest"; import { DevelopmentOidcRequestTimeoutError, - DevelopmentOidcTokenCache, + DevelopmentOidcTokenRequests, } from "../../src/shared/development-oidc"; import type { DevelopmentOidcIdentity, @@ -14,6 +14,9 @@ const identity: DevelopmentOidcIdentity = { userId: "user", developmentSessionId: "session", policy: "policy", + identityId: "orders", + assignmentId: "local-orders", + policyRevision: "1:1", }; const audience = "https://example.com"; @@ -27,11 +30,11 @@ beforeEach(() => { }); afterEach(() => vi.useRealTimers()); -describe("development OIDC token cache", () => { +describe("development OIDC token requests", () => { it.for(Object.keys(identity) as (keyof DevelopmentOidcIdentity)[])( "rejects missing %s before issuance", async (field, { expect }) => { - const cache = new DevelopmentOidcTokenCache(); + const cache = new DevelopmentOidcTokenRequests(); const issue = vi.fn(async () => issued()); await expect( cache.getToken({ ...identity, [field]: "" }, audience, issue) @@ -40,10 +43,10 @@ describe("development OIDC token cache", () => { } ); - it("coalesces concurrent requests and performs no idle renewal", async ({ + it("authorizes concurrent requests independently and performs no idle renewal", async ({ expect, }) => { - const cache = new DevelopmentOidcTokenCache(); + const cache = new DevelopmentOidcTokenRequests(); const issue = vi.fn(async () => issued()); expect( await Promise.all( @@ -52,33 +55,37 @@ describe("development OIDC token cache", () => { ) ) ).toEqual(Array(20).fill("development-token")); - expect(issue).toHaveBeenCalledOnce(); + expect(issue).toHaveBeenCalledTimes(20); await vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000); - expect(issue).toHaveBeenCalledOnce(); + expect(issue).toHaveBeenCalledTimes(20); expect(await cache.getToken(identity, audience, issue)).toBe( "development-token" ); - expect(issue).toHaveBeenCalledTimes(2); + expect(issue).toHaveBeenCalledTimes(21); }); - it("reuses at exactly 60 seconds remaining and renews below it", async ({ + it("accepts exactly 60 seconds remaining and rejects less", async ({ expect, }) => { - const cache = new DevelopmentOidcTokenCache(); - const issue = vi.fn(async () => issued()); - await cache.getToken(identity, audience, issue); - vi.advanceTimersByTime(240_000); - await cache.getToken(identity, audience, issue); - expect(issue).toHaveBeenCalledOnce(); - vi.advanceTimersByTime(1); - await cache.getToken(identity, audience, issue); - expect(issue).toHaveBeenCalledTimes(2); + 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 DevelopmentOidcTokenCache(); + const cache = new DevelopmentOidcTokenRequests(); const issue = vi.fn(async () => issued()); await cache.getToken(identity, audience, issue); vi.advanceTimersByTime(241_000); @@ -90,7 +97,7 @@ describe("development OIDC token cache", () => { it.for(Object.keys(identity) as (keyof DevelopmentOidcIdentity)[])( "separates %s", async (field, { expect }) => { - const cache = new DevelopmentOidcTokenCache(); + const cache = new DevelopmentOidcTokenRequests(); const issue = vi.fn(async () => issued()); await cache.getToken(identity, audience, issue); await cache.getToken({ ...identity, [field]: "other" }, audience, issue); @@ -101,7 +108,7 @@ describe("development OIDC token cache", () => { it("separates audiences and keys containing separators", async ({ expect, }) => { - const cache = new DevelopmentOidcTokenCache(); + const cache = new DevelopmentOidcTokenRequests(); const issue = vi.fn(async () => issued()); await cache.getToken(identity, audience, issue); await cache.getToken(identity, audience + "/other", issue); @@ -121,7 +128,7 @@ describe("development OIDC token cache", () => { it("propagates renewal failure without returning the old token, then recovers", async ({ expect, }) => { - const cache = new DevelopmentOidcTokenCache(); + const cache = new DevelopmentOidcTokenRequests(); const issue = vi .fn(async () => issued()) .mockResolvedValueOnce(issued("old")); @@ -139,7 +146,7 @@ describe("development OIDC token cache", () => { 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 DevelopmentOidcTokenCache(); + const cache = new DevelopmentOidcTokenRequests(); await expect( cache.getToken(identity, audience, async () => ({ token: "invalid", @@ -152,7 +159,7 @@ describe("development OIDC token cache", () => { it.for(["", "x".repeat(65_537)])( "rejects invalid token length (%#)", async (token, { expect }) => { - const cache = new DevelopmentOidcTokenCache(); + const cache = new DevelopmentOidcTokenRequests(); await expect( cache.getToken(identity, audience, async () => issued(token)) ).rejects.toThrow("invalid token or lifetime"); @@ -160,7 +167,7 @@ describe("development OIDC token cache", () => { ); it("checks lifetime again after awaiting issuance", async ({ expect }) => { - const cache = new DevelopmentOidcTokenCache(); + const cache = new DevelopmentOidcTokenRequests(); const token = issued(); await expect( cache.getToken(identity, audience, async () => { @@ -173,7 +180,7 @@ describe("development OIDC token cache", () => { it("clears cached tokens and rejects pending results across session changes", async ({ expect, }) => { - const cache = new DevelopmentOidcTokenCache(); + const cache = new DevelopmentOidcTokenRequests(); let complete: ((value: DevelopmentOidcToken) => void) | undefined; const issue = vi.fn( (signal: AbortSignal) => @@ -195,10 +202,10 @@ describe("development OIDC token cache", () => { expect(fresh).toHaveBeenCalledTimes(2); }); - it("does not delete a replacement entry when an invalidated issuance fails", async ({ + it("keeps new requests independent of an old cancelled request", async ({ expect, }) => { - const cache = new DevelopmentOidcTokenCache(); + const cache = new DevelopmentOidcTokenRequests(); let fail: ((reason: Error) => void) | undefined; const pending = cache.getToken( identity, @@ -215,27 +222,26 @@ describe("development OIDC token cache", () => { fail?.(new Error("old request failed")); await expect(pending).rejects.toThrow("session changed"); await cache.getToken(identity, audience, fresh); - expect(fresh).toHaveBeenCalledOnce(); + expect(fresh).toHaveBeenCalledTimes(2); }); - it("bounds completed entries with LRU eviction", async ({ expect }) => { - const cache = new DevelopmentOidcTokenCache(); + it("rejects revoked authorization even after a successful token", async ({ + expect, + }) => { + const requests = new DevelopmentOidcTokenRequests(); const issue = vi.fn(async () => issued()); - for (let i = 0; i < 128; i++) { - await cache.getToken(identity, String(i), issue); - } - await cache.getToken(identity, "0", issue); - await cache.getToken(identity, "128", issue); - await cache.getToken(identity, "0", issue); - expect(issue).toHaveBeenCalledTimes(129); - await cache.getToken(identity, "1", issue); - expect(issue).toHaveBeenCalledTimes(130); + 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 DevelopmentOidcTokenCache(); + const cache = new DevelopmentOidcTokenRequests(); const signals: AbortSignal[] = []; const completions: ((value: DevelopmentOidcToken) => void)[] = []; const requests = Array.from({ length: 128 }, (_, i) => @@ -267,7 +273,7 @@ describe("development OIDC resource and cancellation boundaries", () => { async (value, { expect }) => { const issue = vi.fn(async () => issued()); await expect( - new DevelopmentOidcTokenCache().getToken(identity, value, issue) + new DevelopmentOidcTokenRequests().getToken(identity, value, issue) ).rejects.toThrow("1–2048 UTF-8 bytes"); expect(issue).not.toHaveBeenCalled(); } @@ -276,7 +282,7 @@ describe("development OIDC resource and cancellation boundaries", () => { it("bounds identity and token bytes, rather than just characters", async ({ expect, }) => { - const cache = new DevelopmentOidcTokenCache(); + const cache = new DevelopmentOidcTokenRequests(); const issue = vi.fn(async () => issued("é".repeat(32_769))); await expect( cache.getToken({ ...identity, policy: "é".repeat(2049) }, audience, issue) @@ -290,7 +296,7 @@ describe("development OIDC resource and cancellation boundaries", () => { it("does not start issuance if disposed before the deferred callback", async ({ expect, }) => { - const cache = new DevelopmentOidcTokenCache(); + 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"); @@ -302,7 +308,7 @@ describe("development OIDC resource and cancellation boundaries", () => { it("rejects immediately on invalidation even when the issuer ignores abort", async ({ expect, }) => { - const cache = new DevelopmentOidcTokenCache(); + const cache = new DevelopmentOidcTokenRequests(); const pending = cache.getToken( identity, audience, @@ -317,7 +323,7 @@ describe("development OIDC resource and cancellation boundaries", () => { it("times out stalled issuance and still observes a late rejection", async ({ expect, }) => { - const cache = new DevelopmentOidcTokenCache(); + const cache = new DevelopmentOidcTokenRequests(); let fail: ((reason: Error) => void) | undefined; let signal: AbortSignal | undefined; const pending = cache.getToken(identity, audience, (value) => { @@ -339,10 +345,8 @@ describe("development OIDC resource and cancellation boundaries", () => { ); }); - it("bounds coalesced waiters as well as distinct issuances", async ({ - expect, - }) => { - const cache = new DevelopmentOidcTokenCache(); + 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) @@ -351,7 +355,7 @@ describe("development OIDC resource and cancellation boundaries", () => { await expect(cache.getToken(identity, audience, issue)).rejects.toThrow( "Too many concurrent" ); - expect(issue).toHaveBeenCalledOnce(); + expect(issue).toHaveBeenCalledTimes(128); cache.dispose(); expect((await settled).every((value) => value.status === "rejected")).toBe( true @@ -361,7 +365,7 @@ describe("development OIDC resource and cancellation boundaries", () => { it("keeps physical issuance bounded across repeated cache clears", async ({ expect, }) => { - const cache = new DevelopmentOidcTokenCache(); + const cache = new DevelopmentOidcTokenRequests(); const completions: ((value: DevelopmentOidcToken) => void)[] = []; const issue = vi.fn( () => @@ -393,7 +397,7 @@ describe("development OIDC resource and cancellation boundaries", () => { it("snapshots an issuance response once for every waiter", async ({ expect, }) => { - const cache = new DevelopmentOidcTokenCache(); + const cache = new DevelopmentOidcTokenRequests(); let reads = 0; const response = { get token() { @@ -407,11 +411,13 @@ describe("development OIDC resource and cancellation boundaries", () => { cache.getToken(identity, audience, async () => response) ) ); - expect(results).toEqual(Array(10).fill("token-1")); - expect(reads).toBe(1); + 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( - "token-1" + "development-token" ); }); }); diff --git a/packages/remote-bindings/src/development-oidc-api.test.ts b/packages/remote-bindings/src/development-oidc-api.test.ts index b6c857983c5..9dbefc03ad0 100644 --- a/packages/remote-bindings/src/development-oidc-api.test.ts +++ b/packages/remote-bindings/src/development-oidc-api.test.ts @@ -22,30 +22,44 @@ const accountId = "a".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-v1", + 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-v1", + policy: "workers-oidc-v2", default_audience: audience, expires_at: session.expiresAt, }; const claims = { iss: `https://oidc.cloudflare.app/accounts/${accountId}`, - sub: `worker:${workerId}:development:user:${userId}`, + sub: "identity:orders", + account_id: accountId, + product: "workers", + jti: "1".repeat(32), aud: audience, execution: "local", worker_id: workerId, @@ -95,24 +109,26 @@ describe("development issuance API transport", () => { ); const transport = createDevelopmentOidcApiTransport({}, logger); expect( - await transport.createSession(auth, "linked-worker", signal) + await transport.createSession(auth, "linked-worker", binding, signal) ).toEqual(session); - expect(await transport.issueToken(auth, session, audience, signal)).toEqual( - { token: token(), expiresAt: now + 300 } - ); + 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: "{}", + 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 })); + expect(calls[1]?.[2]?.body).toBe( + JSON.stringify({ audience, fresh: false }) + ); expect(calls[1]?.[7]).toEqual(auth.apiToken); expect(calls[1]?.[6]).toBe(signal); }); @@ -136,7 +152,7 @@ describe("development issuance API transport", () => { const pending = createDevelopmentOidcApiTransport( {}, logger - ).createSession(auth, "linked-worker", signal); + ).createSession(auth, "linked-worker", binding, signal); await expect(pending).rejects.toMatchObject({ message: expect.stringMatching(/^Development OIDC/), kind, @@ -164,6 +180,7 @@ describe("development issuance API transport", () => { auth, session, audience, + false, signal ) ).rejects.toMatchObject({ kind: "rate_limited", retryAfterMs: delay }); @@ -188,6 +205,7 @@ describe("development issuance API transport", () => { createDevelopmentOidcApiTransport({}, logger).createSession( auth, "linked-worker", + binding, signal ) ).rejects.toThrow("invalid development OIDC response"); @@ -205,10 +223,10 @@ describe("development issuance API transport", () => { ) .mockRejectedValueOnce(new Error("oauth-secret")); await expect( - transport.createSession(auth, "linked-worker", signal) + transport.createSession(auth, "linked-worker", binding, signal) ).rejects.toThrow("invalid development OIDC response"); await expect( - transport.createSession(auth, "linked-worker", signal) + transport.createSession(auth, "linked-worker", binding, signal) ).rejects.toMatchObject({ message: expect.stringContaining("could not reach Cloudflare"), kind: "unavailable", @@ -225,6 +243,7 @@ describe("development issuance API transport", () => { createDevelopmentOidcApiTransport({}, logger).createSession( auth, "linked-worker", + binding, controller.signal ) ).rejects.toBe(reason); @@ -249,6 +268,7 @@ describe("development issuance API transport", () => { createDevelopmentOidcApiTransport({}, logger).createSession( auth, "linked-worker", + binding, signal ) ).rejects.toThrow("invalid development OIDC response"); @@ -272,6 +292,7 @@ describe("development issuance API transport", () => { createDevelopmentOidcApiTransport({}, logger).createSession( auth, "linked-worker", + binding, signal ) ).rejects.toThrow("invalid development OIDC response"); @@ -284,6 +305,7 @@ describe("development issuance API transport", () => { createDevelopmentOidcApiTransport({}, logger).createSession( auth, name, + binding, signal ) ).rejects.toThrow(); diff --git a/packages/remote-bindings/src/development-oidc-api.ts b/packages/remote-bindings/src/development-oidc-api.ts index 388ec164f82..a6164d0b766 100644 --- a/packages/remote-bindings/src/development-oidc-api.ts +++ b/packages/remote-bindings/src/development-oidc-api.ts @@ -77,7 +77,7 @@ async function readResult(response: Response): Promise { } if (response.status === 403) { throw new DevelopmentOidcApiError( - "Development OIDC access denied. Check the selected account, existing Worker, OAuth grant, and Worker edit permission.", + "Development OIDC access denied. Check the selected account, existing Worker, OAuth grant, Worker edit permission, and approved local identity assignment.", "access_denied" ); } @@ -198,7 +198,7 @@ export function validateDevelopmentOidcApiToken( } const header = decode(headerPart), claims = decode(claimsPart); - const { accountId, workerId, userId, developmentSessionId } = + const { accountId, workerId, userId, developmentSessionId, identityId } = session.identity; const now = Date.now() / 1000; const issued = claims.iat, @@ -212,7 +212,11 @@ export function validateDevelopmentOidcApiToken( "crit" in header || "b64" in header || claims.iss !== `https://oidc.cloudflare.app/accounts/${accountId}` || - claims.sub !== `worker:${workerId}:development:user:${userId}` || + 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 || @@ -288,7 +292,7 @@ export function createDevelopmentOidcApiTransport( return readResult(response); } return { - async createSession(auth, workerName, signal) { + async createSession(auth, workerName, binding, signal) { if ( workerName.trim() !== workerName || !/^[A-Za-z0-9_-]{1,63}$/.test(workerName) @@ -301,7 +305,7 @@ export function createDevelopmentOidcApiTransport( await request( auth, `/accounts/${encodeURIComponent(auth.accountId)}/workers/scripts/${encodeURIComponent(workerName)}/oidc/development-sessions`, - {}, + { binding: binding.binding, identity_id: binding.identity_id }, signal ) ); @@ -317,9 +321,18 @@ export function createDevelopmentOidcApiTransport( typeof data.development_session_id !== "string" || data.development_session_id.length !== 36 || !SESSION_ID.test(data.development_session_id) || - data.policy !== "workers-oidc-v1" || - data.default_audience !== - `https://workers.cloudflare.com/accounts/${auth.accountId}` || + 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 || + !data.allowed_audiences.includes(data.default_audience) || typeof data.expires_at !== "number" || !Number.isSafeInteger(data.expires_at) || data.expires_at < now + 300 || @@ -327,8 +340,12 @@ export function createDevelopmentOidcApiTransport( ) { throw new DevelopmentOidcApiError(INVALID); } + validateDevelopmentOidcApiAudience(data.default_audience); + for (const audience of data.allowed_audiences) { + validateDevelopmentOidcApiAudience(audience); + } logger.info( - `Development OIDC subject for ${workerName}: worker:${data.worker_id}:development:user:${data.user_id} (local execution)` + `Development OIDC subject for ${workerName}: identity:${data.identity_id} (local execution)` ); return { identity: { @@ -337,12 +354,17 @@ export function createDevelopmentOidcApiTransport( 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, signal) { + async issueToken(auth, session, audience, fresh, signal) { validateDevelopmentOidcApiAudience(audience); if ( auth.accountId !== session.identity.accountId || @@ -355,7 +377,7 @@ export function createDevelopmentOidcApiTransport( await request( auth, `/accounts/${encodeURIComponent(auth.accountId)}/workers/oidc/development-sessions/${session.identity.developmentSessionId}/tokens`, - { audience }, + { audience, fresh }, signal ), session, diff --git a/packages/remote-bindings/src/development-oidc-options.ts b/packages/remote-bindings/src/development-oidc-options.ts index 391acf2a4a0..56f46aeae1a 100644 --- a/packages/remote-bindings/src/development-oidc-options.ts +++ b/packages/remote-bindings/src/development-oidc-options.ts @@ -22,16 +22,23 @@ import type { DevelopmentOidcBrokerFactory } from "miniflare"; export function getDevelopmentOidcOptions( config: Pick< Config, - "name" | "account_id" | "compatibility_flags" | "compliance_region" + "name" | "account_id" | "compatibility_flags" | "compliance_region" | "oidc" >, logger: RemoteBindingsLogger, profileDir = process.cwd() ): DevelopmentOidcBrokerFactory | undefined { - if (!config.compatibility_flags.includes("workers_oidc")) { + 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, }; @@ -43,6 +50,7 @@ export function getDevelopmentOidcOptions( return { key: JSON.stringify([ snapshot.name, + snapshot.oidc, snapshot.account_id, snapshot.compliance_region, directory, @@ -51,34 +59,49 @@ export function getDevelopmentOidcOptions( ]), create() { let readAuth: ReturnType | undefined; - const provider = 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) + 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 { - return await provider.getToken(options); + 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( @@ -90,7 +113,9 @@ export function getDevelopmentOidcOptions( } }, dispose() { - provider.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 index 10381a75ab9..5921d560408 100644 --- a/packages/remote-bindings/src/development-oidc.test.ts +++ b/packages/remote-bindings/src/development-oidc.test.ts @@ -1,19 +1,28 @@ import { inspect } from "node:util"; -import { DevelopmentOidcTokenCache } from "miniflare"; +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 } from "./development-oidc"; +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, @@ -29,20 +38,31 @@ function setup(workerName = "linked-worker") { apiToken: { apiToken: "human-oauth" }, }); const createSession = vi.fn( - async (_auth: CfAccount, _name: string, _signal: AbortSignal) => session + 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, - }); + const provider = new DevelopmentOidcProvider( + workerName, + getAuth, + { + createSession, + issueToken, + }, + binding + ); return { provider, getAuth, createSession, issueToken }; } @@ -53,7 +73,7 @@ beforeEach(() => { afterEach(() => vi.useRealTimers()); describe("development OIDC provider", () => { - it("does nothing until the getter, then shares auth, session, and issuance", async ({ + it("does nothing until the getter, then shares auth and session while authorizing every issuance", async ({ expect, }) => { const { provider, getAuth, createSession, issueToken } = setup(); @@ -68,14 +88,55 @@ describe("development OIDC provider", () => { expect(createSession).toHaveBeenCalledWith( { accountId: "account", apiToken: { apiToken: "human-oauth" } }, "linked-worker", + binding, expect.any(AbortSignal) ); - expect(issueToken).toHaveBeenCalledOnce(); + 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).toHaveBeenCalledOnce(); + expect(issueToken).toHaveBeenCalledTimes(11); + }); + + 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 ({ @@ -90,6 +151,7 @@ describe("development OIDC provider", () => { session.defaultAudience, "https://EXAMPLE.com/", "https://example.com/", + "https://EXAMPLE.com/", ]); }); @@ -129,7 +191,7 @@ describe("development OIDC provider", () => { await provider.getToken(); expect(createSession).toHaveBeenCalledTimes(2); expect(issueToken).toHaveBeenCalledTimes(2); - expect(createSession.mock.calls[0]?.[2].aborted).toBe(true); + expect(createSession.mock.calls[0]?.[3].aborted).toBe(true); }); it("isolates account changes and refuses mismatched server identities", async ({ @@ -171,7 +233,7 @@ describe("development OIDC provider", () => { expect(await provider.getToken()).toBe("development-token"); expect(createSession).toHaveBeenCalledOnce(); expect(issueToken).toHaveBeenCalledTimes(3); - expect(createSession.mock.calls[0]?.[2].aborted).toBe(false); + expect(createSession.mock.calls[0]?.[3].aborted).toBe(false); } ); @@ -185,7 +247,7 @@ describe("development OIDC provider", () => { const rejected = expect(provider.getToken()).rejects.toThrow("timed out"); await vi.advanceTimersByTimeAsync(30_000); await rejected; - expect(issueToken.mock.calls[1]?.[3].aborted).toBe(true); + expect(issueToken.mock.calls[1]?.[4].aborted).toBe(true); expect(await provider.getToken()).toBe("development-token"); expect(createSession).toHaveBeenCalledOnce(); expect(issueToken).toHaveBeenCalledTimes(3); @@ -206,14 +268,14 @@ describe("development OIDC provider", () => { await expect(provider.getToken({ audience: "other-api" })).rejects.toBe( error ); - expect(createSession.mock.calls[0]?.[2].aborted).toBe(true); + 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 across audiences without delaying cached reads or extending backoff", async ({ + it("honors Retry-After for every audience without extending backoff", async ({ expect, }) => { const { provider, createSession, issueToken } = setup(); @@ -227,7 +289,7 @@ describe("development OIDC provider", () => { await expect(provider.getToken({ audience: "other-api" })).rejects.toBe( error ); - expect(await provider.getToken()).toBe("development-token"); + await expect(provider.getToken()).rejects.toBe(error); await vi.advanceTimersByTimeAsync(30_000); await expect(provider.getToken({ audience: "third-api" })).rejects.toBe( error @@ -287,7 +349,7 @@ describe("development OIDC provider", () => { for (const subject of ["object", "namespace"]) { await expect( configured.provider.getToken({ subject } as { audience?: string }) - ).rejects.toThrow("cloud Preview"); + ).rejects.toThrow("only audience and boolean fresh"); } expect(configured.getAuth).not.toHaveBeenCalled(); }); @@ -316,7 +378,7 @@ describe("development OIDC provider", () => { const pending = provider.getToken(); await vi.waitFor(() => expect(issueToken).toHaveBeenCalledOnce()); provider.dispose(); - expect(issueToken.mock.calls[0]?.[3].aborted).toBe(true); + 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"); @@ -344,6 +406,7 @@ describe("development OIDC provider security boundaries", () => { apiToken: { apiToken: "original-oauth" }, }; const response = { + ...session, identity: { ...session.identity }, defaultAudience: session.defaultAudience, expiresAt: session.expiresAt, @@ -476,7 +539,7 @@ describe("development OIDC provider security boundaries", () => { const { provider } = setup(); await provider.getToken(); const spy = vi - .spyOn(DevelopmentOidcTokenCache.prototype, "getToken") + .spyOn(DevelopmentOidcTokenRequests.prototype, "getToken") .mockImplementationOnce(async () => { queueMicrotask(() => provider.dispose()); return "cached-token"; diff --git a/packages/remote-bindings/src/development-oidc.ts b/packages/remote-bindings/src/development-oidc.ts index da3ea2e7aba..40e1a7900c5 100644 --- a/packages/remote-bindings/src/development-oidc.ts +++ b/packages/remote-bindings/src/development-oidc.ts @@ -1,7 +1,7 @@ import { DevelopmentOidcRequestPool, DevelopmentOidcRequestTimeoutError, - DevelopmentOidcTokenCache, + DevelopmentOidcTokenRequests, snapshotDevelopmentOidcIdentity, validateDevelopmentOidcAudience, } from "miniflare"; @@ -10,7 +10,16 @@ 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. */ @@ -29,12 +38,14 @@ 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; } @@ -58,9 +69,10 @@ interface SessionState { */ export class DevelopmentOidcProvider { #workerName: string; + #binding: Readonly; #getAuth: (signal: AbortSignal) => Promise; #transport: DevelopmentOidcTransport; - #cache = new DevelopmentOidcTokenCache(); + #tokens = new DevelopmentOidcTokenRequests(); #requests = new DevelopmentOidcRequestPool(); #shutdown = new AbortController(); #state?: SessionState; @@ -76,9 +88,16 @@ export class DevelopmentOidcProvider { constructor( workerName: string, getAuth: (signal: AbortSignal) => Promise, - transport: DevelopmentOidcTransport + 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({ @@ -93,7 +112,9 @@ export class DevelopmentOidcProvider { * @param options Audience override, preserved exactly as supplied. * @returns A server-signed token with at least 60 seconds remaining. */ - async getToken(options: { audience?: string } = {}): Promise { + async getToken( + options: { audience?: string; fresh?: boolean } = {} + ): Promise { this.#checkActive(); if ( typeof this.#workerName !== "string" || @@ -107,12 +128,17 @@ export class DevelopmentOidcProvider { if (options === null || typeof options !== "object") { throw new Error("Development OIDC requires an options object."); } - // Retain this restriction in the host even when an adapter bypasses workerd. - if ("subject" in options) { + if ( + Reflect.ownKeys(options).some( + (key) => key !== "audience" && key !== "fresh" + ) || + ("fresh" in options && typeof options.fresh !== "boolean") + ) { throw new Error( - "Local Durable Objects use the Worker's development identity. Explicit object and namespace subjects require a cloud Preview." + "Development OIDC accepts only audience and boolean fresh options." ); } + const fresh = options.fresh ?? false; // Snapshot before the first await: callers may retain and mutate options. const requestedAudience = options.audience; if (requestedAudience !== undefined) { @@ -144,6 +170,7 @@ export class DevelopmentOidcProvider { const response = await this.#transport.createSession( auth, this.#workerName, + this.#binding, signal ); signal.throwIfAborted(); @@ -152,11 +179,33 @@ export class DevelopmentOidcProvider { ); const defaultAudience = response?.defaultAudience; validateDevelopmentOidcAudience(defaultAudience); - if (identity.accountId !== auth.accountId) { + if ( + identity.accountId !== auth.accountId || + identity.identityId !== this.#binding.identity_id || + response.binding !== this.#binding.binding || + !Array.isArray(response.allowedAudiences) || + !response.allowedAudiences.includes(defaultAudience) + ) { throw new Error( "Development OIDC returned a session for a different account." ); } + 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 ( @@ -172,7 +221,13 @@ export class DevelopmentOidcProvider { this.#state.expiresAt = expiresAt; this.#state.monotonicExpiry = performance.now() + remaining; } - return Object.freeze({ identity, defaultAudience, expiresAt }); + return Object.freeze({ + identity, + defaultAudience, + expiresAt, + binding: response.binding, + allowedAudiences: Object.freeze([...response.allowedAudiences]), + }); }, controller.signal), }; this.#state = state; @@ -183,7 +238,7 @@ export class DevelopmentOidcProvider { this.#checkCurrent(state); established = true; const audience = requestedAudience ?? session.defaultAudience; - const token = await this.#cache.getToken( + const token = await this.#tokens.getToken( session.identity, audience, (signal) => { @@ -192,11 +247,12 @@ export class DevelopmentOidcProvider { auth, session, audience, + fresh, AbortSignal.any([signal, state.controller.signal]) ); } ); - // Invalidation can occur while awaiting even an already-cached token. + // Recheck the session after the issuance request completes. this.#checkCurrent(state); return token; } catch (error) { @@ -292,14 +348,14 @@ export class DevelopmentOidcProvider { new Error("The development OIDC provider has been disposed.") ); this.#invalidate(); - this.#cache.dispose(); + this.#tokens.dispose(); } #invalidate(): void { const state = this.#state; this.#state = undefined; this.#retryAfter = undefined; - this.#cache.clear(); + this.#tokens.clear(); state?.controller.abort( new Error( "Development OIDC credentials changed during issuance. Retry the getter." diff --git a/packages/vite-plugin-cloudflare/src/__tests__/development-oidc.spec.ts b/packages/vite-plugin-cloudflare/src/__tests__/development-oidc.spec.ts index 92564335d3e..d5a21bb30e7 100644 --- a/packages/vite-plugin-cloudflare/src/__tests__/development-oidc.spec.ts +++ b/packages/vite-plugin-cloudflare/src/__tests__/development-oidc.spec.ts @@ -24,6 +24,9 @@ test("local Vite preview links each Worker's broker with remote bindings disable 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 }); diff --git a/packages/vitest-plugin/test/development-oidc.test.ts b/packages/vitest-plugin/test/development-oidc.test.ts index 801501b1b5f..bb3fe8b3a2a 100644 --- a/packages/vitest-plugin/test/development-oidc.test.ts +++ b/packages/vitest-plugin/test/development-oidc.test.ts @@ -18,6 +18,13 @@ describe("live development OIDC opt-in", () => { account_id: "a".repeat(32), compatibility_date: "2026-01-01", compatibility_flags: ["workers_oidc"], + oidc: [ + { + binding: "IDENTITY", + identity_id: "orders", + audience: "recipient", + }, + ], }) ); const project = { 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..58e0bb386be --- /dev/null +++ b/packages/workers-utils/src/config/oidc.ts @@ -0,0 +1,63 @@ +/** Validate an exact StringOrURI audience without fetching or normalizing it. */ +export function validOidcAudience(value: unknown): value is string { + if ( + typeof value !== "string" || + !value.isWellFormed() || + !value.trim() || + new TextEncoder().encode(value).length > 2048 + ) { + return false; + } + if (!value.includes(":")) { + return true; + } + if ( + !/^[A-Za-z][A-Za-z0-9+.-]*:/.test(value) || + /[^\x21-\x7e]/.test(value) || + /%(?![A-Fa-f0-9]{2})/.test(value) + ) { + return false; + } + try { + new URL(value); + return true; + } catch { + return false; + } +} + +/** 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 ( + Object.keys(v).some( + (k) => + !["binding", "identity_id", "audience", "allowed_audiences"].includes(k) + ) + ) { + return false; + } + if ( + ![v.binding, v.identity_id].every( + (x) => typeof x === "string" && /^[A-Za-z0-9_-]{1,128}$/.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/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/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 From de571e4096e7c967f860b9c93efe096fc40bc83b Mon Sep 17 00:00:00 2001 From: Brendan Irvine-Broque Date: Fri, 23 Oct 2026 16:39:06 -0700 Subject: [PATCH 6/7] Allow prerelease builds for explicitly selected branches --- .github/workflows/prerelease.yml | 1 + 1 file changed, 1 insertion(+) 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: From 40ff3ab6025a1418d64ba78bf5d20a3a256ffbf2 Mon Sep 17 00:00:00 2001 From: Brendan Irvine-Broque Date: Fri, 23 Oct 2026 17:01:52 -0700 Subject: [PATCH 7/7] fix: harden OIDC audience validation and local option snapshots --- .changeset/oidc-validation-review.md | 9 + .../miniflare/src/shared/development-oidc.ts | 8 +- .../src/development-oidc-api.test.ts | 3 + .../src/development-oidc-api.ts | 27 +-- .../src/development-oidc.test.ts | 45 +++++ .../remote-bindings/src/development-oidc.ts | 35 ++-- packages/workers-utils/src/config/oidc.ts | 160 ++++++++++++++++-- .../workers-utils/tests/config/oidc.test.ts | 88 ++++++++++ 8 files changed, 327 insertions(+), 48 deletions(-) create mode 100644 .changeset/oidc-validation-review.md create mode 100644 packages/workers-utils/tests/config/oidc.test.ts 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/packages/miniflare/src/shared/development-oidc.ts b/packages/miniflare/src/shared/development-oidc.ts index 6efbf3e0fb9..e8a338ae1d7 100644 --- a/packages/miniflare/src/shared/development-oidc.ts +++ b/packages/miniflare/src/shared/development-oidc.ts @@ -1,5 +1,6 @@ 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 { @@ -112,17 +113,16 @@ function isBoundedString(value: unknown, maxBytes: number): value is string { } /** - * Validate host-side audience bounds without normalizing the value. The trusted - * transport must also enforce the signing API's StringOrURI rules. + * 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 (!isBoundedString(audience, 2048) || audience.trim().length === 0) { + if (!validOidcAudience(audience)) { throw new Error( - "Development OIDC requires an audience of 1–2048 UTF-8 bytes that is not whitespace-only." + "Development OIDC requires a StringOrURI audience of 1–2048 UTF-8 bytes that is not whitespace-only." ); } } diff --git a/packages/remote-bindings/src/development-oidc-api.test.ts b/packages/remote-bindings/src/development-oidc-api.test.ts index 9dbefc03ad0..50642fbd684 100644 --- a/packages/remote-bindings/src/development-oidc-api.test.ts +++ b/packages/remote-bindings/src/development-oidc-api.test.ts @@ -393,6 +393,9 @@ describe("local token profile", () => { "x".repeat(2049), "with spaces:bad", "1invalid:scheme", + "https://host/\\path", + "https://host/[]", + "\u0085", "https://example/%xx", "urn:雪", ])("rejects invalid StringOrURI audiences", (value, { expect }) => { diff --git a/packages/remote-bindings/src/development-oidc-api.ts b/packages/remote-bindings/src/development-oidc-api.ts index a6164d0b766..0ffa417e179 100644 --- a/packages/remote-bindings/src/development-oidc-api.ts +++ b/packages/remote-bindings/src/development-oidc-api.ts @@ -1,7 +1,7 @@ import { Buffer } from "node:buffer"; import { TextDecoder } from "node:util"; import { performApiFetchBase } from "@cloudflare/workers-utils"; -import { validateDevelopmentOidcAudience } from "miniflare"; +import { validOidcAudience } from "@cloudflare/workers-utils/oidc"; import { version } from "../package.json"; import { DevelopmentOidcApiError } from "./development-oidc-errors"; import type { @@ -32,25 +32,10 @@ function object(value: unknown): Record { /** Enforce the API's StringOrURI rules without fetching or normalizing audiences. */ export function validateDevelopmentOidcApiAudience(audience: string): void { - validateDevelopmentOidcAudience(audience); - if (audience.includes(":")) { - // StringOrURI requires a URI scheme when a colon occurs. Preserve the original string. - if ( - !/^[A-Za-z][A-Za-z0-9+.-]*:/.test(audience) || - /[^\x21-\x7e]/.test(audience) || - /%(?![A-Fa-f0-9]{2})/.test(audience) - ) { - throw new DevelopmentOidcApiError( - "Development OIDC audiences containing a colon must be valid URIs." - ); - } - try { - new URL(audience); - } catch { - throw new DevelopmentOidcApiError( - "Development OIDC audiences containing a colon must be valid URIs." - ); - } + 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." + ); } } @@ -332,6 +317,8 @@ export function createDevelopmentOidcApiTransport( !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) || diff --git a/packages/remote-bindings/src/development-oidc.test.ts b/packages/remote-bindings/src/development-oidc.test.ts index 5921d560408..f34bce7bf5e 100644 --- a/packages/remote-bindings/src/development-oidc.test.ts +++ b/packages/remote-bindings/src/development-oidc.test.ts @@ -99,6 +99,51 @@ describe("development OIDC provider", () => { 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; diff --git a/packages/remote-bindings/src/development-oidc.ts b/packages/remote-bindings/src/development-oidc.ts index 40e1a7900c5..180d6c6c973 100644 --- a/packages/remote-bindings/src/development-oidc.ts +++ b/packages/remote-bindings/src/development-oidc.ts @@ -128,19 +128,22 @@ export class DevelopmentOidcProvider { if (options === null || typeof options !== "object") { throw new Error("Development OIDC requires an options object."); } - if ( - Reflect.ownKeys(options).some( - (key) => key !== "audience" && key !== "fresh" - ) || - ("fresh" in options && typeof options.fresh !== "boolean") - ) { + 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." ); } - const fresh = options.fresh ?? false; // Snapshot before the first await: callers may retain and mutate options. - const requestedAudience = options.audience; + 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); } @@ -182,14 +185,26 @@ export class DevelopmentOidcProvider { if ( identity.accountId !== auth.accountId || identity.identityId !== this.#binding.identity_id || - response.binding !== this.#binding.binding || + 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 a session for a different account." + "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, diff --git a/packages/workers-utils/src/config/oidc.ts b/packages/workers-utils/src/config/oidc.ts index 58e0bb386be..07a8807c50e 100644 --- a/packages/workers-utils/src/config/oidc.ts +++ b/packages/workers-utils/src/config/oidc.ts @@ -2,28 +2,154 @@ export function validOidcAudience(value: unknown): value is string { if ( typeof value !== "string" || + value.length === 0 || + value.length > 2048 || !value.isWellFormed() || - !value.trim() || + /^[\s\u0085]*$/.test(value) || new TextEncoder().encode(value).length > 2048 ) { return false; } - if (!value.includes(":")) { + const colon = value.indexOf(":"); + if (colon < 0) { return true; } - if ( - !/^[A-Za-z][A-Za-z0-9+.-]*:/.test(value) || - /[^\x21-\x7e]/.test(value) || - /%(?![A-Fa-f0-9]{2})/.test(value) - ) { + if (/[^\x21-\x7e]/.test(value)) { return false; } - try { - new URL(value); - return true; - } catch { + 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. */ @@ -33,16 +159,22 @@ export function isOidcBinding(value: unknown): boolean { } const v = value as Record; if ( - Object.keys(v).some( + Reflect.ownKeys(v).some( (k) => - !["binding", "identity_id", "audience", "allowed_audiences"].includes(k) + !["binding", "identity_id", "audience", "allowed_audiences"].includes( + String(k) + ) ) ) { return false; } if ( ![v.binding, v.identity_id].every( - (x) => typeof x === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(x) + (x) => + typeof x === "string" && + x.length > 0 && + x.length <= 128 && + !/[^A-Za-z0-9_-]/.test(x) ) ) { return false; 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); +});