diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 0df014b49ab..053f7f647ec 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -2379,6 +2379,61 @@ export const webviewMessageHandler = async ( } break } + case "claudeCodeStartOobFlow": { + // Start OOB (out-of-band) OAuth flow for remote environments + // This flow displays the auth code on a webpage for manual entry + try { + const { claudeCodeOAuthManager } = await import("../../integrations/claude-code/oauth") + const authUrl = claudeCodeOAuthManager.startOobAuthorizationFlow() + + // Open the authorization URL in the browser + await vscode.env.openExternal(vscode.Uri.parse(authUrl)) + + // Notify webview that OOB flow has started + await provider.postMessageToWebview({ + type: "claudeCodeOobFlowStarted", + success: true, + }) + } catch (error) { + provider.log(`Claude Code OOB OAuth failed: ${error}`) + vscode.window.showErrorMessage("Claude Code sign in failed.") + await provider.postMessageToWebview({ + type: "claudeCodeOobFlowStarted", + success: false, + error: error instanceof Error ? error.message : String(error), + }) + } + break + } + case "claudeCodeExchangeManualCode": { + // Exchange manually entered auth code for tokens (OOB flow) + try { + const code = message.text?.trim() + if (!code) { + throw new Error("No authorization code provided") + } + + const { claudeCodeOAuthManager } = await import("../../integrations/claude-code/oauth") + await claudeCodeOAuthManager.exchangeManualCode(code) + + vscode.window.showInformationMessage("Successfully signed in to Claude Code") + await provider.postStateToWebview() + await provider.postMessageToWebview({ + type: "claudeCodeManualCodeResult", + success: true, + }) + } catch (error) { + provider.log(`Claude Code manual code exchange failed: ${error}`) + const errorMessage = error instanceof Error ? error.message : String(error) + vscode.window.showErrorMessage(`Claude Code sign in failed: ${errorMessage}`) + await provider.postMessageToWebview({ + type: "claudeCodeManualCodeResult", + success: false, + error: errorMessage, + }) + } + break + } case "rooCloudManualUrl": { try { if (!message.text) { diff --git a/src/integrations/claude-code/oauth.ts b/src/integrations/claude-code/oauth.ts index 5d7a929e1cc..e49a9094ca8 100644 --- a/src/integrations/claude-code/oauth.ts +++ b/src/integrations/claude-code/oauth.ts @@ -10,6 +10,9 @@ export const CLAUDE_CODE_OAUTH_CONFIG = { tokenEndpoint: "https://console.anthropic.com/v1/oauth/token", clientId: "9d1c250a-e61b-44d9-88ed-5944d1962f5e", redirectUri: "http://localhost:54545/callback", + // OOB (out-of-band) redirect URI - tells the server to display the auth code + // on the webpage instead of redirecting, allowing manual code entry + oobRedirectUri: "urn:ietf:wg:oauth:2.0:oob", scopes: "org:create_api_key user:profile user:inference", callbackPort: 54545, } as const @@ -162,6 +165,25 @@ export function buildAuthorizationUrl(codeChallenge: string, state: string): str return `${CLAUDE_CODE_OAUTH_CONFIG.authorizationEndpoint}?${params.toString()}` } +/** + * Builds the authorization URL for OOB (out-of-band) OAuth flow. + * This flow is used for remote environments where localhost callbacks don't work. + * The auth code is displayed on the webpage for manual copy-paste. + */ +export function buildOobAuthorizationUrl(codeChallenge: string, state: string): string { + const params = new URLSearchParams({ + client_id: CLAUDE_CODE_OAUTH_CONFIG.clientId, + redirect_uri: CLAUDE_CODE_OAUTH_CONFIG.oobRedirectUri, + scope: CLAUDE_CODE_OAUTH_CONFIG.scopes, + code_challenge: codeChallenge, + code_challenge_method: "S256", + response_type: "code", + state, + }) + + return `${CLAUDE_CODE_OAUTH_CONFIG.authorizationEndpoint}?${params.toString()}` +} + /** * Exchanges the authorization code for tokens */ @@ -213,6 +235,63 @@ export async function exchangeCodeForTokens( } } +/** + * Exchanges the authorization code for tokens using OOB redirect URI. + * Used for manual code entry in remote environments. + */ +export async function exchangeOobCodeForTokens( + code: string, + codeVerifier: string, + state: string, +): Promise { + const body = { + code, + state, + grant_type: "authorization_code", + client_id: CLAUDE_CODE_OAUTH_CONFIG.clientId, + redirect_uri: CLAUDE_CODE_OAUTH_CONFIG.oobRedirectUri, + code_verifier: codeVerifier, + } + + const response = await fetch(CLAUDE_CODE_OAUTH_CONFIG.tokenEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(30000), + }) + + if (!response.ok) { + const errorText = await response.text() + const { errorCode, errorMessage } = parseOAuthErrorDetails(errorText) + const details = errorMessage ? errorMessage : errorText + throw new ClaudeCodeOAuthTokenError( + `Token exchange failed: ${response.status} ${response.statusText}${details ? ` - ${details}` : ""}`, + { status: response.status, errorCode }, + ) + } + + const data = await response.json() + const tokenResponse = tokenResponseSchema.parse(data) + + if (!tokenResponse.refresh_token) { + // The access token is unusable without a refresh token for persistence. + throw new Error("Token exchange did not return a refresh_token") + } + + // Calculate expiry time + const expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000) + + return { + type: "claude", + access_token: tokenResponse.access_token, + refresh_token: tokenResponse.refresh_token, + expired: expiresAt.toISOString(), + email: tokenResponse.email, + } +} + /** * Refreshes the access token using the refresh token */ @@ -486,6 +565,63 @@ export class ClaudeCodeOAuthManager { return buildAuthorizationUrl(codeChallenge, state) } + /** + * Start the OOB (out-of-band) OAuth authorization flow for remote environments. + * This flow displays the auth code on the webpage for manual entry. + * Returns the authorization URL to open in browser. + */ + startOobAuthorizationFlow(): string { + // Cancel any existing authorization flow before starting a new one + this.cancelAuthorizationFlow() + + const codeVerifier = generateCodeVerifier() + const codeChallenge = generateCodeChallenge(codeVerifier) + const state = generateState() + + this.pendingAuth = { + codeVerifier, + state, + } + + this.log(`[claude-code-oauth] Started OOB authorization flow (state=${state})`) + return buildOobAuthorizationUrl(codeChallenge, state) + } + + /** + * Exchange manually entered auth code for tokens (OOB flow). + * Used for remote environments where localhost callbacks don't work. + * @param code The authorization code copied from the webpage + */ + async exchangeManualCode(code: string): Promise { + if (!this.pendingAuth) { + throw new Error("No pending authorization flow. Please start the OOB flow first.") + } + + const { codeVerifier, state } = this.pendingAuth + + this.log(`[claude-code-oauth] Exchanging manual code for tokens...`) + + try { + const credentials = await exchangeOobCodeForTokens(code, codeVerifier, state) + await this.saveCredentials(credentials) + this.pendingAuth = null + this.log(`[claude-code-oauth] Manual code exchange successful (email=${credentials.email || "unknown"})`) + return credentials + } catch (error) { + this.logError("[claude-code-oauth] Manual code exchange failed:", error) + // Don't clear pending auth on failure - allow retry with different code + throw error + } + } + + /** + * Check if there's a pending OOB authorization flow waiting for manual code entry + */ + hasPendingOobFlow(): boolean { + // OOB flow is pending if we have pendingAuth but no server (server is only used for localhost flow) + return this.pendingAuth !== null && this.pendingAuth.server === undefined + } + /** * Start a local server to receive the OAuth callback * Returns a promise that resolves when authentication is complete diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 4c3e321dea8..5eaba0fe0ec 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -128,6 +128,8 @@ export interface WebviewMessage { | "rooCloudManualUrl" | "claudeCodeSignIn" | "claudeCodeSignOut" + | "claudeCodeStartOobFlow" + | "claudeCodeExchangeManualCode" | "switchOrganization" | "condenseTaskContextRequest" | "requestIndexingStatus" diff --git a/webview-ui/src/components/settings/providers/ClaudeCode.tsx b/webview-ui/src/components/settings/providers/ClaudeCode.tsx index 87072a9b976..5c48261d37c 100644 --- a/webview-ui/src/components/settings/providers/ClaudeCode.tsx +++ b/webview-ui/src/components/settings/providers/ClaudeCode.tsx @@ -1,7 +1,7 @@ -import React from "react" +import React, { useState, useEffect, useCallback } from "react" import { type ProviderSettings, claudeCodeDefaultModelId, claudeCodeModels } from "@roo-code/types" import { useAppTranslation } from "@src/i18n/TranslationContext" -import { Button } from "@src/components/ui" +import { Button, VSCodeTextField } from "@src/components/ui" import { vscode } from "@src/utils/vscode" import { ModelPicker } from "../ModelPicker" import { ClaudeCodeRateLimitDashboard } from "./ClaudeCodeRateLimitDashboard" @@ -20,6 +20,55 @@ export const ClaudeCode: React.FC = ({ claudeCodeIsAuthenticated = false, }) => { const { t } = useAppTranslation() + const [showManualAuth, setShowManualAuth] = useState(false) + const [manualCode, setManualCode] = useState("") + const [oobFlowStarted, setOobFlowStarted] = useState(false) + const [isExchanging, setIsExchanging] = useState(false) + const [exchangeError, setExchangeError] = useState(null) + + // Handle messages from extension + const handleMessage = useCallback((event: MessageEvent) => { + const message = event.data + if (message.type === "claudeCodeOobFlowStarted") { + if (message.success) { + setOobFlowStarted(true) + setExchangeError(null) + } else { + setExchangeError(message.error || "Failed to start authentication") + } + } else if (message.type === "claudeCodeManualCodeResult") { + setIsExchanging(false) + if (message.success) { + // Reset state on success + setShowManualAuth(false) + setManualCode("") + setOobFlowStarted(false) + setExchangeError(null) + } else { + setExchangeError(message.error || "Failed to exchange code") + } + } + }, []) + + useEffect(() => { + window.addEventListener("message", handleMessage) + return () => window.removeEventListener("message", handleMessage) + }, [handleMessage]) + + const handleStartOobFlow = () => { + setExchangeError(null) + vscode.postMessage({ type: "claudeCodeStartOobFlow" }) + } + + const handleExchangeCode = () => { + if (!manualCode.trim()) { + setExchangeError("Please enter the authorization code") + return + } + setIsExchanging(true) + setExchangeError(null) + vscode.postMessage({ type: "claudeCodeExchangeManualCode", text: manualCode.trim() }) + } return (
@@ -36,15 +85,97 @@ export const ClaudeCode: React.FC = ({ })}
+ ) : showManualAuth ? ( +
+
+ {t("settings:providers.claudeCode.manualAuthDescription", { + defaultValue: + "For remote environments (GitHub Codespaces, SSH, etc.) where localhost callbacks don't work:", + })} +
+ + {!oobFlowStarted ? ( + <> + +
+ {t("settings:providers.claudeCode.manualAuthStep1", { + defaultValue: + "This will open Anthropic's sign-in page. After signing in, you'll see an authorization code.", + })} +
+ + ) : ( + <> +
+ + setManualCode(e.target.value)} + placeholder={t("settings:providers.claudeCode.codePlaceholder", { + defaultValue: "Paste your authorization code here", + })} + className="w-full" + /> +
+ + + )} + + {exchangeError &&
{exchangeError}
} + + +
) : ( - +
+ + +
)} diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index b836ecbfc87..15ffeed8db4 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -527,7 +527,18 @@ "description": "Optional path to your Claude Code CLI. Defaults to 'claude' if not set.", "placeholder": "Default: claude", "maxTokensLabel": "Max Output Tokens", - "maxTokensDescription": "Maximum number of output tokens for Claude Code responses. Default is 8000." + "maxTokensDescription": "Maximum number of output tokens for Claude Code responses. Default is 8000.", + "signInButton": "Sign in to Claude Code", + "signOutButton": "Sign Out", + "manualAuthDescription": "For remote environments (GitHub Codespaces, SSH, etc.) where localhost callbacks don't work:", + "openAuthPage": "1. Open Authentication Page", + "manualAuthStep1": "This will open Anthropic's sign-in page. After signing in, you'll see an authorization code.", + "pasteCodeLabel": "2. Paste the authorization code:", + "codePlaceholder": "Paste your authorization code here", + "exchangingCode": "Signing in...", + "submitCode": "3. Complete Sign In", + "backToNormalAuth": "← Back to normal sign in", + "remoteEnvLink": "Using a remote environment? Click here for manual sign in" } }, "browser": {