mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-08-22 03:18:54 +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:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user