Skip to content

Commit b00a341

Browse files
fix: share one exchanged WIF credential across spawned Claude processes (#1407)
* fix: share one exchanged WIF credential across spawned Claude processes GitHub OIDC tokens are single-use at the Anthropic token-exchange endpoint (the same jti cannot be exchanged twice). With plugins configured, the action spawns several short-lived claude processes (plugin marketplace add, one plugin install per plugin, then the main query). Each resolved federation from bare env vars and exchanged the same identity-token file independently: the first exchange succeeded and every later process got 401 (jti_reused), which the main query retried for ~3 minutes before failing the job. The SDK only enables its on-disk credentials cache when federation is loaded from a profile config file, not from bare env vars. Write a profile pointing at the identity-token file and select it via ANTHROPIC_CONFIG_DIR / ANTHROPIC_PROFILE so the first process exchanges once and the rest reuse the cached access token. The env vars are kept as a fallback for CLIs that predate profile support. * fix: scope the WIF credential cache per federation config Address review feedback on the shared-credentials-cache fix: - Embed a fingerprint of the federation inputs (rule, org, service account, workspace, base URL, scope) in the config dir name. The SDK cache reuses a token on expires_at alone and RUNNER_TEMP is per-job, so a later step with different federation inputs would silently reuse the first step's token. service_account_id and scope are included beyond the reviewed list because both are sent in the exchange request body and change which credential is minted. - Skip the action-managed profile with a warning when the operator has already set ANTHROPIC_CONFIG_DIR or ANTHROPIC_PROFILE. - Shrink the profile to the minimal file-backed form; the CLI's bundled SDK gap-fills the federation fields from the env vars the action already exports (verified against the pinned 2.1.173 binary). - Remove the token dir in stop() so the identity token and the cached exchanged credential don't outlive the step. - Document that cache sharing relies on the plugin subprocesses spawning sequentially.
1 parent fa7e2f0 commit b00a341

4 files changed

Lines changed: 215 additions & 6 deletions

File tree

base-action/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,8 @@ async function run() {
7575
core.setOutput("conclusion", "failure");
7676
process.exit(1);
7777
} finally {
78-
// Stop refreshing the workload identity token file so the process can exit
78+
// Stop refreshing the workload identity token file (so the process can
79+
// exit) and delete the token material so it doesn't outlive this step
7980
workloadIdentity?.stop();
8081
}
8182
}

base-action/src/workload-identity.ts

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
*/
1616

1717
import * as core from "@actions/core";
18-
import { mkdirSync, writeFileSync } from "fs";
18+
import { createHash } from "crypto";
19+
import { mkdirSync, rmSync, writeFileSync } from "fs";
1920
import { join } from "path";
2021
import { retryWithBackoff } from "./retry";
2122

@@ -50,14 +51,72 @@ async function fetchIdentityToken(audience: string) {
5051
return retryWithBackoff(() => core.getIDToken(audience));
5152
}
5253

54+
/**
55+
* Writes a profile config that switches federation resolution to the
56+
* file-backed path. Resolving federation through a profile (rather than bare
57+
* env vars) enables the SDK's on-disk credentials cache, so the several
58+
* `claude` processes the action spawns (plugin installs, main query) share
59+
* one exchanged access token instead of each re-exchanging the single-use
60+
* GitHub OIDC token, which fails with 401 (`jti_reused`).
61+
*
62+
* The profile is intentionally minimal: the SDK gap-fills the federation
63+
* fields (rule, organization, identity-token file, service account, base URL)
64+
* from the ANTHROPIC_* env vars the action already exports, so the file only
65+
* needs to exist to turn the cache on.
66+
*
67+
* The config dir name embeds a fingerprint of the federation inputs. The
68+
* SDK's cache reuses a token on `expires_at` alone, with no record of the
69+
* config that minted it, and the token's scope is bound at mint time — so a
70+
* later action step in the same job (RUNNER_TEMP is per-job) with different
71+
* federation inputs must land in a different dir or it would silently reuse
72+
* the first step's token.
73+
*
74+
* Sharing the cache is only safe while the action spawns its `claude`
75+
* subprocesses sequentially: the SDK cache is not cross-process serialized,
76+
* and concurrent cache misses would each re-exchange the same single-use
77+
* identity token. Parallelizing the plugin installs would reintroduce the
78+
* `jti_reused` failures.
79+
*/
80+
function writeFederationProfile(baseDir: string): string {
81+
// Every input that changes which credential the exchange mints must be in
82+
// here; service_account_id and scope are sent in the exchange request body.
83+
const fingerprint = createHash("sha256")
84+
.update(
85+
JSON.stringify([
86+
process.env.ANTHROPIC_FEDERATION_RULE_ID?.trim() ?? "",
87+
process.env.ANTHROPIC_ORGANIZATION_ID?.trim() ?? "",
88+
process.env.ANTHROPIC_SERVICE_ACCOUNT_ID?.trim() ?? "",
89+
process.env.ANTHROPIC_WORKSPACE_ID?.trim() ?? "",
90+
process.env.ANTHROPIC_BASE_URL?.trim() ?? "",
91+
process.env.ANTHROPIC_SCOPE?.trim() ?? "",
92+
]),
93+
)
94+
.digest("hex")
95+
.slice(0, 16);
96+
const configDir = join(baseDir, `config-${fingerprint}`);
97+
98+
mkdirSync(join(configDir, "configs"), { recursive: true, mode: 0o700 });
99+
writeFileSync(
100+
join(configDir, "configs", "default.json"),
101+
JSON.stringify(
102+
{ version: "1.0", authentication: { type: "oidc_federation" } },
103+
null,
104+
2,
105+
),
106+
{ mode: 0o600 },
107+
);
108+
return configDir;
109+
}
110+
53111
/**
54112
* Fetches a GitHub Actions OIDC token, writes it to a file in RUNNER_TEMP,
55113
* exports ANTHROPIC_IDENTITY_TOKEN_FILE, and starts a background refresh so
56114
* the file stays valid for long executions.
57115
*
58116
* Returns undefined when federation is not configured or is shadowed by a
59117
* higher-precedence credential. Callers must invoke stop() when execution
60-
* finishes.
118+
* finishes; it also deletes the identity token and any cached exchanged
119+
* credential.
61120
*/
62121
export async function setupWorkloadIdentity(): Promise<
63122
WorkloadIdentityHandle | undefined
@@ -101,6 +160,17 @@ export async function setupWorkloadIdentity(): Promise<
101160
}
102161

103162
process.env.ANTHROPIC_IDENTITY_TOKEN_FILE = tokenFile;
163+
if (
164+
process.env.ANTHROPIC_CONFIG_DIR?.trim() ||
165+
process.env.ANTHROPIC_PROFILE?.trim()
166+
) {
167+
core.warning(
168+
"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.",
169+
);
170+
} else {
171+
process.env.ANTHROPIC_CONFIG_DIR = writeFederationProfile(tokenDir);
172+
process.env.ANTHROPIC_PROFILE = "default";
173+
}
104174
console.log(
105175
`Workload identity federation configured (rule: ${process.env.ANTHROPIC_FEDERATION_RULE_ID}, identity token file: ${tokenFile})`,
106176
);
@@ -115,6 +185,12 @@ export async function setupWorkloadIdentity(): Promise<
115185

116186
return {
117187
tokenFile,
118-
stop: () => clearInterval(refreshInterval),
188+
stop: () => {
189+
clearInterval(refreshInterval);
190+
// RUNNER_TEMP is per-job, not per-step: remove the identity token, the
191+
// profile, and the cached exchanged credential so they don't outlive
192+
// this step.
193+
rmSync(tokenDir, { recursive: true, force: true });
194+
},
119195
};
120196
}

base-action/test/workload-identity.test.ts

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,14 @@
22

33
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
44
import * as core from "@actions/core";
5-
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "fs";
5+
import {
6+
existsSync,
7+
mkdtempSync,
8+
readdirSync,
9+
readFileSync,
10+
rmSync,
11+
statSync,
12+
} from "fs";
613
import { tmpdir } from "os";
714
import { join } from "path";
815
import {
@@ -27,6 +34,12 @@ describe("workload identity federation", () => {
2734
delete process.env.ANTHROPIC_ORGANIZATION_ID;
2835
delete process.env.ANTHROPIC_OIDC_AUDIENCE;
2936
delete process.env.ANTHROPIC_IDENTITY_TOKEN_FILE;
37+
delete process.env.ANTHROPIC_SERVICE_ACCOUNT_ID;
38+
delete process.env.ANTHROPIC_WORKSPACE_ID;
39+
delete process.env.ANTHROPIC_BASE_URL;
40+
delete process.env.ANTHROPIC_SCOPE;
41+
delete process.env.ANTHROPIC_CONFIG_DIR;
42+
delete process.env.ANTHROPIC_PROFILE;
3043

3144
getIDTokenSpy = spyOn(core, "getIDToken").mockResolvedValue(
3245
"test-identity-token",
@@ -123,5 +136,123 @@ describe("workload identity federation", () => {
123136
handle?.stop();
124137
}
125138
});
139+
140+
test("writes a minimal federation profile and selects it", async () => {
141+
process.env.ANTHROPIC_FEDERATION_RULE_ID = "fdrl_test";
142+
process.env.ANTHROPIC_ORGANIZATION_ID =
143+
"00000000-0000-0000-0000-000000000000";
144+
process.env.ANTHROPIC_SERVICE_ACCOUNT_ID = "svac_test";
145+
process.env.ANTHROPIC_WORKSPACE_ID = "wrkspc_test";
146+
147+
const handle = await setupWorkloadIdentity();
148+
try {
149+
const configDir = process.env.ANTHROPIC_CONFIG_DIR;
150+
expect(configDir).toBeDefined();
151+
expect(
152+
configDir!.startsWith(
153+
join(tempDir, "claude-workload-identity", "config-"),
154+
),
155+
).toBe(true);
156+
expect(process.env.ANTHROPIC_PROFILE).toBe("default");
157+
158+
const profilePath = join(configDir!, "configs", "default.json");
159+
expect(statSync(profilePath).mode & 0o777).toBe(0o600);
160+
// Minimal on purpose: the SDK gap-fills the federation fields from
161+
// the ANTHROPIC_* env vars the action exports.
162+
expect(JSON.parse(readFileSync(profilePath, "utf-8"))).toEqual({
163+
version: "1.0",
164+
authentication: { type: "oidc_federation" },
165+
});
166+
} finally {
167+
handle?.stop();
168+
}
169+
});
170+
171+
test("derives the config dir from the federation inputs", async () => {
172+
process.env.ANTHROPIC_FEDERATION_RULE_ID = "fdrl_test";
173+
process.env.ANTHROPIC_ORGANIZATION_ID =
174+
"00000000-0000-0000-0000-000000000000";
175+
process.env.ANTHROPIC_WORKSPACE_ID = "wrkspc_a";
176+
177+
(await setupWorkloadIdentity())?.stop();
178+
const firstConfigDir = process.env.ANTHROPIC_CONFIG_DIR;
179+
expect(firstConfigDir).toBeDefined();
180+
181+
// A later step in the same job with a different workspace must not
182+
// share the first step's credentials cache.
183+
delete process.env.ANTHROPIC_CONFIG_DIR;
184+
delete process.env.ANTHROPIC_PROFILE;
185+
process.env.ANTHROPIC_WORKSPACE_ID = "wrkspc_b";
186+
187+
(await setupWorkloadIdentity())?.stop();
188+
const secondConfigDir = process.env.ANTHROPIC_CONFIG_DIR;
189+
expect(secondConfigDir).toBeDefined();
190+
expect(secondConfigDir).not.toBe(firstConfigDir);
191+
192+
// Same inputs land in the same dir, so an unchanged config can still
193+
// reuse a cached token.
194+
delete process.env.ANTHROPIC_CONFIG_DIR;
195+
delete process.env.ANTHROPIC_PROFILE;
196+
197+
(await setupWorkloadIdentity())?.stop();
198+
expect(process.env.ANTHROPIC_CONFIG_DIR).toBe(secondConfigDir!);
199+
});
200+
201+
test("does not overwrite an operator-set ANTHROPIC_PROFILE", async () => {
202+
process.env.ANTHROPIC_FEDERATION_RULE_ID = "fdrl_test";
203+
process.env.ANTHROPIC_ORGANIZATION_ID =
204+
"00000000-0000-0000-0000-000000000000";
205+
process.env.ANTHROPIC_PROFILE = "operator";
206+
207+
const handle = await setupWorkloadIdentity();
208+
try {
209+
expect(process.env.ANTHROPIC_PROFILE).toBe("operator");
210+
expect(process.env.ANTHROPIC_CONFIG_DIR).toBeUndefined();
211+
expect(warningSpy).toHaveBeenCalled();
212+
213+
const entries = readdirSync(join(tempDir, "claude-workload-identity"));
214+
expect(entries.filter((e) => e.startsWith("config-"))).toEqual([]);
215+
216+
// The identity token file is still provisioned for the operator's
217+
// profile (or the env-var fallback) to consume.
218+
expect(process.env.ANTHROPIC_IDENTITY_TOKEN_FILE).toBe(
219+
handle!.tokenFile,
220+
);
221+
} finally {
222+
handle?.stop();
223+
}
224+
});
225+
226+
test("does not overwrite an operator-set ANTHROPIC_CONFIG_DIR", async () => {
227+
process.env.ANTHROPIC_FEDERATION_RULE_ID = "fdrl_test";
228+
process.env.ANTHROPIC_ORGANIZATION_ID =
229+
"00000000-0000-0000-0000-000000000000";
230+
const operatorConfigDir = join(tempDir, "operator-config");
231+
process.env.ANTHROPIC_CONFIG_DIR = operatorConfigDir;
232+
233+
const handle = await setupWorkloadIdentity();
234+
try {
235+
expect(process.env.ANTHROPIC_CONFIG_DIR).toBe(operatorConfigDir);
236+
expect(process.env.ANTHROPIC_PROFILE).toBeUndefined();
237+
expect(warningSpy).toHaveBeenCalled();
238+
} finally {
239+
handle?.stop();
240+
}
241+
});
242+
243+
test("stop removes the identity token and credential cache", async () => {
244+
process.env.ANTHROPIC_FEDERATION_RULE_ID = "fdrl_test";
245+
process.env.ANTHROPIC_ORGANIZATION_ID =
246+
"00000000-0000-0000-0000-000000000000";
247+
248+
const handle = await setupWorkloadIdentity();
249+
const tokenDir = join(tempDir, "claude-workload-identity");
250+
expect(existsSync(handle!.tokenFile)).toBe(true);
251+
expect(existsSync(process.env.ANTHROPIC_CONFIG_DIR!)).toBe(true);
252+
253+
handle!.stop();
254+
255+
expect(existsSync(tokenDir)).toBe(false);
256+
});
126257
});
127258
});

src/entrypoints/run.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,8 @@ async function run() {
318318
} finally {
319319
// Phase 4: Cleanup (always runs)
320320

321-
// Stop refreshing the workload identity token file
321+
// Stop refreshing the workload identity token file and delete the token
322+
// material so it doesn't outlive this step
322323
workloadIdentity?.stop();
323324

324325
// Update tracking comment

0 commit comments

Comments
 (0)