From ab40ba072ab922a958435ef4e57805fd15c73112 Mon Sep 17 00:00:00 2001 From: Guillaume Raille Date: Wed, 25 Jun 2025 23:32:02 +0200 Subject: [PATCH] add support for token refresh via PAT --- README.md | 50 ++++++-- action.yml | 5 + src/index.ts | 1 + src/setup-oauth.ts | 125 +++++++++++++++++++- test/setup-oauth.test.ts | 242 ++++++++++++++++++++++++++++++++++++++- 5 files changed, 411 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 8c2e543..fc721c3 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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. @@ -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 @@ -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: @@ -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: @@ -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 @@ -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' @@ -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) @@ -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`: @@ -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) diff --git a/action.yml b/action.yml index 6ff893c..88dea89 100644 --- a/action.yml +++ b/action.yml @@ -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)" @@ -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 }} diff --git a/src/index.ts b/src/index.ts index 8b7593e..b985b86 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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, }); } diff --git a/src/setup-oauth.ts b/src/setup-oauth.ts index 7476681..b5dffea 100644 --- a/src/setup-oauth.ts +++ b/src/setup-oauth.ts @@ -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) { @@ -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"], }, }; diff --git a/test/setup-oauth.test.ts b/test/setup-oauth.test.ts index 4123270..e82ce7d 100644 --- a/test/setup-oauth.test.ts +++ b/test/setup-oauth.test.ts @@ -1,17 +1,21 @@ #!/usr/bin/env bun -import { describe, test, expect, afterEach, beforeEach } from "bun:test"; +import { describe, test, expect, afterEach, beforeEach, mock, spyOn } from "bun:test"; import { setupOAuthCredentials } from "../src/setup-oauth"; import { readFile, unlink, access } from "fs/promises"; import { join } from "path"; import { homedir } from "os"; +import * as childProcess from "child_process"; describe("setupOAuthCredentials", () => { let originalXdgConfigHome: string | undefined; + let originalFetch: typeof global.fetch; beforeEach(() => { // Save original XDG_CONFIG_HOME originalXdgConfigHome = process.env.XDG_CONFIG_HOME; + // Save original fetch + originalFetch = global.fetch; }); afterEach(async () => { @@ -22,6 +26,9 @@ describe("setupOAuthCredentials", () => { process.env.XDG_CONFIG_HOME = originalXdgConfigHome; } + // Restore original fetch + global.fetch = originalFetch; + // Clean up the credentials file after each test const paths = [join(homedir(), ".claude", ".credentials.json")]; @@ -149,4 +156,237 @@ describe("setupOAuthCredentials", () => { // Clean up await unlink(xdgCredentialsPath); }); + + describe("Token refresh functionality", () => { + test("should display warning when token is expiring and secrets_admin_pat is missing", async () => { + const consoleSpy = spyOn(console, "warn").mockImplementation(() => {}); + + // Set token to expire in 30 minutes (should trigger warning) + const expiresAt = (Date.now() + 30 * 60 * 1000).toString(); + + const credentials = { + accessToken: "test-access-token", + refreshToken: "test-refresh-token", + expiresAt: expiresAt, + // No secretsAdminPat provided + }; + + await setupOAuthCredentials(credentials); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("WARNING: OAuth token is expiring soon but SECRETS_ADMIN_PAT is not set!") + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("https://github.com/grll/claude-code-login/blob/main/README.md#prerequisites-setting-up-secrets_admin_pat") + ); + + consoleSpy.mockRestore(); + }); + + test("should not display warning when token is not expiring", async () => { + const consoleSpy = spyOn(console, "warn").mockImplementation(() => {}); + const consoleLogSpy = spyOn(console, "log").mockImplementation(() => {}); + + // Set token to expire in 2 hours (should not trigger warning) + const expiresAt = (Date.now() + 2 * 60 * 60 * 1000).toString(); + + const credentials = { + accessToken: "test-access-token", + refreshToken: "test-refresh-token", + expiresAt: expiresAt, + }; + + await setupOAuthCredentials(credentials); + + expect(consoleSpy).not.toHaveBeenCalled(); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining("Token is still valid") + ); + + consoleSpy.mockRestore(); + consoleLogSpy.mockRestore(); + }); + + test("should attempt token refresh when secrets_admin_pat is provided and token is expiring", async () => { + // Mock fetch to simulate successful token refresh + const mockFetch = mock(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + access_token: "new-access-token", + refresh_token: "new-refresh-token", + expires_in: 3600, + }), + }) + ); + global.fetch = mockFetch as any; + + // Mock execSync to prevent actual execution but allow the test to proceed + const execSyncSpy = spyOn(childProcess, "execSync").mockImplementation(() => { + // Return empty buffer to simulate successful execution + return Buffer.from("") as any; + }); + const consoleLogSpy = spyOn(console, "log").mockImplementation(() => {}); + + // Set token to expire in 30 minutes + const expiresAt = (Date.now() + 30 * 60 * 1000).toString(); + + const credentials = { + accessToken: "old-access-token", + refreshToken: "old-refresh-token", + expiresAt: expiresAt, + secretsAdminPat: "test-pat-token", + }; + + await setupOAuthCredentials(credentials); + + // Verify fetch was called for token refresh + expect(mockFetch).toHaveBeenCalledWith( + "https://console.anthropic.com/v1/oauth/token", + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + body: expect.stringContaining("refresh_token"), + }) + ); + + expect(consoleLogSpy).toHaveBeenCalledWith("✅ Token refreshed successfully!"); + + // Verify credentials file contains new tokens + const credentialsPath = join(homedir(), ".claude", ".credentials.json"); + const content = await readFile(credentialsPath, "utf-8"); + const parsed = JSON.parse(content); + + expect(parsed.claudeAiOauth.accessToken).toBe("new-access-token"); + expect(parsed.claudeAiOauth.refreshToken).toBe("new-refresh-token"); + + execSyncSpy.mockRestore(); + consoleLogSpy.mockRestore(); + }); + + test("should handle token refresh failure gracefully", async () => { + // Mock fetch to simulate failed token refresh + const mockFetch = mock(() => + Promise.resolve({ + ok: false, + status: 400, + text: () => Promise.resolve('{"error": "invalid_grant"}'), + }) + ); + global.fetch = mockFetch as any; + + const consoleLogSpy = spyOn(console, "log").mockImplementation(() => {}); + const consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {}); + + // Set token to expire in 30 minutes + const expiresAt = (Date.now() + 30 * 60 * 1000).toString(); + + const credentials = { + accessToken: "old-access-token", + refreshToken: "invalid-refresh-token", + expiresAt: expiresAt, + secretsAdminPat: "test-pat-token", + }; + + await setupOAuthCredentials(credentials); + + expect(consoleLogSpy).toHaveBeenCalledWith("🔄 Token expired or expiring soon, refreshing..."); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining("Token refresh failed: 400") + ); + expect(consoleErrorSpy).toHaveBeenCalledWith("❌ Failed to refresh token, using existing credentials"); + + // Verify credentials file still contains old tokens + const credentialsPath = join(homedir(), ".claude", ".credentials.json"); + const content = await readFile(credentialsPath, "utf-8"); + const parsed = JSON.parse(content); + + expect(parsed.claudeAiOauth.accessToken).toBe("old-access-token"); + expect(parsed.claudeAiOauth.refreshToken).toBe("invalid-refresh-token"); + + consoleLogSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + + test("should handle GitHub secrets update failure", async () => { + // Mock fetch to simulate successful token refresh + const mockFetch = mock(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + access_token: "new-access-token", + refresh_token: "new-refresh-token", + expires_in: 3600, + }), + }) + ); + global.fetch = mockFetch as any; + + // Mock execSync to simulate GitHub secrets update failure + const execSyncSpy = spyOn(childProcess, "execSync").mockImplementation(() => { + throw new Error("GitHub CLI error"); + }); + const consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {}); + + // Set token to expire in 30 minutes + const expiresAt = (Date.now() + 30 * 60 * 1000).toString(); + + const credentials = { + accessToken: "old-access-token", + refreshToken: "old-refresh-token", + expiresAt: expiresAt, + secretsAdminPat: "test-pat-token", + }; + + // Expect the function to throw due to GitHub CLI failure + await expect(setupOAuthCredentials(credentials)).rejects.toThrow(); + + execSyncSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + + test("should use correct environment variables when updating GitHub secrets", async () => { + // Mock fetch to simulate successful token refresh + const mockFetch = mock(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + access_token: "new-access-token", + refresh_token: "new-refresh-token", + expires_in: 3600, + }), + }) + ); + global.fetch = mockFetch as any; + + // Mock execSync to capture environment variables and simulate success + const execSyncSpy = spyOn(childProcess, "execSync").mockImplementation(() => { + return Buffer.from("") as any; + }); + + // Set token to expire in 30 minutes + const expiresAt = (Date.now() + 30 * 60 * 1000).toString(); + + const credentials = { + accessToken: "old-access-token", + refreshToken: "old-refresh-token", + expiresAt: expiresAt, + secretsAdminPat: "test-pat-token-12345", + }; + + await setupOAuthCredentials(credentials); + + // Verify execSync was called with correct environment + expect(execSyncSpy).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + env: expect.objectContaining({ + GH_TOKEN: "test-pat-token-12345", + }), + }) + ); + + execSyncSpy.mockRestore(); + }); + }); });