mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-08-22 03:18:54 +08:00
Add Workload Identity Federation (OIDC) authentication support (#1338)
* Add workload identity federation auth support Adds anthropic_federation_rule_id, anthropic_organization_id, anthropic_service_account_id, anthropic_workspace_id, and anthropic_oidc_audience inputs. When the federation rule and organization are set, the action fetches the workflow's GitHub Actions OIDC token, writes it to a file in RUNNER_TEMP, keeps it refreshed during execution, and points the Claude Code CLI at it via ANTHROPIC_IDENTITY_TOKEN_FILE so the CLI can exchange it for a short-lived access token instead of using a static API key. * Add WIF example workflow and base-action federation docs * Default workload identity OIDC audience to https://api.anthropic.com
This commit is contained in:
@@ -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 "../utils/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),
|
||||
};
|
||||
}
|
||||
@@ -20,6 +20,11 @@ export function collectActionInputsPresence(): string {
|
||||
settings: "",
|
||||
anthropic_api_key: "",
|
||||
claude_code_oauth_token: "",
|
||||
anthropic_federation_rule_id: "",
|
||||
anthropic_organization_id: "",
|
||||
anthropic_service_account_id: "",
|
||||
anthropic_workspace_id: "",
|
||||
anthropic_oidc_audience: "",
|
||||
github_token: "",
|
||||
max_turns: "",
|
||||
use_sticky_comment: "false",
|
||||
|
||||
@@ -29,6 +29,8 @@ import { prepareAgentMode } from "../modes/agent";
|
||||
import { checkContainsTrigger } from "../github/validation/trigger";
|
||||
import { restoreConfigFromBase } from "../github/operations/restore-config";
|
||||
import { validateBranchName } from "../github/operations/branch";
|
||||
import { setupWorkloadIdentity } from "../auth/workload-identity";
|
||||
import type { WorkloadIdentityHandle } from "../auth/workload-identity";
|
||||
import { collectActionInputsPresence } from "./collect-inputs";
|
||||
import { updateCommentLink } from "./update-comment-link";
|
||||
import { formatTurnsFromData } from "./format-turns";
|
||||
@@ -150,6 +152,7 @@ async function run() {
|
||||
let prepareError: string | undefined;
|
||||
let context: GitHubContext | undefined;
|
||||
let octokit: Octokits | undefined;
|
||||
let workloadIdentity: WorkloadIdentityHandle | undefined;
|
||||
// Track whether we've completed prepare phase, so we can attribute errors correctly
|
||||
let prepareCompleted = false;
|
||||
try {
|
||||
@@ -231,6 +234,10 @@ async function run() {
|
||||
process.env.CLAUDE_CODE_ACTION = "1";
|
||||
process.env.DETAILED_PERMISSION_MESSAGES = "1";
|
||||
|
||||
// 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();
|
||||
|
||||
// On PRs, .claude/ and .mcp.json in the checkout are attacker-controlled.
|
||||
@@ -307,6 +314,9 @@ async function run() {
|
||||
} finally {
|
||||
// Phase 4: Cleanup (always runs)
|
||||
|
||||
// Stop refreshing the workload identity token file
|
||||
workloadIdentity?.stop();
|
||||
|
||||
// Update tracking comment
|
||||
if (
|
||||
commentId &&
|
||||
|
||||
Reference in New Issue
Block a user