diff --git a/README.md b/README.md index b8301f71a..b0b5c7382 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # Claude Code Action -A general-purpose [Claude Code](https://claude.ai/code) action for GitHub PRs and issues that can answer questions and implement code changes. This action intelligently detects when to activate based on your workflow context—whether responding to @claude mentions, issue assignments, or executing automation tasks with explicit prompts. It supports multiple authentication methods including Anthropic direct API, Amazon Bedrock, Google Vertex AI, and Microsoft Foundry. +A general-purpose [Claude Code](https://claude.ai/code) action for GitHub PRs and issues that can answer questions and implement code changes. This action intelligently detects when to activate based on your workflow context—whether responding to @claude mentions, issue assignments, or executing automation tasks with explicit prompts. It supports multiple authentication methods including Anthropic direct API (API key or workload identity federation), Amazon Bedrock, Google Vertex AI, and Microsoft Foundry. ## Features diff --git a/action.yml b/action.yml index ee05a529e..1d6270a9d 100644 --- a/action.yml +++ b/action.yml @@ -70,6 +70,21 @@ inputs: claude_code_oauth_token: description: "Claude Code OAuth token (alternative to anthropic_api_key)" required: false + 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 + anthropic_organization_id: + description: "Anthropic organization UUID used for workload identity federation" + required: false + anthropic_service_account_id: + description: "Service account ID (svac_...) the federated token acts as (optional, used with workload identity federation)" + required: false + anthropic_workspace_id: + description: "Workspace ID (wrkspc_...) for workload identity federation. Optional when the federation rule targets a single workspace." + required: false + 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 github_token: description: "GitHub token with repo and pull request permissions (optional if using GitHub App)" required: false @@ -294,6 +309,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 }} CLAUDE_CODE_USE_BEDROCK: ${{ inputs.use_bedrock == 'true' && '1' || '' }} diff --git a/base-action/README.md b/base-action/README.md index fa750badb..792c19ac2 100644 --- a/base-action/README.md +++ b/base-action/README.md @@ -91,6 +91,24 @@ Add the following to your workflow file: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} ``` +### 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: + +```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 +``` + +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. + ## Inputs | Input | Description | Required | Default | diff --git a/base-action/src/validate-env.ts b/base-action/src/validate-env.ts index 1f28da37e..7fc17be91 100644 --- a/base-action/src/validate-env.ts +++ b/base-action/src/validate-env.ts @@ -8,6 +8,14 @@ export function validateEnvironmentVariables() { const useFoundry = process.env.CLAUDE_CODE_USE_FOUNDRY === "1"; const anthropicApiKey = process.env.ANTHROPIC_API_KEY; const claudeCodeOAuthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN; + const federationRuleId = process.env.ANTHROPIC_FEDERATION_RULE_ID; + const federationOrganizationId = process.env.ANTHROPIC_ORGANIZATION_ID; + const hasWorkloadIdentity = Boolean( + federationRuleId && federationOrganizationId, + ); + const hasPartialWorkloadIdentity = + !hasWorkloadIdentity && + Boolean(federationRuleId || federationOrganizationId); const errors: string[] = []; @@ -20,10 +28,16 @@ export function validateEnvironmentVariables() { } if (!useBedrock && !useVertex && !useFoundry) { - if (!anthropicApiKey && !claudeCodeOAuthToken) { - errors.push( - "Either ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN is required when using direct Anthropic API.", - ); + if (!anthropicApiKey && !claudeCodeOAuthToken && !hasWorkloadIdentity) { + if (hasPartialWorkloadIdentity) { + errors.push( + "Workload identity federation requires both ANTHROPIC_FEDERATION_RULE_ID and ANTHROPIC_ORGANIZATION_ID to be set.", + ); + } else { + errors.push( + "Either ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN, or workload identity federation (ANTHROPIC_FEDERATION_RULE_ID and ANTHROPIC_ORGANIZATION_ID) is required when using direct Anthropic API.", + ); + } } } else if (useBedrock) { const awsRegion = process.env.AWS_REGION; diff --git a/base-action/test/validate-env.test.ts b/base-action/test/validate-env.test.ts index 4a4b09334..69d4f56c4 100644 --- a/base-action/test/validate-env.test.ts +++ b/base-action/test/validate-env.test.ts @@ -11,6 +11,8 @@ describe("validateEnvironmentVariables", () => { originalEnv = { ...process.env }; // Clear relevant environment variables delete process.env.ANTHROPIC_API_KEY; + delete process.env.ANTHROPIC_FEDERATION_RULE_ID; + delete process.env.ANTHROPIC_ORGANIZATION_ID; delete process.env.CLAUDE_CODE_USE_BEDROCK; delete process.env.CLAUDE_CODE_USE_VERTEX; delete process.env.CLAUDE_CODE_USE_FOUNDRY; @@ -42,7 +44,32 @@ describe("validateEnvironmentVariables", () => { test("should fail when ANTHROPIC_API_KEY is missing", () => { expect(() => validateEnvironmentVariables()).toThrow( - "Either ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN is required when using direct Anthropic API.", + "Either ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN, or workload identity federation (ANTHROPIC_FEDERATION_RULE_ID and ANTHROPIC_ORGANIZATION_ID) is required when using direct Anthropic API.", + ); + }); + + test("should pass when workload identity federation variables are provided", () => { + process.env.ANTHROPIC_FEDERATION_RULE_ID = "fdrl_test"; + process.env.ANTHROPIC_ORGANIZATION_ID = + "00000000-0000-0000-0000-000000000000"; + + expect(() => validateEnvironmentVariables()).not.toThrow(); + }); + + test("should fail when only ANTHROPIC_FEDERATION_RULE_ID is provided", () => { + process.env.ANTHROPIC_FEDERATION_RULE_ID = "fdrl_test"; + + expect(() => validateEnvironmentVariables()).toThrow( + "Workload identity federation requires both ANTHROPIC_FEDERATION_RULE_ID and ANTHROPIC_ORGANIZATION_ID to be set.", + ); + }); + + test("should fail when only ANTHROPIC_ORGANIZATION_ID is provided", () => { + process.env.ANTHROPIC_ORGANIZATION_ID = + "00000000-0000-0000-0000-000000000000"; + + expect(() => validateEnvironmentVariables()).toThrow( + "Workload identity federation requires both ANTHROPIC_FEDERATION_RULE_ID and ANTHROPIC_ORGANIZATION_ID to be set.", ); }); }); diff --git a/docs/setup.md b/docs/setup.md index e0c7f56c8..695f8af9f 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -10,6 +10,52 @@ - Or `CLAUDE_CODE_OAUTH_TOKEN` for OAuth token authentication (Pro and Max users can generate this by running `claude setup-token` locally) 3. Copy the workflow file from [`examples/claude.yml`](../examples/claude.yml) into your repository's `.github/workflows/` +> Don't want to store a static API key at all? See [Workload Identity Federation](#workload-identity-federation) below. + +## Workload Identity Federation + +Workload Identity Federation (WIF) lets the action authenticate to the Claude API by exchanging the workflow's GitHub Actions OIDC token for a short-lived Anthropic access token — no `ANTHROPIC_API_KEY` secret to create, store, or rotate. + +### One-time setup in the Claude Console + +You need admin access to your Anthropic organization (Console → **Settings → Workload identity**): + +1. **Register an issuer** for GitHub Actions with issuer URL `https://token.actions.githubusercontent.com` (JWKS source: `discovery`). +2. **Create a service account** (Settings → Service accounts) and add it to the workspace it should act in. Note the `svac_...` ID. +3. **Create a federation rule** targeting that service account, matched to your repository's OIDC claims (for example a subject prefix of `repo:your-org/your-repo:`). Note the `fdrl_...` rule ID. + +See the [Workload Identity Federation documentation](https://platform.claude.com/docs/en/manage-claude/workload-identity-federation) for full details. + +### Workflow configuration + +```yaml +jobs: + claude-response: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + issues: write + id-token: write # required: used to fetch the GitHub OIDC token + steps: + - uses: anthropics/claude-code-action@v1 + with: + anthropic_federation_rule_id: fdrl_xxxxxxxxxxxx + anthropic_organization_id: 00000000-0000-0000-0000-000000000000 + anthropic_service_account_id: svac_xxxxxxxxxxxx + # Optional when the federation rule targets a single workspace: + anthropic_workspace_id: wrkspc_xxxxxxxxxxxx +``` + +These values are identifiers, not credentials, so they can live directly in the workflow file (or in repository variables). + +Notes: + +- The workflow must grant `id-token: write` permission so the action can fetch a GitHub OIDC token. The default GitHub App authentication path already requires this permission. +- 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. +- The GitHub OIDC token is requested with audience `https://api.anthropic.com` by default, so set the federation rule's expected audience to that value (or leave the rule's audience unmatched). Use `anthropic_oidc_audience` only if your rule expects a different audience. +- Inline comment classification (`classify_inline_comments`) currently requires `anthropic_api_key`; with federation it is skipped and unconfirmed inline comments are posted directly. + ## Using a Custom GitHub App If you prefer not to install the official Claude app, you can create your own GitHub App to use with this action. This gives you complete control over permissions and access. diff --git a/docs/usage.md b/docs/usage.md index 7f1be0fec..ade075a75 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -52,38 +52,43 @@ jobs: ## Inputs -| Input | Description | Required | Default | -| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------------- | -| `anthropic_api_key` | Anthropic API key (required for direct API, not needed for Bedrock/Vertex) | No\* | - | -| `claude_code_oauth_token` | Claude Code OAuth token (alternative to anthropic_api_key) | No\* | - | -| `prompt` | Instructions for Claude. Can be a direct prompt or custom template for automation workflows | No | - | -| `track_progress` | Force tag mode with tracking comments. Only works with specific PR/issue events. Preserves GitHub context | No | `false` | -| `include_fix_links` | Include 'Fix this' links in PR code review feedback that open Claude Code with context to fix the identified issue | No | `true` | -| `claude_args` | Additional [arguments to pass directly to Claude CLI](https://docs.claude.com/en/docs/claude-code/cli-reference#cli-flags) (e.g., `--max-turns 10 --model claude-4-0-sonnet-20250805`) | No | "" | -| `base_branch` | The base branch to use for creating new branches (e.g., 'main', 'develop') | No | - | -| `use_sticky_comment` | Use just one comment to deliver PR comments (only applies for pull_request event workflows) | No | `false` | -| `classify_inline_comments` | Buffer inline comments without `confirmed: true` and classify them (real review vs test/probe) via Haiku before posting after the session ends. Prevents subagent test comments. Set `'false'` to post all inline comments immediately | No | `true` | -| `github_token` | GitHub token for Claude to operate with. **Only include this if you're connecting a custom GitHub app of your own!** | 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` | -| `assignee_trigger` | The assignee username that triggers the action (e.g. @claude). Only used for issue assignment | No | - | -| `label_trigger` | The label name that triggers the action when applied to an issue (e.g. "claude") | No | - | -| `trigger_phrase` | The trigger phrase to look for in comments, issue/PR bodies, and issue titles | No | `@claude` | -| `branch_prefix` | The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format) | No | `claude/` | -| `settings` | Claude Code settings as JSON string or path to settings JSON file | No | "" | -| `additional_permissions` | Additional permissions to enable. Currently supports 'actions: read' for viewing workflow results | No | "" | -| `use_commit_signing` | Enable commit signing using GitHub's API. Simple but cannot perform complex git operations like rebasing. See [Security](./security.md#commit-signing) | No | `false` | -| `ssh_signing_key` | SSH private key for signing commits. Enables signed commits with full git CLI support (rebasing, etc.). See [Security](./security.md#commit-signing) | No | "" | -| `bot_id` | GitHub user ID to use for git operations (defaults to Claude's bot ID). Required with `ssh_signing_key` for verified commits | No | `41898282` | -| `bot_name` | GitHub username to use for git operations (defaults to Claude's bot name). Required with `ssh_signing_key` for verified commits | No | `claude[bot]` | -| `include_comments_by_actor` | Comma-separated list of actor usernames to INCLUDE in comments. Supports the `*[bot]` wildcard to match all bot accounts. Empty (default) includes all actors | No | "" | -| `exclude_comments_by_actor` | Comma-separated list of actor usernames to EXCLUDE from comments. Supports the `*[bot]` wildcard to match all bot accounts. If an actor matches both lists, exclusion takes priority | No | "" | -| `allowed_bots` | Comma-separated list of allowed bot usernames, or '\*' to allow all bots. Empty string (default) allows no bots. **⚠️ On public repos with `'*'`, external Apps may be able to invoke this action.** See [Security](./security.md) | No | "" | -| `allowed_non_write_users` | **⚠️ RISKY**: Comma-separated list of usernames to allow without write permissions, or '\*' for all users. Only works with `github_token` input. See [Security](./security.md) | No | "" | -| `path_to_claude_code_executable` | Optional path to a custom Claude Code executable. Skips automatic installation. Useful for Nix, custom containers, or specialized environments | No | "" | -| `path_to_bun_executable` | Optional path to a custom Bun executable. Skips automatic Bun installation. Useful for Nix, custom containers, or specialized environments | No | "" | -| `plugin_marketplaces` | Newline-separated list of Claude Code plugin marketplace Git URLs to install from (e.g., see example in workflow above). Marketplaces are added before plugin installation | No | "" | -| `plugins` | Newline-separated list of Claude Code plugin names to install (e.g., see example in workflow above). Plugins are installed before Claude Code execution | No | "" | +| Input | Description | Required | Default | +| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | --------------------------- | +| `anthropic_api_key` | Anthropic API key (required for direct API, not needed for Bedrock/Vertex) | 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_...`). With `anthropic_organization_id`, authenticates via the workflow's GitHub OIDC token instead of a static API key. See [Setup Guide](./setup.md#workload-identity-federation) | No\* | - | +| `anthropic_organization_id` | Anthropic organization UUID 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 workload identity federation. Optional when the federation rule targets a single workspace | No | - | +| `anthropic_oidc_audience` | Audience requested on the GitHub OIDC token used for workload identity federation | No | `https://api.anthropic.com` | +| `prompt` | Instructions for Claude. Can be a direct prompt or custom template for automation workflows | No | - | +| `track_progress` | Force tag mode with tracking comments. Only works with specific PR/issue events. Preserves GitHub context | No | `false` | +| `include_fix_links` | Include 'Fix this' links in PR code review feedback that open Claude Code with context to fix the identified issue | No | `true` | +| `claude_args` | Additional [arguments to pass directly to Claude CLI](https://docs.claude.com/en/docs/claude-code/cli-reference#cli-flags) (e.g., `--max-turns 10 --model claude-4-0-sonnet-20250805`) | No | "" | +| `base_branch` | The base branch to use for creating new branches (e.g., 'main', 'develop') | No | - | +| `use_sticky_comment` | Use just one comment to deliver PR comments (only applies for pull_request event workflows) | No | `false` | +| `classify_inline_comments` | Buffer inline comments without `confirmed: true` and classify them (real review vs test/probe) via Haiku before posting after the session ends. Prevents subagent test comments. Set `'false'` to post all inline comments immediately | No | `true` | +| `github_token` | GitHub token for Claude to operate with. **Only include this if you're connecting a custom GitHub app of your own!** | 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` | +| `assignee_trigger` | The assignee username that triggers the action (e.g. @claude). Only used for issue assignment | No | - | +| `label_trigger` | The label name that triggers the action when applied to an issue (e.g. "claude") | No | - | +| `trigger_phrase` | The trigger phrase to look for in comments, issue/PR bodies, and issue titles | No | `@claude` | +| `branch_prefix` | The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format) | No | `claude/` | +| `settings` | Claude Code settings as JSON string or path to settings JSON file | No | "" | +| `additional_permissions` | Additional permissions to enable. Currently supports 'actions: read' for viewing workflow results | No | "" | +| `use_commit_signing` | Enable commit signing using GitHub's API. Simple but cannot perform complex git operations like rebasing. See [Security](./security.md#commit-signing) | No | `false` | +| `ssh_signing_key` | SSH private key for signing commits. Enables signed commits with full git CLI support (rebasing, etc.). See [Security](./security.md#commit-signing) | No | "" | +| `bot_id` | GitHub user ID to use for git operations (defaults to Claude's bot ID). Required with `ssh_signing_key` for verified commits | No | `41898282` | +| `bot_name` | GitHub username to use for git operations (defaults to Claude's bot name). Required with `ssh_signing_key` for verified commits | No | `claude[bot]` | +| `include_comments_by_actor` | Comma-separated list of actor usernames to INCLUDE in comments. Supports the `*[bot]` wildcard to match all bot accounts. Empty (default) includes all actors | No | "" | +| `exclude_comments_by_actor` | Comma-separated list of actor usernames to EXCLUDE from comments. Supports the `*[bot]` wildcard to match all bot accounts. If an actor matches both lists, exclusion takes priority | No | "" | +| `allowed_bots` | Comma-separated list of allowed bot usernames, or '\*' to allow all bots. Empty string (default) allows no bots. **⚠️ On public repos with `'*'`, external Apps may be able to invoke this action.** See [Security](./security.md) | No | "" | +| `allowed_non_write_users` | **⚠️ RISKY**: Comma-separated list of usernames to allow without write permissions, or '\*' for all users. Only works with `github_token` input. See [Security](./security.md) | No | "" | +| `path_to_claude_code_executable` | Optional path to a custom Claude Code executable. Skips automatic installation. Useful for Nix, custom containers, or specialized environments | No | "" | +| `path_to_bun_executable` | Optional path to a custom Bun executable. Skips automatic Bun installation. Useful for Nix, custom containers, or specialized environments | No | "" | +| `plugin_marketplaces` | Newline-separated list of Claude Code plugin marketplace Git URLs to install from (e.g., see example in workflow above). Marketplaces are added before plugin installation | No | "" | +| `plugins` | Newline-separated list of Claude Code plugin names to install (e.g., see example in workflow above). Plugins are installed before Claude Code execution | No | "" | ### Deprecated Inputs diff --git a/examples/claude-wif.yml b/examples/claude-wif.yml new file mode 100644 index 000000000..eedc7ce05 --- /dev/null +++ b/examples/claude-wif.yml @@ -0,0 +1,56 @@ +name: Claude Code (Workload Identity Federation) + +# Authenticates to the Claude API by exchanging the workflow's GitHub OIDC +# token for a short-lived access token — no ANTHROPIC_API_KEY secret needed. +# One-time Console setup (issuer, service account, federation rule): +# https://platform.claude.com/docs/en/manage-claude/workload-identity-federation +# See also docs/setup.md#workload-identity-federation in this repository. + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + issues: write + id-token: write # Required: used to fetch the GitHub OIDC token for the federation exchange + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + # These values are identifiers, not secrets — they can live directly + # in the workflow file or in repository variables. + anthropic_federation_rule_id: fdrl_xxxxxxxxxxxx + anthropic_organization_id: 00000000-0000-0000-0000-000000000000 + anthropic_service_account_id: svac_xxxxxxxxxxxx + + # Optional: only needed when the federation rule targets more than + # one workspace. + # anthropic_workspace_id: wrkspc_xxxxxxxxxxxx + + # Optional: audience requested on the GitHub OIDC token. Defaults to + # https://api.anthropic.com — only set this if your federation rule + # expects a different audience. + # anthropic_oidc_audience: https://example.com/custom-audience diff --git a/src/auth/workload-identity.ts b/src/auth/workload-identity.ts new file mode 100644 index 000000000..6634ae2c6 --- /dev/null +++ b/src/auth/workload-identity.ts @@ -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), + }; +} diff --git a/src/entrypoints/collect-inputs.ts b/src/entrypoints/collect-inputs.ts index 079565c7b..e97d6bd68 100644 --- a/src/entrypoints/collect-inputs.ts +++ b/src/entrypoints/collect-inputs.ts @@ -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", diff --git a/src/entrypoints/run.ts b/src/entrypoints/run.ts index f431a9fd4..32f38fcfc 100644 --- a/src/entrypoints/run.ts +++ b/src/entrypoints/run.ts @@ -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 && diff --git a/test/workload-identity.test.ts b/test/workload-identity.test.ts new file mode 100644 index 000000000..8fde17f9c --- /dev/null +++ b/test/workload-identity.test.ts @@ -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/auth/workload-identity"; + +describe("workload identity federation", () => { + let originalEnv: NodeJS.ProcessEnv; + let tempDir: string; + let getIDTokenSpy: ReturnType; + let warningSpy: ReturnType; + let setSecretSpy: ReturnType; + + 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(); + } + }); + }); +});