diff --git a/base-action/src/index.ts b/base-action/src/index.ts index e95c2f64..2e4cd257 100644 --- a/base-action/src/index.ts +++ b/base-action/src/index.ts @@ -75,7 +75,8 @@ async function run() { core.setOutput("conclusion", "failure"); process.exit(1); } finally { - // Stop refreshing the workload identity token file so the process can exit + // Stop refreshing the workload identity token file (so the process can + // exit) and delete the token material so it doesn't outlive this step workloadIdentity?.stop(); } } diff --git a/base-action/src/workload-identity.ts b/base-action/src/workload-identity.ts index 91266f32..79ebaf50 100644 --- a/base-action/src/workload-identity.ts +++ b/base-action/src/workload-identity.ts @@ -15,7 +15,8 @@ */ import * as core from "@actions/core"; -import { mkdirSync, writeFileSync } from "fs"; +import { createHash } from "crypto"; +import { mkdirSync, rmSync, writeFileSync } from "fs"; import { join } from "path"; import { retryWithBackoff } from "./retry"; @@ -50,6 +51,63 @@ async function fetchIdentityToken(audience: string) { return retryWithBackoff(() => core.getIDToken(audience)); } +/** + * Writes a profile config that switches federation resolution to the + * file-backed path. Resolving federation through a profile (rather than bare + * env vars) enables the SDK's on-disk credentials cache, so the several + * `claude` processes the action spawns (plugin installs, main query) share + * one exchanged access token instead of each re-exchanging the single-use + * GitHub OIDC token, which fails with 401 (`jti_reused`). + * + * The profile is intentionally minimal: the SDK gap-fills the federation + * fields (rule, organization, identity-token file, service account, base URL) + * from the ANTHROPIC_* env vars the action already exports, so the file only + * needs to exist to turn the cache on. + * + * The config dir name embeds a fingerprint of the federation inputs. The + * SDK's cache reuses a token on `expires_at` alone, with no record of the + * config that minted it, and the token's scope is bound at mint time — so a + * later action step in the same job (RUNNER_TEMP is per-job) with different + * federation inputs must land in a different dir or it would silently reuse + * the first step's token. + * + * Sharing the cache is only safe while the action spawns its `claude` + * subprocesses sequentially: the SDK cache is not cross-process serialized, + * and concurrent cache misses would each re-exchange the same single-use + * identity token. Parallelizing the plugin installs would reintroduce the + * `jti_reused` failures. + */ +function writeFederationProfile(baseDir: string): string { + // Every input that changes which credential the exchange mints must be in + // here; service_account_id and scope are sent in the exchange request body. + const fingerprint = createHash("sha256") + .update( + JSON.stringify([ + process.env.ANTHROPIC_FEDERATION_RULE_ID?.trim() ?? "", + process.env.ANTHROPIC_ORGANIZATION_ID?.trim() ?? "", + process.env.ANTHROPIC_SERVICE_ACCOUNT_ID?.trim() ?? "", + process.env.ANTHROPIC_WORKSPACE_ID?.trim() ?? "", + process.env.ANTHROPIC_BASE_URL?.trim() ?? "", + process.env.ANTHROPIC_SCOPE?.trim() ?? "", + ]), + ) + .digest("hex") + .slice(0, 16); + const configDir = join(baseDir, `config-${fingerprint}`); + + mkdirSync(join(configDir, "configs"), { recursive: true, mode: 0o700 }); + writeFileSync( + join(configDir, "configs", "default.json"), + JSON.stringify( + { version: "1.0", authentication: { type: "oidc_federation" } }, + null, + 2, + ), + { mode: 0o600 }, + ); + return configDir; +} + /** * Fetches a GitHub Actions OIDC token, writes it to a file in RUNNER_TEMP, * exports ANTHROPIC_IDENTITY_TOKEN_FILE, and starts a background refresh so @@ -57,7 +115,8 @@ async function fetchIdentityToken(audience: string) { * * Returns undefined when federation is not configured or is shadowed by a * higher-precedence credential. Callers must invoke stop() when execution - * finishes. + * finishes; it also deletes the identity token and any cached exchanged + * credential. */ export async function setupWorkloadIdentity(): Promise< WorkloadIdentityHandle | undefined @@ -101,6 +160,17 @@ export async function setupWorkloadIdentity(): Promise< } process.env.ANTHROPIC_IDENTITY_TOKEN_FILE = tokenFile; + if ( + process.env.ANTHROPIC_CONFIG_DIR?.trim() || + process.env.ANTHROPIC_PROFILE?.trim() + ) { + core.warning( + "ANTHROPIC_CONFIG_DIR or ANTHROPIC_PROFILE is already set, so the action will not write its own federation profile. Credential caching across the spawned Claude processes follows the existing profile configuration.", + ); + } else { + process.env.ANTHROPIC_CONFIG_DIR = writeFederationProfile(tokenDir); + process.env.ANTHROPIC_PROFILE = "default"; + } console.log( `Workload identity federation configured (rule: ${process.env.ANTHROPIC_FEDERATION_RULE_ID}, identity token file: ${tokenFile})`, ); @@ -115,6 +185,12 @@ export async function setupWorkloadIdentity(): Promise< return { tokenFile, - stop: () => clearInterval(refreshInterval), + stop: () => { + clearInterval(refreshInterval); + // RUNNER_TEMP is per-job, not per-step: remove the identity token, the + // profile, and the cached exchanged credential so they don't outlive + // this step. + rmSync(tokenDir, { recursive: true, force: true }); + }, }; } diff --git a/base-action/test/workload-identity.test.ts b/base-action/test/workload-identity.test.ts index e95f4e06..5b7615ca 100644 --- a/base-action/test/workload-identity.test.ts +++ b/base-action/test/workload-identity.test.ts @@ -2,7 +2,14 @@ import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test"; import * as core from "@actions/core"; -import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "fs"; +import { + existsSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, +} from "fs"; import { tmpdir } from "os"; import { join } from "path"; import { @@ -27,6 +34,12 @@ describe("workload identity federation", () => { delete process.env.ANTHROPIC_ORGANIZATION_ID; delete process.env.ANTHROPIC_OIDC_AUDIENCE; delete process.env.ANTHROPIC_IDENTITY_TOKEN_FILE; + delete process.env.ANTHROPIC_SERVICE_ACCOUNT_ID; + delete process.env.ANTHROPIC_WORKSPACE_ID; + delete process.env.ANTHROPIC_BASE_URL; + delete process.env.ANTHROPIC_SCOPE; + delete process.env.ANTHROPIC_CONFIG_DIR; + delete process.env.ANTHROPIC_PROFILE; getIDTokenSpy = spyOn(core, "getIDToken").mockResolvedValue( "test-identity-token", @@ -123,5 +136,123 @@ describe("workload identity federation", () => { handle?.stop(); } }); + + test("writes a minimal federation profile and selects it", async () => { + process.env.ANTHROPIC_FEDERATION_RULE_ID = "fdrl_test"; + process.env.ANTHROPIC_ORGANIZATION_ID = + "00000000-0000-0000-0000-000000000000"; + process.env.ANTHROPIC_SERVICE_ACCOUNT_ID = "svac_test"; + process.env.ANTHROPIC_WORKSPACE_ID = "wrkspc_test"; + + const handle = await setupWorkloadIdentity(); + try { + const configDir = process.env.ANTHROPIC_CONFIG_DIR; + expect(configDir).toBeDefined(); + expect( + configDir!.startsWith( + join(tempDir, "claude-workload-identity", "config-"), + ), + ).toBe(true); + expect(process.env.ANTHROPIC_PROFILE).toBe("default"); + + const profilePath = join(configDir!, "configs", "default.json"); + expect(statSync(profilePath).mode & 0o777).toBe(0o600); + // Minimal on purpose: the SDK gap-fills the federation fields from + // the ANTHROPIC_* env vars the action exports. + expect(JSON.parse(readFileSync(profilePath, "utf-8"))).toEqual({ + version: "1.0", + authentication: { type: "oidc_federation" }, + }); + } finally { + handle?.stop(); + } + }); + + test("derives the config dir from the federation inputs", async () => { + process.env.ANTHROPIC_FEDERATION_RULE_ID = "fdrl_test"; + process.env.ANTHROPIC_ORGANIZATION_ID = + "00000000-0000-0000-0000-000000000000"; + process.env.ANTHROPIC_WORKSPACE_ID = "wrkspc_a"; + + (await setupWorkloadIdentity())?.stop(); + const firstConfigDir = process.env.ANTHROPIC_CONFIG_DIR; + expect(firstConfigDir).toBeDefined(); + + // A later step in the same job with a different workspace must not + // share the first step's credentials cache. + delete process.env.ANTHROPIC_CONFIG_DIR; + delete process.env.ANTHROPIC_PROFILE; + process.env.ANTHROPIC_WORKSPACE_ID = "wrkspc_b"; + + (await setupWorkloadIdentity())?.stop(); + const secondConfigDir = process.env.ANTHROPIC_CONFIG_DIR; + expect(secondConfigDir).toBeDefined(); + expect(secondConfigDir).not.toBe(firstConfigDir); + + // Same inputs land in the same dir, so an unchanged config can still + // reuse a cached token. + delete process.env.ANTHROPIC_CONFIG_DIR; + delete process.env.ANTHROPIC_PROFILE; + + (await setupWorkloadIdentity())?.stop(); + expect(process.env.ANTHROPIC_CONFIG_DIR).toBe(secondConfigDir!); + }); + + test("does not overwrite an operator-set ANTHROPIC_PROFILE", async () => { + process.env.ANTHROPIC_FEDERATION_RULE_ID = "fdrl_test"; + process.env.ANTHROPIC_ORGANIZATION_ID = + "00000000-0000-0000-0000-000000000000"; + process.env.ANTHROPIC_PROFILE = "operator"; + + const handle = await setupWorkloadIdentity(); + try { + expect(process.env.ANTHROPIC_PROFILE).toBe("operator"); + expect(process.env.ANTHROPIC_CONFIG_DIR).toBeUndefined(); + expect(warningSpy).toHaveBeenCalled(); + + const entries = readdirSync(join(tempDir, "claude-workload-identity")); + expect(entries.filter((e) => e.startsWith("config-"))).toEqual([]); + + // The identity token file is still provisioned for the operator's + // profile (or the env-var fallback) to consume. + expect(process.env.ANTHROPIC_IDENTITY_TOKEN_FILE).toBe( + handle!.tokenFile, + ); + } finally { + handle?.stop(); + } + }); + + test("does not overwrite an operator-set ANTHROPIC_CONFIG_DIR", async () => { + process.env.ANTHROPIC_FEDERATION_RULE_ID = "fdrl_test"; + process.env.ANTHROPIC_ORGANIZATION_ID = + "00000000-0000-0000-0000-000000000000"; + const operatorConfigDir = join(tempDir, "operator-config"); + process.env.ANTHROPIC_CONFIG_DIR = operatorConfigDir; + + const handle = await setupWorkloadIdentity(); + try { + expect(process.env.ANTHROPIC_CONFIG_DIR).toBe(operatorConfigDir); + expect(process.env.ANTHROPIC_PROFILE).toBeUndefined(); + expect(warningSpy).toHaveBeenCalled(); + } finally { + handle?.stop(); + } + }); + + test("stop removes the identity token and credential cache", async () => { + process.env.ANTHROPIC_FEDERATION_RULE_ID = "fdrl_test"; + process.env.ANTHROPIC_ORGANIZATION_ID = + "00000000-0000-0000-0000-000000000000"; + + const handle = await setupWorkloadIdentity(); + const tokenDir = join(tempDir, "claude-workload-identity"); + expect(existsSync(handle!.tokenFile)).toBe(true); + expect(existsSync(process.env.ANTHROPIC_CONFIG_DIR!)).toBe(true); + + handle!.stop(); + + expect(existsSync(tokenDir)).toBe(false); + }); }); }); diff --git a/src/entrypoints/run.ts b/src/entrypoints/run.ts index 85332297..21460d3d 100644 --- a/src/entrypoints/run.ts +++ b/src/entrypoints/run.ts @@ -318,7 +318,8 @@ async function run() { } finally { // Phase 4: Cleanup (always runs) - // Stop refreshing the workload identity token file + // Stop refreshing the workload identity token file and delete the token + // material so it doesn't outlive this step workloadIdentity?.stop(); // Update tracking comment