diff --git a/.github/scripts/claude_oauth_login.rb b/.github/scripts/claude_oauth_login.rb deleted file mode 100644 index 10820a27f..000000000 --- a/.github/scripts/claude_oauth_login.rb +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env ruby - -require 'securerandom' -require 'uri' -require 'json' -require 'digest' -require 'base64' - -class ClaudeLoginStart - OAUTH_AUTHORIZE_URL = 'https://claude.ai/oauth/authorize' - CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e' - REDIRECT_URI = 'https://console.anthropic.com/oauth/code/callback' - STATE_FILE = 'claude_oauth_state.json' - - def generate_login_url - state = SecureRandom.hex(32) - code_verifier = SecureRandom.urlsafe_base64(32) - code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier)).chomp('=') - - # Save state and code verifier for verification later - save_state(state, code_verifier) - - params = { - 'code' => 'true', - 'client_id' => CLIENT_ID, - 'response_type' => 'code', - 'redirect_uri' => REDIRECT_URI, - 'scope' => 'org:create_api_key user:profile user:inference', - 'code_challenge' => code_challenge, - 'code_challenge_method' => 'S256', - 'state' => state - } - - url = "#{OAUTH_AUTHORIZE_URL}?" + URI.encode_www_form(params) - - puts url - - url - end - - private - - def save_state(state, code_verifier) - state_data = { - 'state' => state, - 'code_verifier' => code_verifier, - 'timestamp' => Time.now.to_i, - 'expires_at' => Time.now.to_i + 600 # 10 minutes - } - - File.write(STATE_FILE, JSON.pretty_generate(state_data)) - rescue => e - puts "Warning: Could not save state file: #{e.message}" - end -end - -if __FILE__ == $0 - if ARGV.include?('--help') || ARGV.include?('-h') - puts "Usage: #{$0}" - puts " Generates an OAuth login URL for Claude Code authentication" - puts " --help, -h Show this help message" - exit 0 - end - - login = ClaudeLoginStart.new - login.generate_login_url -end \ No newline at end of file diff --git a/.github/scripts/claude_token_refresh.ts b/.github/scripts/claude_token_refresh.ts new file mode 100755 index 000000000..7e173b9ef --- /dev/null +++ b/.github/scripts/claude_token_refresh.ts @@ -0,0 +1,169 @@ +#!/usr/bin/env bun + +import { writeFile } from 'fs/promises'; +import { existsSync, readFileSync } from 'fs'; + +// Module constants +const OAUTH_TOKEN_URL = 'https://console.anthropic.com/v1/oauth/token'; +const CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'; + +// Types +interface ClaudeOAuthData { + accessToken: string; + refreshToken: string; + expiresAt: number; + scopes: string[]; + isMax: boolean; +} + +interface Credentials { + claudeAiOauth: ClaudeOAuthData; +} + +interface TokenRefreshResponse { + access_token: string; + refresh_token: string; + expires_in: number; + scope?: string; +} + +function loadCredentials(credentialsPath: string): Credentials | null { + if (!existsSync(credentialsPath)) { + console.log(`❌ Credentials file not found: ${credentialsPath}`); + return null; + } + + try { + const content = readFileSync(credentialsPath, 'utf-8'); + return JSON.parse(content); + } catch (error) { + console.log(`❌ Error parsing credentials file: ${error instanceof Error ? error.message : error}`); + return null; + } +} + +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 { + 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: TokenRefreshResponse = await response.json(); + + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresAt: (Math.floor(Date.now() / 1000) + data.expires_in) * 1000, + scopes: data.scope ? data.scope.split(' ') : ['user:inference', 'user:profile'], + isMax: true, + }; + } 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 formatTime(timestampMs: number): string { + return new Date(timestampMs).toLocaleString('en-US', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); +} + +// Main function - simplified to take credentials path as argument +async function refreshTokenIfNeeded(credentialsPath: string): Promise<{ success: boolean; refreshed: boolean }> { + console.log(`🔍 Checking OAuth credentials at: ${credentialsPath}`); + + const credentials = loadCredentials(credentialsPath); + if (!credentials?.claudeAiOauth) { + return { success: false, refreshed: false }; + } + + const oauthData = credentials.claudeAiOauth; + const { refreshToken: refreshTokenValue, expiresAt } = oauthData; + + console.log(`📅 Current token expires at: ${formatTime(expiresAt)}`); + + if (!tokenExpired(expiresAt)) { + console.log(`✅ Token is still valid (expires in ${Math.round((expiresAt - Date.now()) / 1000 / 60)} minutes)`); + return { success: true, refreshed: false }; + } + + console.log(`🔄 Token expired or expiring soon, refreshing...`); + const newTokens = await performRefresh(refreshTokenValue); + + if (newTokens) { + try { + credentials.claudeAiOauth = newTokens; + await writeFile(credentialsPath, JSON.stringify(credentials, null, 2)); + console.log(`✅ Token refreshed successfully! New expiry: ${formatTime(newTokens.expiresAt)}`); + + // Output refresh status for GitHub Actions (modern syntax) + if (process.env.GITHUB_OUTPUT) { + try { + const fs = await import('fs/promises'); + await fs.appendFile(process.env.GITHUB_OUTPUT, 'token_refreshed=true\n'); + } catch (error) { + console.log(`Warning: Could not write to GITHUB_OUTPUT: ${error}`); + } + } + + return { success: true, refreshed: true }; + } catch (error) { + console.log(`❌ Error updating credentials file: ${error instanceof Error ? error.message : error}`); + return { success: false, refreshed: false }; + } + } + + console.log(`❌ Failed to refresh token`); + return { success: false, refreshed: false }; +} + +// Main execution +async function main(): Promise { + const args = process.argv.slice(2); + + if (args.length === 0 || args.includes('--help') || args.includes('-h')) { + console.log(`Usage: ${process.argv[1]} `); + console.log(' credentials-file-path Path to the credentials.json file'); + process.exit(args.includes('--help') || args.includes('-h') ? 0 : 1); + } + + const credentialsPath = args[0]; + const result = await refreshTokenIfNeeded(credentialsPath); + process.exit(result.success ? 0 : 1); +} + +// Run main function if this file is executed directly +if (import.meta.main) { + main().catch((error) => { + console.error('❌ Unexpected error:', error); + process.exit(1); + }); +} \ No newline at end of file diff --git a/.github/workflows/claude-authorized.yml b/.github/workflows/claude-authorized.yml index ff8f42417..c3e0e97bb 100644 --- a/.github/workflows/claude-authorized.yml +++ b/.github/workflows/claude-authorized.yml @@ -26,6 +26,7 @@ jobs: pull-requests: read issues: read id-token: write + actions: write steps: - name: Checkout repository uses: actions/checkout@v4 @@ -33,10 +34,10 @@ jobs: fetch-depth: 1 - name: Run Claude PR Action - uses: grll/claude-code-action@beta + uses: ./ with: use_oauth: true - claude_access_token: ${{ secrets.CLAUDE_ACCESS_TOKEN }} - claude_refresh_token: ${{ secrets.CLAUDE_REFRESH_TOKEN }} - claude_expires_at: ${{ secrets.CLAUDE_EXPIRES_AT }} + # claude_access_token: ${{ secrets.CLAUDE_ACCESS_TOKEN }} + # claude_refresh_token: ${{ secrets.CLAUDE_REFRESH_TOKEN }} + # claude_expires_at: ${{ secrets.CLAUDE_EXPIRES_AT }} timeout_minutes: "60" \ No newline at end of file diff --git a/.github/workflows/test-action.yml b/.github/workflows/test-action.yml index 4bc9ca08c..530b6a552 100644 --- a/.github/workflows/test-action.yml +++ b/.github/workflows/test-action.yml @@ -12,11 +12,11 @@ jobs: test-claude: runs-on: ubuntu-latest permissions: - contents: write - pull-requests: write - issues: write - actions: write + contents: read + pull-requests: read + issues: read id-token: write + actions: write steps: - name: Checkout repository uses: actions/checkout@v4 diff --git a/action.yml b/action.yml index 20bb1813e..1e281c672 100644 --- a/action.yml +++ b/action.yml @@ -4,6 +4,10 @@ branding: icon: "at-sign" color: "orange" +permissions: + actions: write # Required for cache management (create/delete cache entries) + contents: read # Required for basic repository access + inputs: trigger_phrase: description: "The trigger phrase to look for in comments or issue body" @@ -106,6 +110,47 @@ runs: cd ${GITHUB_ACTION_PATH} bun install + - name: Check OAuth cache + id: check-oauth-cache + uses: actions/cache/restore@v4 + with: + key: claude-oauth-credentials + path: ${{ runner.temp }}/claude-oauth-credentials.json + + - name: Debug cache status + shell: bash + run: | + echo "Cache hit: ${{ steps.check-oauth-cache.outputs.cache-hit }}" + echo "Cache key: ${{ steps.check-oauth-cache.outputs.cache-primary-key }}" + echo "Cache file exists: $(test -f '${{ runner.temp }}/claude-oauth-credentials.json' && echo 'YES' || echo 'NO')" + if [ -f "${{ runner.temp }}/claude-oauth-credentials.json" ]; then + echo "Cache file size: $(wc -c < '${{ runner.temp }}/claude-oauth-credentials.json') bytes" + fi + + - name: Validate authentication + id: validate-auth + shell: bash + run: | + # Check if OAuth cache exists + if [ -f "${{ runner.temp }}/claude-oauth-credentials.json" ]; then + echo "oauth_cache_exists=true" >> $GITHUB_OUTPUT + echo "Found OAuth credentials cache" + else + echo "oauth_cache_exists=false" >> $GITHUB_OUTPUT + + # Check if OAuth is enabled but no cache or tokens provided + if [ "${{ inputs.use_oauth }}" = "true" ] && [ -z "${{ inputs.claude_access_token }}" ] && [ -z "${{ inputs.anthropic_api_key }}" ]; then + echo "::error::OAuth authentication is enabled but no credentials found. Please run the Claude OAuth action first to authenticate: https://github.com/grll/claude-code-login" + exit 1 + fi + + # Check if no authentication method is provided + if [ -z "${{ inputs.anthropic_api_key }}" ] && [ "${{ inputs.use_bedrock }}" != "true" ] && [ "${{ inputs.use_vertex }}" != "true" ] && [ "${{ inputs.use_oauth }}" != "true" ]; then + echo "::error::No authentication method provided. Either set anthropic_api_key, enable use_bedrock/use_vertex, set use_oauth=true, or run the Claude OAuth action to authenticate." + exit 1 + fi + fi + - name: Prepare action id: prepare shell: bash @@ -122,6 +167,53 @@ runs: MCP_CONFIG: ${{ inputs.mcp_config }} OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }} GITHUB_RUN_ID: ${{ github.run_id }} + OAUTH_CACHE_EXISTS: ${{ steps.validate-auth.outputs.oauth_cache_exists }} + + - name: Load OAuth credentials from cache + id: load-oauth-cache + if: steps.validate-auth.outputs.oauth_cache_exists == 'true' + shell: bash + run: | + if [ -f "${{ runner.temp }}/claude-oauth-credentials.json" ]; then + # Use the TypeScript refresh script to handle token refresh if necessary + cd ${GITHUB_ACTION_PATH} + bun .github/scripts/claude_token_refresh.ts "${{ runner.temp }}/claude-oauth-credentials.json" + + if [ $? -eq 0 ]; then + # Extract OAuth credentials from the (potentially refreshed) cache + ACCESS_TOKEN=$(jq -r '.claudeAiOauth.accessToken // empty' "${{ runner.temp }}/claude-oauth-credentials.json") + REFRESH_TOKEN=$(jq -r '.claudeAiOauth.refreshToken // empty' "${{ runner.temp }}/claude-oauth-credentials.json") + EXPIRES_AT=$(jq -r '.claudeAiOauth.expiresAt // empty' "${{ runner.temp }}/claude-oauth-credentials.json") + + echo "claude_access_token=${ACCESS_TOKEN}" >> $GITHUB_OUTPUT + echo "claude_refresh_token=${REFRESH_TOKEN}" >> $GITHUB_OUTPUT + echo "claude_expires_at=${EXPIRES_AT}" >> $GITHUB_OUTPUT + echo "use_oauth=true" >> $GITHUB_OUTPUT + + echo "OAuth credentials processed successfully" + else + echo "::error::Failed to process OAuth credentials. Please re-authenticate using the Claude OAuth action." + exit 1 + fi + fi + + - name: Delete Old OAuth Credentials Cache + if: steps.load-oauth-cache.outputs.token_refreshed == 'true' + shell: bash + run: | + if gh cache list --repo ${{ github.repository }} --key claude-oauth-credentials | grep -q claude-oauth-credentials; then + echo "🗑️ Deleting old OAuth credentials cache..." + gh cache delete claude-oauth-credentials --repo ${{ github.repository }} + fi + env: + GITHUB_TOKEN: ${{ github.token }} + + - name: Save Refreshed OAuth Credentials + if: steps.load-oauth-cache.outputs.token_refreshed == 'true' + uses: actions/cache/save@v4 + with: + path: ${{ runner.temp }}/claude-oauth-credentials.json + key: claude-oauth-credentials - name: Run Claude Code id: claude-code @@ -137,11 +229,11 @@ runs: mcp_config: ${{ steps.prepare.outputs.mcp_config }} use_bedrock: ${{ inputs.use_bedrock }} use_vertex: ${{ inputs.use_vertex }} - use_oauth: ${{ inputs.use_oauth }} + use_oauth: ${{ steps.load-oauth-cache.outputs.use_oauth || inputs.use_oauth }} anthropic_api_key: ${{ inputs.anthropic_api_key }} - claude_access_token: ${{ inputs.claude_access_token }} - claude_refresh_token: ${{ inputs.claude_refresh_token }} - claude_expires_at: ${{ inputs.claude_expires_at }} + claude_access_token: ${{ steps.load-oauth-cache.outputs.claude_access_token || inputs.claude_access_token }} + claude_refresh_token: ${{ steps.load-oauth-cache.outputs.claude_refresh_token || inputs.claude_refresh_token }} + claude_expires_at: ${{ steps.load-oauth-cache.outputs.claude_expires_at || inputs.claude_expires_at }} claude_env: ${{ inputs.claude_env }} env: # Model configuration