mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-08-22 11:28:55 +08:00
Add workload identity federation support to base-action (#1378)
* Add workload identity federation support to base-action Move the workload identity module into base-action so the standalone action can fetch and refresh the GitHub OIDC identity token itself, and expose the same federation inputs as the outer action. Switch the base-action test workflows from the anthropic_api_key secret to the federation repo variables and grant them id-token: write. * Verify MCP test tool invocation instead of init connection status MCP servers can connect asynchronously, so the init event may report a server as pending. Check that the server is registered at init, then assert the test tool was actually called and returned its response. Also pass the MCP config through claude_args --mcp-config, replacing the removed mcp_config input.
This commit is contained in:
+40
-32
@@ -93,45 +93,53 @@ Add the following to your workflow file:
|
||||
|
||||
### Workload Identity Federation
|
||||
|
||||
Instead of a static API key or OAuth token, you can authenticate via [Workload Identity Federation](https://platform.claude.com/docs/en/manage-claude/workload-identity-federation) by setting the federation environment variables on the step. Fetch an OIDC identity token from your provider, write it to a file, and point the action at it:
|
||||
Instead of a static API key or OAuth token, you can authenticate via [Workload Identity Federation](https://platform.claude.com/docs/en/manage-claude/workload-identity-federation): the action fetches the workflow's GitHub OIDC token and the Claude Code CLI exchanges it for a short-lived access token. Requires the `id-token: write` permission on the job:
|
||||
|
||||
```yaml
|
||||
- name: Run Claude Code with workload identity federation
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt: "Your prompt here"
|
||||
env:
|
||||
ANTHROPIC_FEDERATION_RULE_ID: fdrl_xxxxxxxxxxxx
|
||||
ANTHROPIC_ORGANIZATION_ID: 00000000-0000-0000-0000-000000000000
|
||||
ANTHROPIC_SERVICE_ACCOUNT_ID: svac_xxxxxxxxxxxx
|
||||
ANTHROPIC_IDENTITY_TOKEN_FILE: /path/to/identity-token
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Run Claude Code with workload identity federation
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt: "Your prompt here"
|
||||
anthropic_federation_rule_id: fdrl_xxxxxxxxxxxx
|
||||
anthropic_organization_id: 00000000-0000-0000-0000-000000000000
|
||||
anthropic_service_account_id: svac_xxxxxxxxxxxx
|
||||
```
|
||||
|
||||
Note: the base action does not fetch or refresh the identity token itself — you are responsible for providing a valid token file. [`anthropics/claude-code-action`](https://github.com/anthropics/claude-code-action) handles fetching and refreshing the GitHub Actions OIDC token automatically via its `anthropic_federation_rule_id` input.
|
||||
Do not set `anthropic_api_key` or `claude_code_oauth_token` alongside the federation inputs — a static credential takes precedence and federation will not be used.
|
||||
|
||||
## Inputs
|
||||
|
||||
| Input | Description | Required | Default |
|
||||
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -------- | ---------------------------- |
|
||||
| `prompt` | The prompt to send to Claude Code | No\* | '' |
|
||||
| `prompt_file` | Path to a file containing the prompt to send to Claude Code | No\* | '' |
|
||||
| `allowed_tools` | Comma-separated list of allowed tools for Claude Code to use | No | '' |
|
||||
| `disallowed_tools` | Comma-separated list of disallowed tools that Claude Code cannot use | No | '' |
|
||||
| `max_turns` | Maximum number of conversation turns (default: no limit) | No | '' |
|
||||
| `mcp_config` | Path to the MCP configuration JSON file, or MCP configuration JSON string | No | '' |
|
||||
| `settings` | Path to Claude Code settings JSON file, or settings JSON string | No | '' |
|
||||
| `system_prompt` | Override system prompt | No | '' |
|
||||
| `append_system_prompt` | Append to system prompt | No | '' |
|
||||
| `claude_env` | Custom environment variables to pass to Claude Code execution (YAML multiline format) | No | '' |
|
||||
| `model` | Model to use (provider-specific format required for Bedrock/Vertex) | No | 'claude-4-0-sonnet-20250219' |
|
||||
| `anthropic_model` | DEPRECATED: Use 'model' instead | No | 'claude-4-0-sonnet-20250219' |
|
||||
| `fallback_model` | Enable automatic fallback to specified model when default model is overloaded | No | '' |
|
||||
| `anthropic_api_key` | Anthropic API key (required for direct Anthropic API) | No | '' |
|
||||
| `claude_code_oauth_token` | Claude Code OAuth token (alternative to anthropic_api_key) | No | '' |
|
||||
| `use_bedrock` | Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API | No | 'false' |
|
||||
| `use_vertex` | Use Google Vertex AI with OIDC authentication instead of direct Anthropic API | No | 'false' |
|
||||
| `use_node_cache` | Whether to use Node.js dependency caching (set to true only for Node.js projects with lock files) | No | 'false' |
|
||||
| `show_full_output` | Show full JSON output (⚠️ May expose secrets - see [security docs](../docs/security.md#️-full-output-security-warning)) | No | 'false'\*\* |
|
||||
| Input | Description | Required | Default |
|
||||
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | -------- | ---------------------------- |
|
||||
| `prompt` | The prompt to send to Claude Code | No\* | '' |
|
||||
| `prompt_file` | Path to a file containing the prompt to send to Claude Code | No\* | '' |
|
||||
| `allowed_tools` | Comma-separated list of allowed tools for Claude Code to use | No | '' |
|
||||
| `disallowed_tools` | Comma-separated list of disallowed tools that Claude Code cannot use | No | '' |
|
||||
| `max_turns` | Maximum number of conversation turns (default: no limit) | No | '' |
|
||||
| `mcp_config` | Path to the MCP configuration JSON file, or MCP configuration JSON string | No | '' |
|
||||
| `settings` | Path to Claude Code settings JSON file, or settings JSON string | No | '' |
|
||||
| `system_prompt` | Override system prompt | No | '' |
|
||||
| `append_system_prompt` | Append to system prompt | No | '' |
|
||||
| `claude_env` | Custom environment variables to pass to Claude Code execution (YAML multiline format) | No | '' |
|
||||
| `model` | Model to use (provider-specific format required for Bedrock/Vertex) | No | 'claude-4-0-sonnet-20250219' |
|
||||
| `anthropic_model` | DEPRECATED: Use 'model' instead | No | 'claude-4-0-sonnet-20250219' |
|
||||
| `fallback_model` | Enable automatic fallback to specified model when default model is overloaded | No | '' |
|
||||
| `anthropic_api_key` | Anthropic API key (required for direct Anthropic API) | No | '' |
|
||||
| `claude_code_oauth_token` | Claude Code OAuth token (alternative to anthropic_api_key) | No | '' |
|
||||
| `anthropic_federation_rule_id` | Workload identity federation rule ID (fdrl\_...). Requires `id-token: write` permission | No | '' |
|
||||
| `anthropic_organization_id` | Anthropic organization UUID used for workload identity federation | No | '' |
|
||||
| `anthropic_service_account_id` | Service account ID (svac\_...) the federated token acts as (optional) | No | '' |
|
||||
| `anthropic_workspace_id` | Workspace ID (wrkspc\_...) for federation. Optional when the rule targets a single workspace | No | '' |
|
||||
| `anthropic_oidc_audience` | Audience to request on the GitHub OIDC token. Defaults to https://api.anthropic.com | No | '' |
|
||||
| `use_bedrock` | Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API | No | 'false' |
|
||||
| `use_vertex` | Use Google Vertex AI with OIDC authentication instead of direct Anthropic API | No | 'false' |
|
||||
| `use_node_cache` | Whether to use Node.js dependency caching (set to true only for Node.js projects with lock files) | No | 'false' |
|
||||
| `show_full_output` | Show full JSON output (⚠️ May expose secrets - see [security docs](../docs/security.md#️-full-output-security-warning)) | No | 'false'\*\* |
|
||||
|
||||
\*Either `prompt` or `prompt_file` must be provided, but not both.
|
||||
|
||||
|
||||
@@ -34,6 +34,26 @@ inputs:
|
||||
description: "Claude Code OAuth token (alternative to anthropic_api_key)"
|
||||
required: false
|
||||
default: ""
|
||||
anthropic_federation_rule_id:
|
||||
description: "Workload identity federation rule ID (fdrl_...). When set with anthropic_organization_id, the action authenticates to the Claude API by exchanging the workflow's GitHub OIDC token instead of using a static API key. Requires `id-token: write` permission."
|
||||
required: false
|
||||
default: ""
|
||||
anthropic_organization_id:
|
||||
description: "Anthropic organization UUID used for workload identity federation"
|
||||
required: false
|
||||
default: ""
|
||||
anthropic_service_account_id:
|
||||
description: "Service account ID (svac_...) the federated token acts as (optional, used with workload identity federation)"
|
||||
required: false
|
||||
default: ""
|
||||
anthropic_workspace_id:
|
||||
description: "Workspace ID (wrkspc_...) for workload identity federation. Optional when the federation rule targets a single workspace."
|
||||
required: false
|
||||
default: ""
|
||||
anthropic_oidc_audience:
|
||||
description: "Audience to request on the GitHub OIDC token used for workload identity federation. Defaults to https://api.anthropic.com."
|
||||
required: false
|
||||
default: ""
|
||||
use_bedrock:
|
||||
description: "Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API"
|
||||
required: false
|
||||
@@ -175,6 +195,11 @@ runs:
|
||||
# Provider configuration
|
||||
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ inputs.claude_code_oauth_token }}
|
||||
ANTHROPIC_FEDERATION_RULE_ID: ${{ inputs.anthropic_federation_rule_id }}
|
||||
ANTHROPIC_ORGANIZATION_ID: ${{ inputs.anthropic_organization_id }}
|
||||
ANTHROPIC_SERVICE_ACCOUNT_ID: ${{ inputs.anthropic_service_account_id }}
|
||||
ANTHROPIC_WORKSPACE_ID: ${{ inputs.anthropic_workspace_id }}
|
||||
ANTHROPIC_OIDC_AUDIENCE: ${{ inputs.anthropic_oidc_audience }}
|
||||
ANTHROPIC_BASE_URL: ${{ env.ANTHROPIC_BASE_URL }}
|
||||
ANTHROPIC_CUSTOM_HEADERS: ${{ env.ANTHROPIC_CUSTOM_HEADERS }}
|
||||
# Only set provider flags if explicitly true, since any value (including "false") is truthy
|
||||
|
||||
@@ -7,9 +7,16 @@ import { setupClaudeCodeSettings } from "./setup-claude-code-settings";
|
||||
import { validateEnvironmentVariables } from "./validate-env";
|
||||
import { installPlugins } from "./install-plugins";
|
||||
import { setExecutionFileOutputIfPresent } from "./execution-file";
|
||||
import { setupWorkloadIdentity } from "./workload-identity";
|
||||
import type { WorkloadIdentityHandle } from "./workload-identity";
|
||||
|
||||
async function run() {
|
||||
let workloadIdentity: WorkloadIdentityHandle | undefined;
|
||||
try {
|
||||
// When workload identity federation is configured, fetch the GitHub OIDC
|
||||
// identity token and expose it to the CLI before validating auth env vars.
|
||||
workloadIdentity = await setupWorkloadIdentity();
|
||||
|
||||
validateEnvironmentVariables();
|
||||
|
||||
// The composite action's "Install Claude Code" step writes the binary to
|
||||
@@ -67,6 +74,9 @@ async function run() {
|
||||
core.setFailed(`Action failed with error: ${error}`);
|
||||
core.setOutput("conclusion", "failure");
|
||||
process.exit(1);
|
||||
} finally {
|
||||
// Stop refreshing the workload identity token file so the process can exit
|
||||
workloadIdentity?.stop();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
export type RetryOptions = {
|
||||
maxAttempts?: number;
|
||||
initialDelayMs?: number;
|
||||
maxDelayMs?: number;
|
||||
backoffFactor?: number;
|
||||
shouldRetry?: (error: Error) => boolean;
|
||||
};
|
||||
|
||||
export async function retryWithBackoff<T>(
|
||||
operation: () => Promise<T>,
|
||||
options: RetryOptions = {},
|
||||
): Promise<T> {
|
||||
const {
|
||||
maxAttempts = 3,
|
||||
initialDelayMs = 5000,
|
||||
maxDelayMs = 20000,
|
||||
backoffFactor = 2,
|
||||
shouldRetry,
|
||||
} = options;
|
||||
|
||||
let delayMs = initialDelayMs;
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
console.log(`Attempt ${attempt} of ${maxAttempts}...`);
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
console.error(`Attempt ${attempt} failed:`, lastError.message);
|
||||
|
||||
if (shouldRetry && !shouldRetry(lastError)) {
|
||||
console.error("Error is not retryable, giving up immediately");
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
if (attempt < maxAttempts) {
|
||||
console.log(`Retrying in ${delayMs / 1000} seconds...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
delayMs = Math.min(delayMs * backoffFactor, maxDelayMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`Operation failed after ${maxAttempts} attempts`);
|
||||
throw lastError;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* Workload Identity Federation support.
|
||||
*
|
||||
* When the federation inputs are configured, the action fetches a GitHub
|
||||
* Actions OIDC token (JWT), writes it to a file, and points the Claude Code
|
||||
* CLI at it via ANTHROPIC_IDENTITY_TOKEN_FILE. The CLI exchanges the JWT for
|
||||
* a short-lived Anthropic access token using the federation rule, so no
|
||||
* static ANTHROPIC_API_KEY is needed.
|
||||
*
|
||||
* GitHub's OIDC tokens are short-lived and the CLI re-reads the token file
|
||||
* every time it refreshes its Anthropic access token, so the action keeps the
|
||||
* file fresh in the background for long-running executions.
|
||||
*/
|
||||
|
||||
import * as core from "@actions/core";
|
||||
import { mkdirSync, writeFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { retryWithBackoff } from "./retry";
|
||||
|
||||
/** How often the GitHub OIDC identity token file is rewritten. */
|
||||
const REFRESH_INTERVAL_MS = 4 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Default audience requested on the GitHub OIDC token. Scopes the JWT to the
|
||||
* Claude API token exchange; override with the anthropic_oidc_audience input
|
||||
* if your federation rule expects a different audience.
|
||||
*/
|
||||
const DEFAULT_OIDC_AUDIENCE = "https://api.anthropic.com";
|
||||
|
||||
export type WorkloadIdentityHandle = {
|
||||
tokenFile: string;
|
||||
stop: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the workload identity federation inputs are configured.
|
||||
* Mirrors the Claude Code CLI's env detection, which requires the federation
|
||||
* rule ID and organization ID.
|
||||
*/
|
||||
export function isWorkloadIdentityConfigured(): boolean {
|
||||
return Boolean(
|
||||
process.env.ANTHROPIC_FEDERATION_RULE_ID?.trim() &&
|
||||
process.env.ANTHROPIC_ORGANIZATION_ID?.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchIdentityToken(audience: string) {
|
||||
return retryWithBackoff(() => core.getIDToken(audience));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* the file stays valid for long executions.
|
||||
*
|
||||
* Returns undefined when federation is not configured or is shadowed by a
|
||||
* higher-precedence credential. Callers must invoke stop() when execution
|
||||
* finishes.
|
||||
*/
|
||||
export async function setupWorkloadIdentity(): Promise<
|
||||
WorkloadIdentityHandle | undefined
|
||||
> {
|
||||
if (!isWorkloadIdentityConfigured()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
process.env.ANTHROPIC_API_KEY?.trim() ||
|
||||
process.env.CLAUDE_CODE_OAUTH_TOKEN?.trim()
|
||||
) {
|
||||
core.warning(
|
||||
"Workload identity federation inputs are set alongside anthropic_api_key or claude_code_oauth_token. The API key/OAuth token takes precedence, so federation will not be used.",
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const audience =
|
||||
process.env.ANTHROPIC_OIDC_AUDIENCE?.trim() || DEFAULT_OIDC_AUDIENCE;
|
||||
const tokenDir = join(
|
||||
process.env.RUNNER_TEMP || "/tmp",
|
||||
"claude-workload-identity",
|
||||
);
|
||||
const tokenFile = join(tokenDir, "identity-token");
|
||||
|
||||
const writeIdentityToken = async () => {
|
||||
const identityToken = await fetchIdentityToken(audience);
|
||||
core.setSecret(identityToken);
|
||||
mkdirSync(tokenDir, { recursive: true, mode: 0o700 });
|
||||
writeFileSync(tokenFile, identityToken, { mode: 0o600 });
|
||||
};
|
||||
|
||||
try {
|
||||
await writeIdentityToken();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(
|
||||
`Failed to fetch a GitHub Actions OIDC token for workload identity federation: ${message}. Did you remember to add \`id-token: write\` to your workflow permissions?`,
|
||||
);
|
||||
}
|
||||
|
||||
process.env.ANTHROPIC_IDENTITY_TOKEN_FILE = tokenFile;
|
||||
console.log(
|
||||
`Workload identity federation configured (rule: ${process.env.ANTHROPIC_FEDERATION_RULE_ID}, identity token file: ${tokenFile})`,
|
||||
);
|
||||
|
||||
const refreshInterval = setInterval(() => {
|
||||
writeIdentityToken().catch((error) => {
|
||||
core.warning(
|
||||
`Failed to refresh the GitHub Actions OIDC identity token: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
});
|
||||
}, REFRESH_INTERVAL_MS);
|
||||
|
||||
return {
|
||||
tokenFile,
|
||||
stop: () => clearInterval(refreshInterval),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test";
|
||||
import { retryWithBackoff } from "../src/retry";
|
||||
|
||||
describe("retryWithBackoff", () => {
|
||||
let originalConsoleLog: typeof console.log;
|
||||
let originalConsoleError: typeof console.error;
|
||||
|
||||
beforeEach(() => {
|
||||
originalConsoleLog = console.log;
|
||||
originalConsoleError = console.error;
|
||||
console.log = mock(() => {});
|
||||
console.error = mock(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
console.log = originalConsoleLog;
|
||||
console.error = originalConsoleError;
|
||||
});
|
||||
|
||||
it("returns the result on first success", async () => {
|
||||
const result = await retryWithBackoff(() => Promise.resolve("ok"), {
|
||||
maxAttempts: 3,
|
||||
initialDelayMs: 1,
|
||||
});
|
||||
expect(result).toBe("ok");
|
||||
});
|
||||
|
||||
it("retries on failure and succeeds", async () => {
|
||||
let attempt = 0;
|
||||
const result = await retryWithBackoff(
|
||||
() => {
|
||||
attempt++;
|
||||
if (attempt < 3) throw new Error("transient");
|
||||
return Promise.resolve("recovered");
|
||||
},
|
||||
{ maxAttempts: 3, initialDelayMs: 1 },
|
||||
);
|
||||
expect(result).toBe("recovered");
|
||||
expect(attempt).toBe(3);
|
||||
});
|
||||
|
||||
it("throws after exhausting all attempts", async () => {
|
||||
await expect(
|
||||
retryWithBackoff(() => Promise.reject(new Error("permanent")), {
|
||||
maxAttempts: 2,
|
||||
initialDelayMs: 1,
|
||||
}),
|
||||
).rejects.toThrow("permanent");
|
||||
});
|
||||
|
||||
it("stops retrying immediately when shouldRetry returns false", async () => {
|
||||
class NonRetryableError extends Error {
|
||||
constructor() {
|
||||
super("non-retryable");
|
||||
this.name = "NonRetryableError";
|
||||
}
|
||||
}
|
||||
|
||||
let attempts = 0;
|
||||
await expect(
|
||||
retryWithBackoff(
|
||||
() => {
|
||||
attempts++;
|
||||
throw new NonRetryableError();
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
initialDelayMs: 1,
|
||||
shouldRetry: (error) => !(error instanceof NonRetryableError),
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("non-retryable");
|
||||
expect(attempts).toBe(1);
|
||||
});
|
||||
|
||||
it("continues retrying when shouldRetry returns true", async () => {
|
||||
let attempts = 0;
|
||||
await expect(
|
||||
retryWithBackoff(
|
||||
() => {
|
||||
attempts++;
|
||||
throw new Error("retryable");
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
initialDelayMs: 1,
|
||||
shouldRetry: () => true,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("retryable");
|
||||
expect(attempts).toBe(3);
|
||||
});
|
||||
|
||||
it("preserves the original error when shouldRetry aborts", async () => {
|
||||
class SpecificError extends Error {
|
||||
code = 401;
|
||||
constructor() {
|
||||
super("unauthorized");
|
||||
this.name = "SpecificError";
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await retryWithBackoff(
|
||||
() => {
|
||||
throw new SpecificError();
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
initialDelayMs: 1,
|
||||
shouldRetry: (error) => !(error instanceof SpecificError),
|
||||
},
|
||||
);
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(SpecificError);
|
||||
expect((error as SpecificError).code).toBe(401);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
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 { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import {
|
||||
isWorkloadIdentityConfigured,
|
||||
setupWorkloadIdentity,
|
||||
} from "../src/workload-identity";
|
||||
|
||||
describe("workload identity federation", () => {
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
let tempDir: string;
|
||||
let getIDTokenSpy: ReturnType<typeof spyOn>;
|
||||
let warningSpy: ReturnType<typeof spyOn>;
|
||||
let setSecretSpy: ReturnType<typeof spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
originalEnv = { ...process.env };
|
||||
tempDir = mkdtempSync(join(tmpdir(), "wif-test-"));
|
||||
process.env.RUNNER_TEMP = tempDir;
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
delete process.env.ANTHROPIC_FEDERATION_RULE_ID;
|
||||
delete process.env.ANTHROPIC_ORGANIZATION_ID;
|
||||
delete process.env.ANTHROPIC_OIDC_AUDIENCE;
|
||||
delete process.env.ANTHROPIC_IDENTITY_TOKEN_FILE;
|
||||
|
||||
getIDTokenSpy = spyOn(core, "getIDToken").mockResolvedValue(
|
||||
"test-identity-token",
|
||||
);
|
||||
warningSpy = spyOn(core, "warning").mockImplementation(() => {});
|
||||
setSecretSpy = spyOn(core, "setSecret").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
getIDTokenSpy.mockRestore();
|
||||
warningSpy.mockRestore();
|
||||
setSecretSpy.mockRestore();
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("isWorkloadIdentityConfigured", () => {
|
||||
test("returns false when no federation variables are set", () => {
|
||||
expect(isWorkloadIdentityConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false when only one federation variable is set", () => {
|
||||
process.env.ANTHROPIC_FEDERATION_RULE_ID = "fdrl_test";
|
||||
expect(isWorkloadIdentityConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
test("returns true when rule ID and organization ID are set", () => {
|
||||
process.env.ANTHROPIC_FEDERATION_RULE_ID = "fdrl_test";
|
||||
process.env.ANTHROPIC_ORGANIZATION_ID =
|
||||
"00000000-0000-0000-0000-000000000000";
|
||||
expect(isWorkloadIdentityConfigured()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setupWorkloadIdentity", () => {
|
||||
test("returns undefined when federation is not configured", async () => {
|
||||
const handle = await setupWorkloadIdentity();
|
||||
expect(handle).toBeUndefined();
|
||||
expect(getIDTokenSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns undefined and warns when an API key is also set", async () => {
|
||||
process.env.ANTHROPIC_FEDERATION_RULE_ID = "fdrl_test";
|
||||
process.env.ANTHROPIC_ORGANIZATION_ID =
|
||||
"00000000-0000-0000-0000-000000000000";
|
||||
process.env.ANTHROPIC_API_KEY = "sk-ant-test";
|
||||
|
||||
const handle = await setupWorkloadIdentity();
|
||||
expect(handle).toBeUndefined();
|
||||
expect(warningSpy).toHaveBeenCalled();
|
||||
expect(getIDTokenSpy).not.toHaveBeenCalled();
|
||||
expect(process.env.ANTHROPIC_IDENTITY_TOKEN_FILE).toBeUndefined();
|
||||
});
|
||||
|
||||
test("writes the identity token file and exports its path", async () => {
|
||||
process.env.ANTHROPIC_FEDERATION_RULE_ID = "fdrl_test";
|
||||
process.env.ANTHROPIC_ORGANIZATION_ID =
|
||||
"00000000-0000-0000-0000-000000000000";
|
||||
|
||||
const handle = await setupWorkloadIdentity();
|
||||
try {
|
||||
expect(handle).toBeDefined();
|
||||
expect(handle!.tokenFile).toBe(
|
||||
join(tempDir, "claude-workload-identity", "identity-token"),
|
||||
);
|
||||
expect(process.env.ANTHROPIC_IDENTITY_TOKEN_FILE).toBe(
|
||||
handle!.tokenFile,
|
||||
);
|
||||
expect(existsSync(handle!.tokenFile)).toBe(true);
|
||||
expect(readFileSync(handle!.tokenFile, "utf-8")).toBe(
|
||||
"test-identity-token",
|
||||
);
|
||||
expect(statSync(handle!.tokenFile).mode & 0o777).toBe(0o600);
|
||||
expect(setSecretSpy).toHaveBeenCalledWith("test-identity-token");
|
||||
// Default audience scopes the JWT to the Claude API token exchange
|
||||
expect(getIDTokenSpy).toHaveBeenCalledWith("https://api.anthropic.com");
|
||||
} finally {
|
||||
handle?.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("requests the configured audience", async () => {
|
||||
process.env.ANTHROPIC_FEDERATION_RULE_ID = "fdrl_test";
|
||||
process.env.ANTHROPIC_ORGANIZATION_ID =
|
||||
"00000000-0000-0000-0000-000000000000";
|
||||
process.env.ANTHROPIC_OIDC_AUDIENCE = "https://example.com/custom";
|
||||
|
||||
const handle = await setupWorkloadIdentity();
|
||||
try {
|
||||
expect(getIDTokenSpy).toHaveBeenCalledWith(
|
||||
"https://example.com/custom",
|
||||
);
|
||||
} finally {
|
||||
handle?.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user