mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-08-03 09:48:31 +08:00
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.
This commit is contained in:
parent
fa7e2f0a29
commit
b00a3414fd
@ -75,7 +75,8 @@ async function run() {
|
|||||||
core.setOutput("conclusion", "failure");
|
core.setOutput("conclusion", "failure");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
} finally {
|
} 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();
|
workloadIdentity?.stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,7 +15,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as core from "@actions/core";
|
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 { join } from "path";
|
||||||
import { retryWithBackoff } from "./retry";
|
import { retryWithBackoff } from "./retry";
|
||||||
|
|
||||||
@ -50,6 +51,63 @@ async function fetchIdentityToken(audience: string) {
|
|||||||
return retryWithBackoff(() => core.getIDToken(audience));
|
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,
|
* 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
|
* 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
|
* Returns undefined when federation is not configured or is shadowed by a
|
||||||
* higher-precedence credential. Callers must invoke stop() when execution
|
* 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<
|
export async function setupWorkloadIdentity(): Promise<
|
||||||
WorkloadIdentityHandle | undefined
|
WorkloadIdentityHandle | undefined
|
||||||
@ -101,6 +160,17 @@ export async function setupWorkloadIdentity(): Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
process.env.ANTHROPIC_IDENTITY_TOKEN_FILE = tokenFile;
|
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(
|
console.log(
|
||||||
`Workload identity federation configured (rule: ${process.env.ANTHROPIC_FEDERATION_RULE_ID}, identity token file: ${tokenFile})`,
|
`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 {
|
return {
|
||||||
tokenFile,
|
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 });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,14 @@
|
|||||||
|
|
||||||
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
|
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
|
||||||
import * as core from "@actions/core";
|
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 { tmpdir } from "os";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import {
|
import {
|
||||||
@ -27,6 +34,12 @@ describe("workload identity federation", () => {
|
|||||||
delete process.env.ANTHROPIC_ORGANIZATION_ID;
|
delete process.env.ANTHROPIC_ORGANIZATION_ID;
|
||||||
delete process.env.ANTHROPIC_OIDC_AUDIENCE;
|
delete process.env.ANTHROPIC_OIDC_AUDIENCE;
|
||||||
delete process.env.ANTHROPIC_IDENTITY_TOKEN_FILE;
|
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(
|
getIDTokenSpy = spyOn(core, "getIDToken").mockResolvedValue(
|
||||||
"test-identity-token",
|
"test-identity-token",
|
||||||
@ -123,5 +136,123 @@ describe("workload identity federation", () => {
|
|||||||
handle?.stop();
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -318,7 +318,8 @@ async function run() {
|
|||||||
} finally {
|
} finally {
|
||||||
// Phase 4: Cleanup (always runs)
|
// 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();
|
workloadIdentity?.stop();
|
||||||
|
|
||||||
// Update tracking comment
|
// Update tracking comment
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user