From f2fbd7b8c3cd95dd73416a44d1453d5f973e2896 Mon Sep 17 00:00:00 2001 From: Guillaume Raille Date: Mon, 23 Jun 2025 22:46:25 +0200 Subject: [PATCH 01/10] rename claude oauth login action --- .github/workflows/claude-oauth-login.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/claude-oauth-login.yml b/.github/workflows/claude-oauth-login.yml index 552045203..ace5392d9 100644 --- a/.github/workflows/claude-oauth-login.yml +++ b/.github/workflows/claude-oauth-login.yml @@ -1,4 +1,4 @@ -name: Claude OAuth Login (Simple) +name: Claude OAuth Login on: workflow_dispatch: From fe26936d99953ce6cf2aacf3790e73698f217e2f Mon Sep 17 00:00:00 2001 From: Guillaume Raille Date: Mon, 23 Jun 2025 22:58:15 +0200 Subject: [PATCH 02/10] add token exchange and caching --- .github/scripts/claude_oauth_exchange.rb | 162 +++++++++++++++++++++++ .github/workflows/claude-oauth-login.yml | 69 +++++++++- 2 files changed, 227 insertions(+), 4 deletions(-) create mode 100644 .github/scripts/claude_oauth_exchange.rb diff --git a/.github/scripts/claude_oauth_exchange.rb b/.github/scripts/claude_oauth_exchange.rb new file mode 100644 index 000000000..5ec2b1881 --- /dev/null +++ b/.github/scripts/claude_oauth_exchange.rb @@ -0,0 +1,162 @@ +#!/usr/bin/env ruby + +require 'net/http' +require 'json' +require 'uri' +require 'time' + +class ClaudeOAuthExchange + OAUTH_TOKEN_URL = 'https://console.anthropic.com/v1/oauth/token' + CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e' + REDIRECT_URI = 'https://console.anthropic.com/oauth/code/callback' + STATE_FILE = 'claude_oauth_state.json' + CREDENTIALS_FILE = 'credentials.json' + + def initialize(authorization_code) + # Clean up the authorization code in case it has URL fragments + @authorization_code = authorization_code.split('#').first.split('&').first + end + + def exchange_code_for_tokens + unless verify_state + puts "Error: Invalid or expired state. Please run the login process again." + return false + end + + puts "Exchanging authorization code for tokens..." + tokens = perform_token_exchange + + if tokens + puts "\nOAuth token exchange successful!" + puts "Received scopes: #{tokens['scopes'].join(', ')}" + + # Save OAuth credentials + save_credentials(tokens) + cleanup_state + + puts "\n=== SUCCESS ===" + puts "OAuth login successful!" + puts "Credentials saved to: #{CREDENTIALS_FILE}" + puts "Token expires at: #{Time.at(tokens['expiresAt'] / 1000).strftime('%Y-%m-%d %H:%M:%S')}" + puts "===============" + + # Output for GitHub Actions + puts "::set-output name=success::true" + puts "::set-output name=expires_at::#{tokens['expiresAt']}" + + true + else + puts "Login failed!" + puts "::set-output name=success::false" + false + end + end + + private + + def verify_state + return false unless File.exist?(STATE_FILE) + + begin + state_data = JSON.parse(File.read(STATE_FILE)) + current_time = Time.now.to_i + + if current_time > state_data['expires_at'] + puts "Error: State has expired (older than 10 minutes)" + return false + end + + true + rescue => e + puts "Error reading state file: #{e.message}" + false + end + end + + def perform_token_exchange + # Load state to get code_verifier + state_data = JSON.parse(File.read(STATE_FILE)) + code_verifier = state_data['code_verifier'] + + uri = URI(OAUTH_TOKEN_URL) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + + request = Net::HTTP::Post.new(uri) + request['Content-Type'] = 'application/json' + request['User-Agent'] = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36' + request['Accept'] = 'application/json, text/plain, */*' + request['Accept-Language'] = 'en-US,en;q=0.9' + request['Referer'] = 'https://claude.ai/' + request['Origin'] = 'https://claude.ai' + + params = { + 'grant_type' => 'authorization_code', + 'client_id' => CLIENT_ID, + 'code' => @authorization_code, + 'redirect_uri' => REDIRECT_URI, + 'code_verifier' => code_verifier, + 'state' => state_data['state'] + } + + # Send as JSON + request.body = JSON.generate(params) + + begin + response = http.request(request) + + if response.code == '200' + data = JSON.parse(response.body) + + { + 'accessToken' => data['access_token'], + 'refreshToken' => data['refresh_token'], + 'expiresAt' => (Time.now.to_i + data['expires_in']) * 1000, + 'scopes' => data['scope'] ? data['scope'].split(' ') : ['user:inference', 'user:profile'], + 'isMax' => true + } + else + puts "Error response: #{response.code} - #{response.body}" + nil + end + rescue => e + puts "Error making token request: #{e.message}" + nil + end + end + + def save_credentials(tokens) + # Create credentials structure + credentials = { + 'claudeAiOauth' => tokens + } + + File.write(CREDENTIALS_FILE, JSON.pretty_generate(credentials)) + true + rescue => e + puts "Error saving credentials: #{e.message}" + false + end + + def cleanup_state + File.delete(STATE_FILE) if File.exist?(STATE_FILE) + rescue => e + puts "Warning: Could not clean up state file: #{e.message}" + end +end + +if __FILE__ == $0 + if ARGV.include?('--help') || ARGV.include?('-h') || ARGV.empty? + puts "Usage: #{$0} " + puts " Completes OAuth login and exchanges code for tokens" + puts " authorization_code: The code received from the OAuth callback" + puts " --help, -h Show this help message" + exit ARGV.empty? ? 1 : 0 + end + + authorization_code = ARGV[0] + exchange = ClaudeOAuthExchange.new(authorization_code) + + success = exchange.exchange_code_for_tokens + exit(success ? 0 : 1) +end \ No newline at end of file diff --git a/.github/workflows/claude-oauth-login.yml b/.github/workflows/claude-oauth-login.yml index ace5392d9..c2fb0271e 100644 --- a/.github/workflows/claude-oauth-login.yml +++ b/.github/workflows/claude-oauth-login.yml @@ -16,6 +16,16 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + - name: Restore OAuth state from cache + if: inputs.authorization_code != '' + id: cache-restore + uses: actions/cache/restore@v3 + with: + path: claude_oauth_state.json + key: claude-oauth-state-${{ github.run_id }} + restore-keys: | + claude-oauth-state- + - name: Setup Ruby uses: ruby/setup-ruby@v1 with: @@ -53,6 +63,14 @@ jobs: echo "" echo "After authorization, run this workflow again with the code." echo "==================================================" + + # Verify state file was created + if [ -f claude_oauth_state.json ]; then + echo "OAuth state file created successfully" + else + echo "Error: OAuth state file was not created" + exit 1 + fi else # Code provided, process it echo "## ✅ Authorization Code Received" >> $GITHUB_STEP_SUMMARY @@ -62,7 +80,50 @@ jobs: echo "**Code:** \`${{ inputs.authorization_code }}\`" >> $GITHUB_STEP_SUMMARY echo "**Timestamp:** $(date)" >> $GITHUB_STEP_SUMMARY - # Here you would typically exchange the code for tokens - echo "Authorization code: ${{ inputs.authorization_code }}" - echo "Next step would be to exchange this code for access tokens" - fi \ No newline at end of file + # Check if OAuth state was restored + if [ ! -f claude_oauth_state.json ]; then + echo "❌ Error: OAuth state file not found in cache!" >> $GITHUB_STEP_SUMMARY + echo "Please generate a new OAuth URL first by running this workflow without an authorization code." >> $GITHUB_STEP_SUMMARY + exit 1 + fi + + echo "Authorization code received, exchanging for tokens..." + + # Exchange code for tokens + chmod +x .github/scripts/claude_oauth_exchange.rb + .github/scripts/claude_oauth_exchange.rb "${{ inputs.authorization_code }}" + + if [ -f credentials.json ]; then + echo "✅ Credentials generated successfully!" + + # Cache the credentials for reuse in other workflows + echo "Caching credentials for future use..." + + # Display summary + echo "## ✅ OAuth Login Complete!" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "The Claude OAuth credentials have been successfully generated and cached." >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Next Steps:" >> $GITHUB_STEP_SUMMARY + echo "- The credentials are securely stored in the GitHub Actions cache" >> $GITHUB_STEP_SUMMARY + echo "- Use the cache key \`claude-credentials-${{ github.sha }}\` in other workflows" >> $GITHUB_STEP_SUMMARY + echo "- Cache is only accessible to workflows in this repository" >> $GITHUB_STEP_SUMMARY + else + echo "❌ Failed to generate credentials" + exit 1 + fi + fi + + - name: Save OAuth state to cache + if: inputs.authorization_code == '' + uses: actions/cache/save@v3 + with: + path: claude_oauth_state.json + key: claude-oauth-state-${{ github.run_id }} + + - name: Save credentials to cache + if: inputs.authorization_code != '' && success() + uses: actions/cache/save@v3 + with: + path: credentials.json + key: claude-credentials-${{ github.sha }} \ No newline at end of file From 51842cb4b35e295ded5a8392c77635c308c4d2df Mon Sep 17 00:00:00 2001 From: Guillaume Raille Date: Mon, 23 Jun 2025 23:23:23 +0200 Subject: [PATCH 03/10] add token_refresh --- .github/scripts/claude_token_refresh.rb | 156 ++++++++++++++++++ .github/workflows/claude-code-with-oauth.yml | 92 +++++++++++ .../workflows/example-use-oauth-tokens.yml | 59 +++++++ 3 files changed, 307 insertions(+) create mode 100644 .github/scripts/claude_token_refresh.rb create mode 100644 .github/workflows/claude-code-with-oauth.yml create mode 100644 .github/workflows/example-use-oauth-tokens.yml diff --git a/.github/scripts/claude_token_refresh.rb b/.github/scripts/claude_token_refresh.rb new file mode 100644 index 000000000..5ffbaad22 --- /dev/null +++ b/.github/scripts/claude_token_refresh.rb @@ -0,0 +1,156 @@ +#!/usr/bin/env ruby + +require 'net/http' +require 'json' +require 'time' +require 'uri' + +class ClaudeTokenRefresher + OAUTH_TOKEN_URL = 'https://console.anthropic.com/v1/oauth/token' + CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e' + CREDENTIALS_PATH = 'credentials.json' + + def initialize(credentials_path = CREDENTIALS_PATH) + @credentials_path = credentials_path + end + + def refresh_token + credentials = load_credentials + + if credentials.nil? || credentials['claudeAiOauth'].nil? + puts "Error: No valid credentials found in #{@credentials_path}" + return false + end + + oauth_data = credentials['claudeAiOauth'] + refresh_token = oauth_data['refreshToken'] + expires_at = oauth_data['expiresAt'] + + puts "Current token expires at: #{Time.at(expires_at / 1000).strftime('%Y-%m-%d %H:%M:%S')}" + puts "Token expired: #{token_expired?(expires_at)}" + + if !token_expired?(expires_at) && !force_refresh? + puts "Token is still valid. Use --force to refresh anyway." + + # Output current tokens for GitHub Actions + output_tokens(oauth_data) + return true + end + + puts "Refreshing token..." + new_tokens = perform_refresh(refresh_token) + + if new_tokens + update_credentials(credentials, new_tokens) + puts "Token refreshed successfully!" + puts "New token expires at: #{Time.at(new_tokens['expiresAt'] / 1000).strftime('%Y-%m-%d %H:%M:%S')}" + + # Output new tokens for GitHub Actions + output_tokens(new_tokens) + true + else + puts "Failed to refresh token" + false + end + end + + private + + def load_credentials + return nil unless File.exist?(@credentials_path) + + JSON.parse(File.read(@credentials_path)) + rescue JSON::ParserError => e + puts "Error parsing credentials file: #{e.message}" + nil + end + + def token_expired?(expires_at_ms) + # Add 60 minutes buffer to refresh before actual expiry + buffer_ms = 60 * 60 * 1000 + current_time_ms = Time.now.to_i * 1000 + current_time_ms >= (expires_at_ms - buffer_ms) + end + + def force_refresh? + ARGV.include?('--force') || ARGV.include?('-f') + end + + def perform_refresh(refresh_token) + uri = URI(OAUTH_TOKEN_URL) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + + request = Net::HTTP::Post.new(uri) + request['Content-Type'] = 'application/json' + request.body = { + grant_type: 'refresh_token', + refresh_token: refresh_token, + client_id: CLIENT_ID + }.to_json + + begin + response = http.request(request) + + if response.code == '200' + data = JSON.parse(response.body) + + { + 'accessToken' => data['access_token'], + 'refreshToken' => data['refresh_token'], + 'expiresAt' => (Time.now.to_i + data['expires_in']) * 1000, + 'scopes' => data['scope'] ? data['scope'].split(' ') : ['user:inference', 'user:profile'], + 'isMax' => true + } + else + puts "Error response: #{response.code} - #{response.body}" + nil + end + rescue => e + puts "Error making refresh request: #{e.message}" + nil + end + end + + def update_credentials(credentials, new_tokens) + credentials['claudeAiOauth'] = new_tokens + + File.write(@credentials_path, JSON.pretty_generate(credentials)) + rescue => e + puts "Error updating credentials file: #{e.message}" + false + end + + def output_tokens(oauth_data) + # Output tokens as GitHub Actions outputs (new format) + File.open(ENV['GITHUB_OUTPUT'], 'a') do |f| + f.puts "access_token=#{oauth_data['accessToken']}" + f.puts "refresh_token=#{oauth_data['refreshToken']}" + f.puts "expires_at=#{oauth_data['expiresAt']}" + end if ENV['GITHUB_OUTPUT'] + + # Mask sensitive values in logs + puts "::add-mask::#{oauth_data['accessToken']}" + puts "::add-mask::#{oauth_data['refreshToken']}" + end +end + +if __FILE__ == $0 + refresher = ClaudeTokenRefresher.new + + if ARGV.include?('--help') || ARGV.include?('-h') + puts "Usage: #{$0} [--force|-f] [--path PATH]" + puts " --force, -f Force refresh even if token is still valid" + puts " --path PATH Custom path to credentials.json file" + puts " --help, -h Show this help message" + exit 0 + end + + custom_path_index = ARGV.index('--path') + if custom_path_index && ARGV[custom_path_index + 1] + refresher = ClaudeTokenRefresher.new(ARGV[custom_path_index + 1]) + end + + success = refresher.refresh_token + exit(success ? 0 : 1) +end \ No newline at end of file diff --git a/.github/workflows/claude-code-with-oauth.yml b/.github/workflows/claude-code-with-oauth.yml new file mode 100644 index 000000000..07bb82b90 --- /dev/null +++ b/.github/workflows/claude-code-with-oauth.yml @@ -0,0 +1,92 @@ +name: Claude Code with OAuth + +on: + issue_comment: + types: [created] + issues: + types: [opened, edited] + pull_request: + types: [opened, edited, synchronize] + pull_request_review_comment: + types: [created] + +jobs: + check-and-refresh-token: + runs-on: ubuntu-latest + outputs: + access_token: ${{ steps.refresh-token.outputs.access_token }} + refresh_token: ${{ steps.refresh-token.outputs.refresh_token }} + expires_at: ${{ steps.refresh-token.outputs.expires_at }} + has_credentials: ${{ steps.check-credentials.outputs.has_credentials }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.2' + + - name: Restore Claude credentials from cache + id: restore-creds + uses: actions/cache/restore@v3 + with: + path: credentials.json + key: claude-credentials-${{ github.sha }} + restore-keys: | + claude-credentials- + + - name: Check if credentials exist + id: check-credentials + run: | + if [ -f credentials.json ]; then + echo "✅ Claude credentials found in cache" + echo "has_credentials=true" >> $GITHUB_OUTPUT + else + echo "❌ No Claude credentials found in cache" + echo "has_credentials=false" >> $GITHUB_OUTPUT + echo "## ❌ No Credentials Found" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Please run the 'Claude OAuth Login' workflow first to generate credentials." >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "[Run Claude OAuth Login](${{ github.server_url }}/${{ github.repository }}/actions/workflows/claude-oauth-login.yml)" >> $GITHUB_STEP_SUMMARY + fi + + - name: Refresh token if needed + id: refresh-token + if: steps.check-credentials.outputs.has_credentials == 'true' + run: | + chmod +x .github/scripts/claude_token_refresh.rb + .github/scripts/claude_token_refresh.rb + + - name: Save updated credentials to cache + if: steps.check-credentials.outputs.has_credentials == 'true' && success() + uses: actions/cache/save@v3 + with: + path: credentials.json + key: claude-credentials-${{ github.sha }}-refreshed-${{ github.run_id }} + + claude-code-action: + needs: check-and-refresh-token + if: needs.check-and-refresh-token.outputs.has_credentials == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude PR Action + uses: grll/claude-code-action@beta + with: + use_oauth: true + claude_access_token: ${{ needs.check-and-refresh-token.outputs.access_token }} + claude_refresh_token: ${{ needs.check-and-refresh-token.outputs.refresh_token }} + claude_expires_at: ${{ needs.check-and-refresh-token.outputs.expires_at }} + timeout_minutes: "60" \ No newline at end of file diff --git a/.github/workflows/example-use-oauth-tokens.yml b/.github/workflows/example-use-oauth-tokens.yml new file mode 100644 index 000000000..5132cf38c --- /dev/null +++ b/.github/workflows/example-use-oauth-tokens.yml @@ -0,0 +1,59 @@ +name: Example - Use OAuth Tokens + +on: + workflow_dispatch: + +jobs: + use-oauth-tokens: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.2' + + - name: Restore Claude credentials from cache + id: restore-creds + uses: actions/cache/restore@v3 + with: + path: credentials.json + key: claude-credentials-${{ github.sha }} + restore-keys: | + claude-credentials- + + - name: Check and refresh token if needed + id: refresh-token + run: | + if [ -f credentials.json ]; then + echo "✅ Claude credentials found in cache" + + # Refresh token if needed + chmod +x .github/scripts/claude_token_refresh.rb + .github/scripts/claude_token_refresh.rb + + echo "## ✅ Token Status" >> $GITHUB_STEP_SUMMARY + echo "Tokens are ready for use." >> $GITHUB_STEP_SUMMARY + else + echo "❌ No Claude credentials found in cache" + echo "## ❌ No Credentials Found" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Please run the 'Claude OAuth Login' workflow first." >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "[Run Claude OAuth Login](${{ github.server_url }}/${{ github.repository }}/actions/workflows/claude-oauth-login.yml)" >> $GITHUB_STEP_SUMMARY + exit 1 + fi + + - name: Use tokens (example) + run: | + echo "Access Token: ${{ steps.refresh-token.outputs.access_token }}" + echo "Refresh Token: ${{ steps.refresh-token.outputs.refresh_token }}" + echo "Expires At: ${{ steps.refresh-token.outputs.expires_at }}" + + echo "## Token Information" >> $GITHUB_STEP_SUMMARY + echo "- Access token available: ✅" >> $GITHUB_STEP_SUMMARY + echo "- Refresh token available: ✅" >> $GITHUB_STEP_SUMMARY + echo "- Expires at: $(date -d @$((${{ steps.refresh-token.outputs.expires_at }}/1000)))" >> $GITHUB_STEP_SUMMARY \ No newline at end of file From 99f78a4a4852c4546245714a4895a0777a6e4f50 Mon Sep 17 00:00:00 2001 From: Guillaume Raille Date: Tue, 24 Jun 2025 23:51:37 +0200 Subject: [PATCH 04/10] add oauth refresh --- .github/scripts/claude_oauth_exchange.rb | 162 ---------------------- .github/scripts/claude_oauth_login.rb | 67 --------- .github/scripts/claude_token_refresh.rb | 156 --------------------- .github/scripts/claude_token_refresh.ts | 169 +++++++++++++++++++++++ .github/workflows/claude-authorized.yml | 9 +- action.yml | 90 +++++++++++- 6 files changed, 260 insertions(+), 393 deletions(-) delete mode 100644 .github/scripts/claude_oauth_exchange.rb delete mode 100644 .github/scripts/claude_oauth_login.rb delete mode 100644 .github/scripts/claude_token_refresh.rb create mode 100755 .github/scripts/claude_token_refresh.ts diff --git a/.github/scripts/claude_oauth_exchange.rb b/.github/scripts/claude_oauth_exchange.rb deleted file mode 100644 index 5ec2b1881..000000000 --- a/.github/scripts/claude_oauth_exchange.rb +++ /dev/null @@ -1,162 +0,0 @@ -#!/usr/bin/env ruby - -require 'net/http' -require 'json' -require 'uri' -require 'time' - -class ClaudeOAuthExchange - OAUTH_TOKEN_URL = 'https://console.anthropic.com/v1/oauth/token' - CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e' - REDIRECT_URI = 'https://console.anthropic.com/oauth/code/callback' - STATE_FILE = 'claude_oauth_state.json' - CREDENTIALS_FILE = 'credentials.json' - - def initialize(authorization_code) - # Clean up the authorization code in case it has URL fragments - @authorization_code = authorization_code.split('#').first.split('&').first - end - - def exchange_code_for_tokens - unless verify_state - puts "Error: Invalid or expired state. Please run the login process again." - return false - end - - puts "Exchanging authorization code for tokens..." - tokens = perform_token_exchange - - if tokens - puts "\nOAuth token exchange successful!" - puts "Received scopes: #{tokens['scopes'].join(', ')}" - - # Save OAuth credentials - save_credentials(tokens) - cleanup_state - - puts "\n=== SUCCESS ===" - puts "OAuth login successful!" - puts "Credentials saved to: #{CREDENTIALS_FILE}" - puts "Token expires at: #{Time.at(tokens['expiresAt'] / 1000).strftime('%Y-%m-%d %H:%M:%S')}" - puts "===============" - - # Output for GitHub Actions - puts "::set-output name=success::true" - puts "::set-output name=expires_at::#{tokens['expiresAt']}" - - true - else - puts "Login failed!" - puts "::set-output name=success::false" - false - end - end - - private - - def verify_state - return false unless File.exist?(STATE_FILE) - - begin - state_data = JSON.parse(File.read(STATE_FILE)) - current_time = Time.now.to_i - - if current_time > state_data['expires_at'] - puts "Error: State has expired (older than 10 minutes)" - return false - end - - true - rescue => e - puts "Error reading state file: #{e.message}" - false - end - end - - def perform_token_exchange - # Load state to get code_verifier - state_data = JSON.parse(File.read(STATE_FILE)) - code_verifier = state_data['code_verifier'] - - uri = URI(OAUTH_TOKEN_URL) - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = true - - request = Net::HTTP::Post.new(uri) - request['Content-Type'] = 'application/json' - request['User-Agent'] = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36' - request['Accept'] = 'application/json, text/plain, */*' - request['Accept-Language'] = 'en-US,en;q=0.9' - request['Referer'] = 'https://claude.ai/' - request['Origin'] = 'https://claude.ai' - - params = { - 'grant_type' => 'authorization_code', - 'client_id' => CLIENT_ID, - 'code' => @authorization_code, - 'redirect_uri' => REDIRECT_URI, - 'code_verifier' => code_verifier, - 'state' => state_data['state'] - } - - # Send as JSON - request.body = JSON.generate(params) - - begin - response = http.request(request) - - if response.code == '200' - data = JSON.parse(response.body) - - { - 'accessToken' => data['access_token'], - 'refreshToken' => data['refresh_token'], - 'expiresAt' => (Time.now.to_i + data['expires_in']) * 1000, - 'scopes' => data['scope'] ? data['scope'].split(' ') : ['user:inference', 'user:profile'], - 'isMax' => true - } - else - puts "Error response: #{response.code} - #{response.body}" - nil - end - rescue => e - puts "Error making token request: #{e.message}" - nil - end - end - - def save_credentials(tokens) - # Create credentials structure - credentials = { - 'claudeAiOauth' => tokens - } - - File.write(CREDENTIALS_FILE, JSON.pretty_generate(credentials)) - true - rescue => e - puts "Error saving credentials: #{e.message}" - false - end - - def cleanup_state - File.delete(STATE_FILE) if File.exist?(STATE_FILE) - rescue => e - puts "Warning: Could not clean up state file: #{e.message}" - end -end - -if __FILE__ == $0 - if ARGV.include?('--help') || ARGV.include?('-h') || ARGV.empty? - puts "Usage: #{$0} " - puts " Completes OAuth login and exchanges code for tokens" - puts " authorization_code: The code received from the OAuth callback" - puts " --help, -h Show this help message" - exit ARGV.empty? ? 1 : 0 - end - - authorization_code = ARGV[0] - exchange = ClaudeOAuthExchange.new(authorization_code) - - success = exchange.exchange_code_for_tokens - exit(success ? 0 : 1) -end \ No newline at end of file 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.rb b/.github/scripts/claude_token_refresh.rb deleted file mode 100644 index 5ffbaad22..000000000 --- a/.github/scripts/claude_token_refresh.rb +++ /dev/null @@ -1,156 +0,0 @@ -#!/usr/bin/env ruby - -require 'net/http' -require 'json' -require 'time' -require 'uri' - -class ClaudeTokenRefresher - OAUTH_TOKEN_URL = 'https://console.anthropic.com/v1/oauth/token' - CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e' - CREDENTIALS_PATH = 'credentials.json' - - def initialize(credentials_path = CREDENTIALS_PATH) - @credentials_path = credentials_path - end - - def refresh_token - credentials = load_credentials - - if credentials.nil? || credentials['claudeAiOauth'].nil? - puts "Error: No valid credentials found in #{@credentials_path}" - return false - end - - oauth_data = credentials['claudeAiOauth'] - refresh_token = oauth_data['refreshToken'] - expires_at = oauth_data['expiresAt'] - - puts "Current token expires at: #{Time.at(expires_at / 1000).strftime('%Y-%m-%d %H:%M:%S')}" - puts "Token expired: #{token_expired?(expires_at)}" - - if !token_expired?(expires_at) && !force_refresh? - puts "Token is still valid. Use --force to refresh anyway." - - # Output current tokens for GitHub Actions - output_tokens(oauth_data) - return true - end - - puts "Refreshing token..." - new_tokens = perform_refresh(refresh_token) - - if new_tokens - update_credentials(credentials, new_tokens) - puts "Token refreshed successfully!" - puts "New token expires at: #{Time.at(new_tokens['expiresAt'] / 1000).strftime('%Y-%m-%d %H:%M:%S')}" - - # Output new tokens for GitHub Actions - output_tokens(new_tokens) - true - else - puts "Failed to refresh token" - false - end - end - - private - - def load_credentials - return nil unless File.exist?(@credentials_path) - - JSON.parse(File.read(@credentials_path)) - rescue JSON::ParserError => e - puts "Error parsing credentials file: #{e.message}" - nil - end - - def token_expired?(expires_at_ms) - # Add 60 minutes buffer to refresh before actual expiry - buffer_ms = 60 * 60 * 1000 - current_time_ms = Time.now.to_i * 1000 - current_time_ms >= (expires_at_ms - buffer_ms) - end - - def force_refresh? - ARGV.include?('--force') || ARGV.include?('-f') - end - - def perform_refresh(refresh_token) - uri = URI(OAUTH_TOKEN_URL) - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = true - - request = Net::HTTP::Post.new(uri) - request['Content-Type'] = 'application/json' - request.body = { - grant_type: 'refresh_token', - refresh_token: refresh_token, - client_id: CLIENT_ID - }.to_json - - begin - response = http.request(request) - - if response.code == '200' - data = JSON.parse(response.body) - - { - 'accessToken' => data['access_token'], - 'refreshToken' => data['refresh_token'], - 'expiresAt' => (Time.now.to_i + data['expires_in']) * 1000, - 'scopes' => data['scope'] ? data['scope'].split(' ') : ['user:inference', 'user:profile'], - 'isMax' => true - } - else - puts "Error response: #{response.code} - #{response.body}" - nil - end - rescue => e - puts "Error making refresh request: #{e.message}" - nil - end - end - - def update_credentials(credentials, new_tokens) - credentials['claudeAiOauth'] = new_tokens - - File.write(@credentials_path, JSON.pretty_generate(credentials)) - rescue => e - puts "Error updating credentials file: #{e.message}" - false - end - - def output_tokens(oauth_data) - # Output tokens as GitHub Actions outputs (new format) - File.open(ENV['GITHUB_OUTPUT'], 'a') do |f| - f.puts "access_token=#{oauth_data['accessToken']}" - f.puts "refresh_token=#{oauth_data['refreshToken']}" - f.puts "expires_at=#{oauth_data['expiresAt']}" - end if ENV['GITHUB_OUTPUT'] - - # Mask sensitive values in logs - puts "::add-mask::#{oauth_data['accessToken']}" - puts "::add-mask::#{oauth_data['refreshToken']}" - end -end - -if __FILE__ == $0 - refresher = ClaudeTokenRefresher.new - - if ARGV.include?('--help') || ARGV.include?('-h') - puts "Usage: #{$0} [--force|-f] [--path PATH]" - puts " --force, -f Force refresh even if token is still valid" - puts " --path PATH Custom path to credentials.json file" - puts " --help, -h Show this help message" - exit 0 - end - - custom_path_index = ARGV.index('--path') - if custom_path_index && ARGV[custom_path_index + 1] - refresher = ClaudeTokenRefresher.new(ARGV[custom_path_index + 1]) - end - - success = refresher.refresh_token - exit(success ? 0 : 1) -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/action.yml b/action.yml index 20bb1813e..f8a00feb4 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,37 @@ 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: 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 +157,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 +219,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 From 1fd412ce759c416d28871816107afd55ab478cd0 Mon Sep 17 00:00:00 2001 From: Guillaume Raille Date: Tue, 24 Jun 2025 23:55:15 +0200 Subject: [PATCH 05/10] add a simple test action --- .github/workflows/test-action.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/workflows/test-action.yml diff --git a/.github/workflows/test-action.yml b/.github/workflows/test-action.yml new file mode 100644 index 000000000..4bc9ca08c --- /dev/null +++ b/.github/workflows/test-action.yml @@ -0,0 +1,30 @@ +name: Test Claude Code Action + +on: + workflow_dispatch: + inputs: + prompt: + description: 'Direct prompt for Claude' + required: true + default: 'Hello Claude! Please introduce yourself and list the files in this repository.' + +jobs: + test-claude: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + issues: write + actions: write + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Run Local Claude Code Action + uses: ./ + with: + direct_prompt: ${{ inputs.prompt }} + use_oauth: true \ No newline at end of file From 3ee17debb261fb041d817a1b375e7e8f9904e507 Mon Sep 17 00:00:00 2001 From: Guillaume Raille Date: Wed, 25 Jun 2025 00:05:09 +0200 Subject: [PATCH 06/10] add claude-oauth-login.yml --- .github/workflows/claude-oauth-login.yml | 126 ++--------------------- 1 file changed, 6 insertions(+), 120 deletions(-) diff --git a/.github/workflows/claude-oauth-login.yml b/.github/workflows/claude-oauth-login.yml index c2fb0271e..13d70ab63 100644 --- a/.github/workflows/claude-oauth-login.yml +++ b/.github/workflows/claude-oauth-login.yml @@ -1,129 +1,15 @@ -name: Claude OAuth Login - +name: Claude OAuth on: workflow_dispatch: inputs: - authorization_code: - description: 'Authorization code from Claude OAuth (leave empty to generate URL)' + code: + description: 'Authorization code (leave empty for step 1)' required: false - type: string jobs: - oauth-login: + auth: runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Restore OAuth state from cache - if: inputs.authorization_code != '' - id: cache-restore - uses: actions/cache/restore@v3 - with: - path: claude_oauth_state.json - key: claude-oauth-state-${{ github.run_id }} - restore-keys: | - claude-oauth-state- - - - name: Setup Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: '3.2' - - - name: Generate OAuth URL or Process Code - run: | - if [ -z "${{ inputs.authorization_code }}" ]; then - # No code provided, generate URL - chmod +x .github/scripts/claude_oauth_login.rb - oauth_url=$(.github/scripts/claude_oauth_login.rb) - - echo "## 🔐 Claude OAuth Login Instructions" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Step 1: Complete OAuth Authorization" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "1. Click this link to authenticate: [$oauth_url]($oauth_url)" >> $GITHUB_STEP_SUMMARY - echo "2. Log in to Anthropic and authorize the application" >> $GITHUB_STEP_SUMMARY - echo "3. Copy the authorization code from the redirect page" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Step 2: Run Workflow Again" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "1. Go to the [Actions tab](${{ github.server_url }}/${{ github.repository }}/actions/workflows/claude-oauth-login-simple.yml)" >> $GITHUB_STEP_SUMMARY - echo "2. Click 'Run workflow'" >> $GITHUB_STEP_SUMMARY - echo "3. Paste the authorization code in the input field" >> $GITHUB_STEP_SUMMARY - echo "4. Click 'Run workflow' to process the code" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "⚠️ **Important:** The authorization code expires in 10 minutes!" >> $GITHUB_STEP_SUMMARY - - # Also output to logs - echo "==================================================" - echo "No authorization code provided." - echo "Please visit the following URL to authenticate:" - echo "$oauth_url" - echo "" - echo "After authorization, run this workflow again with the code." - echo "==================================================" - - # Verify state file was created - if [ -f claude_oauth_state.json ]; then - echo "OAuth state file created successfully" - else - echo "Error: OAuth state file was not created" - exit 1 - fi - else - # Code provided, process it - echo "## ✅ Authorization Code Received" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "Processing authorization code..." >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Code:** \`${{ inputs.authorization_code }}\`" >> $GITHUB_STEP_SUMMARY - echo "**Timestamp:** $(date)" >> $GITHUB_STEP_SUMMARY - - # Check if OAuth state was restored - if [ ! -f claude_oauth_state.json ]; then - echo "❌ Error: OAuth state file not found in cache!" >> $GITHUB_STEP_SUMMARY - echo "Please generate a new OAuth URL first by running this workflow without an authorization code." >> $GITHUB_STEP_SUMMARY - exit 1 - fi - - echo "Authorization code received, exchanging for tokens..." - - # Exchange code for tokens - chmod +x .github/scripts/claude_oauth_exchange.rb - .github/scripts/claude_oauth_exchange.rb "${{ inputs.authorization_code }}" - - if [ -f credentials.json ]; then - echo "✅ Credentials generated successfully!" - - # Cache the credentials for reuse in other workflows - echo "Caching credentials for future use..." - - # Display summary - echo "## ✅ OAuth Login Complete!" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "The Claude OAuth credentials have been successfully generated and cached." >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Next Steps:" >> $GITHUB_STEP_SUMMARY - echo "- The credentials are securely stored in the GitHub Actions cache" >> $GITHUB_STEP_SUMMARY - echo "- Use the cache key \`claude-credentials-${{ github.sha }}\` in other workflows" >> $GITHUB_STEP_SUMMARY - echo "- Cache is only accessible to workflows in this repository" >> $GITHUB_STEP_SUMMARY - else - echo "❌ Failed to generate credentials" - exit 1 - fi - fi - - - name: Save OAuth state to cache - if: inputs.authorization_code == '' - uses: actions/cache/save@v3 - with: - path: claude_oauth_state.json - key: claude-oauth-state-${{ github.run_id }} - - - name: Save credentials to cache - if: inputs.authorization_code != '' && success() - uses: actions/cache/save@v3 + - uses: grll/claude-code-login@v1 with: - path: credentials.json - key: claude-credentials-${{ github.sha }} \ No newline at end of file + code: ${{ inputs.code }} \ No newline at end of file From 81affe17d5699662ccd4e8e34eca2bd8ce2125cf Mon Sep 17 00:00:00 2001 From: Guillaume Raille Date: Wed, 25 Jun 2025 00:06:10 +0200 Subject: [PATCH 07/10] fix permissions --- .github/workflows/claude-oauth-login.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/claude-oauth-login.yml b/.github/workflows/claude-oauth-login.yml index 13d70ab63..b314f1c03 100644 --- a/.github/workflows/claude-oauth-login.yml +++ b/.github/workflows/claude-oauth-login.yml @@ -1,4 +1,5 @@ name: Claude OAuth + on: workflow_dispatch: inputs: @@ -6,6 +7,10 @@ on: description: 'Authorization code (leave empty for step 1)' required: false +permissions: + actions: write # Required for cache management + contents: read # Required for basic repository access + jobs: auth: runs-on: ubuntu-latest From 0ce979a36f486f8048a86cb4c1ac9ba6341fb1d2 Mon Sep 17 00:00:00 2001 From: Guillaume Raille Date: Wed, 25 Jun 2025 00:13:55 +0200 Subject: [PATCH 08/10] change permssions --- .github/workflows/test-action.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 From ea6beee08c037a95ae0c91679869981c91d406c2 Mon Sep 17 00:00:00 2001 From: Guillaume Raille Date: Wed, 25 Jun 2025 00:22:14 +0200 Subject: [PATCH 09/10] add debugs --- action.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/action.yml b/action.yml index f8a00feb4..1e281c672 100644 --- a/action.yml +++ b/action.yml @@ -116,6 +116,16 @@ runs: 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 From 87825a01df65f6a5b49b2300d1099da034e05d65 Mon Sep 17 00:00:00 2001 From: Guillaume Raille Date: Wed, 25 Jun 2025 00:36:21 +0200 Subject: [PATCH 10/10] delete actions --- .github/workflows/claude-code-with-oauth.yml | 92 ------------------- .../workflows/example-use-oauth-tokens.yml | 59 ------------ 2 files changed, 151 deletions(-) delete mode 100644 .github/workflows/claude-code-with-oauth.yml delete mode 100644 .github/workflows/example-use-oauth-tokens.yml diff --git a/.github/workflows/claude-code-with-oauth.yml b/.github/workflows/claude-code-with-oauth.yml deleted file mode 100644 index 07bb82b90..000000000 --- a/.github/workflows/claude-code-with-oauth.yml +++ /dev/null @@ -1,92 +0,0 @@ -name: Claude Code with OAuth - -on: - issue_comment: - types: [created] - issues: - types: [opened, edited] - pull_request: - types: [opened, edited, synchronize] - pull_request_review_comment: - types: [created] - -jobs: - check-and-refresh-token: - runs-on: ubuntu-latest - outputs: - access_token: ${{ steps.refresh-token.outputs.access_token }} - refresh_token: ${{ steps.refresh-token.outputs.refresh_token }} - expires_at: ${{ steps.refresh-token.outputs.expires_at }} - has_credentials: ${{ steps.check-credentials.outputs.has_credentials }} - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: '3.2' - - - name: Restore Claude credentials from cache - id: restore-creds - uses: actions/cache/restore@v3 - with: - path: credentials.json - key: claude-credentials-${{ github.sha }} - restore-keys: | - claude-credentials- - - - name: Check if credentials exist - id: check-credentials - run: | - if [ -f credentials.json ]; then - echo "✅ Claude credentials found in cache" - echo "has_credentials=true" >> $GITHUB_OUTPUT - else - echo "❌ No Claude credentials found in cache" - echo "has_credentials=false" >> $GITHUB_OUTPUT - echo "## ❌ No Credentials Found" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "Please run the 'Claude OAuth Login' workflow first to generate credentials." >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "[Run Claude OAuth Login](${{ github.server_url }}/${{ github.repository }}/actions/workflows/claude-oauth-login.yml)" >> $GITHUB_STEP_SUMMARY - fi - - - name: Refresh token if needed - id: refresh-token - if: steps.check-credentials.outputs.has_credentials == 'true' - run: | - chmod +x .github/scripts/claude_token_refresh.rb - .github/scripts/claude_token_refresh.rb - - - name: Save updated credentials to cache - if: steps.check-credentials.outputs.has_credentials == 'true' && success() - uses: actions/cache/save@v3 - with: - path: credentials.json - key: claude-credentials-${{ github.sha }}-refreshed-${{ github.run_id }} - - claude-code-action: - needs: check-and-refresh-token - if: needs.check-and-refresh-token.outputs.has_credentials == 'true' - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - issues: read - id-token: write - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Run Claude PR Action - uses: grll/claude-code-action@beta - with: - use_oauth: true - claude_access_token: ${{ needs.check-and-refresh-token.outputs.access_token }} - claude_refresh_token: ${{ needs.check-and-refresh-token.outputs.refresh_token }} - claude_expires_at: ${{ needs.check-and-refresh-token.outputs.expires_at }} - timeout_minutes: "60" \ No newline at end of file diff --git a/.github/workflows/example-use-oauth-tokens.yml b/.github/workflows/example-use-oauth-tokens.yml deleted file mode 100644 index 5132cf38c..000000000 --- a/.github/workflows/example-use-oauth-tokens.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: Example - Use OAuth Tokens - -on: - workflow_dispatch: - -jobs: - use-oauth-tokens: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: '3.2' - - - name: Restore Claude credentials from cache - id: restore-creds - uses: actions/cache/restore@v3 - with: - path: credentials.json - key: claude-credentials-${{ github.sha }} - restore-keys: | - claude-credentials- - - - name: Check and refresh token if needed - id: refresh-token - run: | - if [ -f credentials.json ]; then - echo "✅ Claude credentials found in cache" - - # Refresh token if needed - chmod +x .github/scripts/claude_token_refresh.rb - .github/scripts/claude_token_refresh.rb - - echo "## ✅ Token Status" >> $GITHUB_STEP_SUMMARY - echo "Tokens are ready for use." >> $GITHUB_STEP_SUMMARY - else - echo "❌ No Claude credentials found in cache" - echo "## ❌ No Credentials Found" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "Please run the 'Claude OAuth Login' workflow first." >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "[Run Claude OAuth Login](${{ github.server_url }}/${{ github.repository }}/actions/workflows/claude-oauth-login.yml)" >> $GITHUB_STEP_SUMMARY - exit 1 - fi - - - name: Use tokens (example) - run: | - echo "Access Token: ${{ steps.refresh-token.outputs.access_token }}" - echo "Refresh Token: ${{ steps.refresh-token.outputs.refresh_token }}" - echo "Expires At: ${{ steps.refresh-token.outputs.expires_at }}" - - echo "## Token Information" >> $GITHUB_STEP_SUMMARY - echo "- Access token available: ✅" >> $GITHUB_STEP_SUMMARY - echo "- Refresh token available: ✅" >> $GITHUB_STEP_SUMMARY - echo "- Expires at: $(date -d @$((${{ steps.refresh-token.outputs.expires_at }}/1000)))" >> $GITHUB_STEP_SUMMARY \ No newline at end of file