Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 42 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

> **🎉 Use Your Claude Max Subscription in GitHub Actions!**
>
> This is a fork of [anthropics/claude-code-base-action](https://github.com/anthropics/claude-code-base-action) that adds OAuth authentication support, enabling you to use your **Claude Max subscription** with GitHub Actions workflows.
> This is a fork of [anthropics/claude-code-base-action](https://github.com/anthropics/claude-code-base-action) that adds OAuth authentication support, enabling you to use your **Claude Pro/Max subscription** with GitHub Actions workflows.
>
> **Key Feature:** Authenticate with your Claude Max subscription credentials instead of requiring API keys, making it accessible to all Claude Max subscribers.
> **Key Feature:** Authenticate with your Claude Pro/Max subscription credentials instead of requiring API keys, making it accessible to all Claude Max subscribers.

We support token refresh via a Personal Access Token with secret write access on the repo. See [Oauth Authentication Setup](https://github.com/grll/claude-code-base-action?tab=readme-ov-file#oauth-authentication-setup).

This GitHub Action allows you to run [Claude Code](https://www.anthropic.com/claude-code) within your GitHub Actions workflows. You can use this to build any custom workflow on top of Claude Code.

Expand All @@ -25,6 +27,7 @@ Add the following to your workflow file:
claude_access_token: ${{ secrets.CLAUDE_ACCESS_TOKEN }}
claude_refresh_token: ${{ secrets.CLAUDE_REFRESH_TOKEN }}
claude_expires_at: ${{ secrets.CLAUDE_EXPIRES_AT }}
secrets_admin_pat: ${{ secrets.SECRETS_ADMIN_PAT}}

# Or using a prompt from a file
- name: Run Claude Code with prompt file
Expand All @@ -36,6 +39,7 @@ Add the following to your workflow file:
claude_access_token: ${{ secrets.CLAUDE_ACCESS_TOKEN }}
claude_refresh_token: ${{ secrets.CLAUDE_REFRESH_TOKEN }}
claude_expires_at: ${{ secrets.CLAUDE_EXPIRES_AT }}
secrets_admin_pat: ${{ secrets.SECRETS_ADMIN_PAT}}

# Or limiting the conversation turns
- name: Run Claude Code with limited turns
Expand All @@ -48,6 +52,7 @@ Add the following to your workflow file:
claude_access_token: ${{ secrets.CLAUDE_ACCESS_TOKEN }}
claude_refresh_token: ${{ secrets.CLAUDE_REFRESH_TOKEN }}
claude_expires_at: ${{ secrets.CLAUDE_EXPIRES_AT }}
secrets_admin_pat: ${{ secrets.SECRETS_ADMIN_PAT}}

# Using custom system prompts
- name: Run Claude Code with custom system prompt
Expand All @@ -56,7 +61,11 @@ Add the following to your workflow file:
prompt: "Build a REST API"
system_prompt: "You are a senior backend engineer. Focus on security, performance, and maintainability."
allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool"
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
use_oauth: "true"
claude_access_token: ${{ secrets.CLAUDE_ACCESS_TOKEN }}
claude_refresh_token: ${{ secrets.CLAUDE_REFRESH_TOKEN }}
claude_expires_at: ${{ secrets.CLAUDE_EXPIRES_AT }}
secrets_admin_pat: ${{ secrets.SECRETS_ADMIN_PAT}}

# Or appending to the default system prompt
- name: Run Claude Code with appended system prompt
Expand All @@ -65,7 +74,11 @@ Add the following to your workflow file:
prompt: "Create a database schema"
append_system_prompt: "After writing code, be sure to code review yourself."
allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool"
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
use_oauth: "true"
claude_access_token: ${{ secrets.CLAUDE_ACCESS_TOKEN }}
claude_refresh_token: ${{ secrets.CLAUDE_REFRESH_TOKEN }}
claude_expires_at: ${{ secrets.CLAUDE_EXPIRES_AT }}
secrets_admin_pat: ${{ secrets.SECRETS_ADMIN_PAT}}

# Using custom environment variables
- name: Run Claude Code with custom environment variables
Expand All @@ -77,7 +90,11 @@ Add the following to your workflow file:
API_URL: https://api-staging.example.com
DEBUG: true
allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool"
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
use_oauth: "true"
claude_access_token: ${{ secrets.CLAUDE_ACCESS_TOKEN }}
claude_refresh_token: ${{ secrets.CLAUDE_REFRESH_TOKEN }}
claude_expires_at: ${{ secrets.CLAUDE_EXPIRES_AT }}
secrets_admin_pat: ${{ secrets.SECRETS_ADMIN_PAT}}
```

## Inputs
Expand All @@ -103,6 +120,7 @@ Add the following to your workflow file:
| `claude_access_token` | Claude AI OAuth access token (required when use_oauth is true) | No | '' |
| `claude_refresh_token` | Claude AI OAuth refresh token (required when use_oauth is true) | No | '' |
| `claude_expires_at` | Claude AI OAuth token expiration timestamp (required when use_oauth is true) | No | '' |
| `secrets_admin_pat` | Personal Access Token with secrets:write access to the repository (required for oauth refresh) | No | '' |
| `use_node_cache` | Whether to use Node.js dependency caching (set to true only for Node.js projects with lock files) | No | 'false' |

\*Either `prompt` or `prompt_file` must be provided, but not both.
Expand Down Expand Up @@ -131,7 +149,11 @@ Example usage:
NODE_VERSION: "20.x"
with:
prompt: "Your prompt here"
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
use_oauth: "true"
claude_access_token: ${{ secrets.CLAUDE_ACCESS_TOKEN }}
claude_refresh_token: ${{ secrets.CLAUDE_REFRESH_TOKEN }}
claude_expires_at: ${{ secrets.CLAUDE_EXPIRES_AT }}
secrets_admin_pat: ${{ secrets.SECRETS_ADMIN_PAT}}
```

## Custom Environment Variables
Expand All @@ -152,7 +174,11 @@ The `claude_env` input accepts YAML multiline format with key-value pairs:
DEBUG: true
LOG_LEVEL: debug
allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool"
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
use_oauth: "true"
claude_access_token: ${{ secrets.CLAUDE_ACCESS_TOKEN }}
claude_refresh_token: ${{ secrets.CLAUDE_REFRESH_TOKEN }}
claude_expires_at: ${{ secrets.CLAUDE_EXPIRES_AT }}
secrets_admin_pat: ${{ secrets.SECRETS_ADMIN_PAT}}
```

### Features:
Expand Down Expand Up @@ -201,6 +227,7 @@ You can provide a custom MCP configuration file to dynamically load MCP servers:
claude_access_token: ${{ secrets.CLAUDE_ACCESS_TOKEN }}
claude_refresh_token: ${{ secrets.CLAUDE_REFRESH_TOKEN }}
claude_expires_at: ${{ secrets.CLAUDE_EXPIRES_AT }}
secrets_admin_pat: ${{ secrets.SECRETS_ADMIN_PAT}}
```

The MCP config file should follow this format:
Expand Down Expand Up @@ -234,6 +261,7 @@ You can combine MCP config with other inputs like allowed tools:
claude_access_token: ${{ secrets.CLAUDE_ACCESS_TOKEN }}
claude_refresh_token: ${{ secrets.CLAUDE_REFRESH_TOKEN }}
claude_expires_at: ${{ secrets.CLAUDE_EXPIRES_AT }}
secrets_admin_pat: ${{ secrets.SECRETS_ADMIN_PAT}}
```

## Example: PR Code Review
Expand Down Expand Up @@ -264,6 +292,7 @@ jobs:
claude_access_token: ${{ secrets.CLAUDE_ACCESS_TOKEN }}
claude_refresh_token: ${{ secrets.CLAUDE_REFRESH_TOKEN }}
claude_expires_at: ${{ secrets.CLAUDE_EXPIRES_AT }}
secrets_admin_pat: ${{ secrets.SECRETS_ADMIN_PAT}}

- name: Extract and Comment PR Review
if: steps.code-review.outputs.conclusion == 'success'
Expand Down Expand Up @@ -339,6 +368,8 @@ Use provider-specific model names based on your chosen provider:
claude_access_token: ${{ secrets.CLAUDE_ACCESS_TOKEN }}
claude_refresh_token: ${{ secrets.CLAUDE_REFRESH_TOKEN }}
claude_expires_at: ${{ secrets.CLAUDE_EXPIRES_AT }}
secrets_admin_pat: ${{ secrets.SECRETS_ADMIN_PAT}}


# For Amazon Bedrock (requires OIDC authentication)
- name: Configure AWS Credentials (OIDC)
Expand Down Expand Up @@ -415,7 +446,7 @@ This example shows how to use OIDC authentication with GCP Vertex AI:

### OAuth Authentication Setup

To use OAuth authentication with your Claude Max Subscription Plan:
To use OAuth authentication with your Claude Pro / Max Subscription Plan:

0. Login into Claude Code with your Claude Max Subscription with `/login`:

Expand All @@ -430,12 +461,15 @@ To use OAuth authentication with your Claude Max Subscription Plan:
- `CLAUDE_REFRESH_TOKEN` - Your Claude AI OAuth refresh token
- `CLAUDE_EXPIRES_AT` - Token expiration timestamp (Unix timestamp in seconds)

2. Create a Personal Access Token with write:secrets permission on your repo (see [guide](https://github.com/grll/claude-code-login/blob/main/README.md#prerequisites-setting-up-secrets_admin_pat)) and add a secret `SECRETS_ADMIN_PAT`.

2. Reference the secrets in your workflow:
```yaml
use_oauth: "true"
claude_access_token: ${{ secrets.CLAUDE_ACCESS_TOKEN }}
claude_refresh_token: ${{ secrets.CLAUDE_REFRESH_TOKEN }}
claude_expires_at: ${{ secrets.CLAUDE_EXPIRES_AT }}
secrets_admin_pat: ${{ secrets.SECRETS_ADMIN_PAT }}
```

### API Key Authentication Setup (Legacy)
Expand Down
5 changes: 5 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ inputs:
description: "Claude AI OAuth token expiration timestamp (required when use_oauth is true)"
required: false
default: ""
secrets_admin_pat:
description: 'Personal Access Token with secrets:write permission to refresh OAuth tokens (required when use_oauth is true)'
required: false
default: ''

use_node_cache:
description: "Whether to use Node.js dependency caching (set to true only for Node.js projects with lock files)"
Expand Down Expand Up @@ -159,6 +163,7 @@ runs:
CLAUDE_ACCESS_TOKEN: ${{ inputs.claude_access_token }}
CLAUDE_REFRESH_TOKEN: ${{ inputs.claude_refresh_token }}
CLAUDE_EXPIRES_AT: ${{ inputs.claude_expires_at }}
SECRETS_ADMIN_PAT: ${{ inputs.secrets_admin_pat }}

# AWS configuration
AWS_REGION: ${{ env.AWS_REGION }}
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ async function run() {
accessToken: process.env.CLAUDE_ACCESS_TOKEN!,
refreshToken: process.env.CLAUDE_REFRESH_TOKEN!,
expiresAt: process.env.CLAUDE_EXPIRES_AT!,
secretsAdminPat: process.env.SECRETS_ADMIN_PAT,
});
}

Expand Down
125 changes: 122 additions & 3 deletions src/setup-oauth.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,84 @@
import { mkdir, writeFile } from "fs/promises";
import { join } from "path";
import { getClaudeConfigHomeDir } from "./setup-claude-code-settings";
import { execSync } from "child_process";

const OAUTH_TOKEN_URL = 'https://console.anthropic.com/v1/oauth/token';
const CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';

interface OAuthCredentials {
accessToken: string;
refreshToken: string;
expiresAt: string;
secretsAdminPat?: string; // optional - used for Secrets Admin API
}

interface TokenRefreshResponse {
access_token: string;
refresh_token: string;
expires_in: number;
scope?: string;
}

function tokenExpired(expiresAtMs: number): boolean {
// Add 60 minutes buffer to refresh before actual expiry
const bufferMs = 60 * 60 * 1000;
const currentTimeMs = Date.now();
return currentTimeMs >= (expiresAtMs - bufferMs);
}

async function performRefresh(refreshToken: string): Promise<{ accessToken: string; refreshToken: string; expiresAt: number } | null> {
try {
const response = await fetch(OAUTH_TOKEN_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: CLIENT_ID,
}),
});

if (response.ok) {
const data = await response.json() as TokenRefreshResponse;

return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresAt: (Math.floor(Date.now() / 1000) + data.expires_in) * 1000,
};
} else {
const errorBody = await response.text();
console.log(`❌ Token refresh failed: ${response.status} - ${errorBody}`);
return null;
}
} catch (error) {
console.log(`❌ Error making refresh request: ${error instanceof Error ? error.message : error}`);
return null;
}
}

function updateGitHubSecrets(secretsAdminPat: string, accessToken: string, refreshToken: string, expiresAt: number) {
const env = { ...process.env, GH_TOKEN: secretsAdminPat };

try {
// Update CLAUDE_ACCESS_TOKEN
execSync(`gh secret set CLAUDE_ACCESS_TOKEN --body "${accessToken}"`, { env, stdio: 'inherit' });
console.log('✅ Updated CLAUDE_ACCESS_TOKEN secret');

// Update CLAUDE_REFRESH_TOKEN
execSync(`gh secret set CLAUDE_REFRESH_TOKEN --body "${refreshToken}"`, { env, stdio: 'inherit' });
console.log('✅ Updated CLAUDE_REFRESH_TOKEN secret');

// Update CLAUDE_EXPIRES_AT
execSync(`gh secret set CLAUDE_EXPIRES_AT --body "${expiresAt}"`, { env, stdio: 'inherit' });
console.log('✅ Updated CLAUDE_EXPIRES_AT secret');
} catch (error) {
console.error('❌ Failed to update GitHub secrets:', error);
throw error;
}
}

export async function setupOAuthCredentials(credentials: OAuthCredentials) {
Expand All @@ -15,12 +88,58 @@ export async function setupOAuthCredentials(credentials: OAuthCredentials) {
// Create the .claude directory if it doesn't exist
await mkdir(claudeDir, { recursive: true });

let accessToken = credentials.accessToken;
let refreshToken = credentials.refreshToken;
let expiresAt = parseInt(credentials.expiresAt);

// Check if token needs refresh
if (tokenExpired(expiresAt)) {
if (!credentials.secretsAdminPat) {
console.warn(`
⚠️ WARNING: OAuth token is expiring soon but SECRETS_ADMIN_PAT is not set!
⚠️
⚠️ The GitHub Action cannot automatically refresh your OAuth tokens without the SECRETS_ADMIN_PAT.
⚠️ Your Claude Code execution may fail if the token expires during the workflow or has expired.
⚠️
⚠️ To enable automatic token refresh:
⚠️ 1. Create a Personal Access Token with 'secrets:write' permission
⚠️ 2. Add it as a repository secret named SECRETS_ADMIN_PAT
⚠️ 3. Pass it to this action using: secrets_admin_pat: \${{ secrets.SECRETS_ADMIN_PAT }}
⚠️
⚠️ For detailed instructions, see:
⚠️ https://github.com/grll/claude-code-login/blob/main/README.md#prerequisites-setting-up-secrets_admin_pat
⚠️
⚠️ Continuing with potentially expired token...
`);
} else {
console.log('🔄 Token expired or expiring soon, refreshing...');
const newTokens = await performRefresh(refreshToken);

if (newTokens) {
accessToken = newTokens.accessToken;
refreshToken = newTokens.refreshToken;
expiresAt = newTokens.expiresAt;

console.log('✅ Token refreshed successfully!');

// Update GitHub secrets with new tokens
console.log('📝 Updating GitHub secrets with refreshed tokens...');
updateGitHubSecrets(credentials.secretsAdminPat, accessToken, refreshToken, expiresAt);
} else {
console.error('❌ Failed to refresh token, using existing credentials');
}
}
} else {
const minutesUntilExpiry = Math.round((expiresAt - Date.now()) / 1000 / 60);
console.log(`✅ Token is still valid (expires in ${minutesUntilExpiry} minutes)`);
}

// Create the credentials JSON structure
const credentialsData = {
claudeAiOauth: {
accessToken: credentials.accessToken,
refreshToken: credentials.refreshToken,
expiresAt: parseInt(credentials.expiresAt),
accessToken: accessToken,
refreshToken: refreshToken,
expiresAt: expiresAt,
scopes: ["user:inference", "user:profile"],
},
};
Expand Down
Loading
Loading