From 63afd92fc5fea58c48936663b87aef630adc2110 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Thu, 8 Jan 2026 16:49:07 +0000 Subject: [PATCH] feat(claude-code): add manual auth flow for remote environments This adds an alternative OAuth flow for users in remote environments like GitHub Codespaces where localhost redirects do not work. The implementation includes: - New OOB (out-of-band) redirect URI support in oauth.ts - startManualAuthorizationFlow() and exchangeManualCode() methods - UI updates in ClaudeCode.tsx with manual code entry option - New message types: claudeCodeStartManualAuth, claudeCodeSubmitManualCode Users can click "Sign in manually" to start the flow, then copy the authorization code from the browser and paste it into Roo Code. Closes #10531 --- src/core/webview/webviewMessageHandler.ts | 48 +++++++ src/integrations/claude-code/oauth.ts | 127 ++++++++++++++++++ src/shared/ExtensionMessage.ts | 1 + src/shared/WebviewMessage.ts | 2 + .../settings/providers/ClaudeCode.tsx | 122 +++++++++++++++-- 5 files changed, 290 insertions(+), 10 deletions(-) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 0df014b49ab..92a3eaba2db 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -2367,6 +2367,54 @@ export const webviewMessageHandler = async ( } break } + case "claudeCodeStartManualAuth": { + // Start manual auth flow for remote environments (e.g., GitHub Codespaces) + try { + const { claudeCodeOAuthManager } = await import("../../integrations/claude-code/oauth") + const authUrl = claudeCodeOAuthManager.startManualAuthorizationFlow() + + // Open the authorization URL in the browser + await vscode.env.openExternal(vscode.Uri.parse(authUrl)) + + // Notify the webview that manual auth flow has started + await provider.postMessageToWebview({ + type: "claudeCodeManualAuthStarted", + text: authUrl, + }) + } catch (error) { + provider.log(`Claude Code manual OAuth failed: ${error}`) + vscode.window.showErrorMessage("Claude Code manual sign in failed to start.") + } + break + } + case "claudeCodeSubmitManualCode": { + // Exchange manually-entered authorization code for tokens + try { + const code = message.text?.trim() + if (!code) { + vscode.window.showErrorMessage("Please enter the authorization code.") + break + } + + const { claudeCodeOAuthManager } = await import("../../integrations/claude-code/oauth") + + if (!claudeCodeOAuthManager.hasPendingManualAuth()) { + vscode.window.showErrorMessage( + "No pending authorization. Please click 'Sign in manually' first.", + ) + break + } + + await claudeCodeOAuthManager.exchangeManualCode(code) + vscode.window.showInformationMessage("Successfully signed in to Claude Code") + await provider.postStateToWebview() + } 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}`) + } + break + } case "claudeCodeSignOut": { try { const { claudeCodeOAuthManager } = await import("../../integrations/claude-code/oauth") diff --git a/src/integrations/claude-code/oauth.ts b/src/integrations/claude-code/oauth.ts index 5d7a929e1cc..da6daff6e90 100644 --- a/src/integrations/claude-code/oauth.ts +++ b/src/integrations/claude-code/oauth.ts @@ -10,6 +10,8 @@ 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", + // Out-of-band redirect URI for manual auth flow (remote environments like Codespaces) + oobRedirectUri: "urn:ietf:wg:oauth:2.0:oob", scopes: "org:create_api_key user:profile user:inference", callbackPort: 54545, } as const @@ -162,6 +164,24 @@ export function buildAuthorizationUrl(codeChallenge: string, state: string): str return `${CLAUDE_CODE_OAUTH_CONFIG.authorizationEndpoint}?${params.toString()}` } +/** + * Builds the authorization URL for manual/OOB OAuth flow + * Used for remote environments where localhost redirect is not possible + */ +export function buildManualAuthorizationUrl(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 +233,58 @@ export async function exchangeCodeForTokens( } } +/** + * Exchanges the manually-entered authorization code for tokens (OOB flow) + * Used for remote environments where localhost redirect is not possible + */ +export async function exchangeManualCodeForTokens( + 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() + throw new Error(`Token exchange failed: ${response.status} ${response.statusText} - ${errorText}`) + } + + 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 +558,61 @@ export class ClaudeCodeOAuthManager { return buildAuthorizationUrl(codeChallenge, state) } + /** + * Start the manual OAuth authorization flow (OOB/device flow) + * Used for remote environments like GitHub Codespaces where localhost redirect is not possible + * Returns the authorization URL to open in browser + */ + startManualAuthorizationFlow(): 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] Starting manual authorization flow (OOB)") + return buildManualAuthorizationUrl(codeChallenge, state) + } + + /** + * Exchange a manually-entered authorization code for tokens + * Used for remote environments where localhost redirect is not possible + * @param code The authorization code copied by the user from the browser + */ + async exchangeManualCode(code: string): Promise { + if (!this.pendingAuth) { + throw new Error("No pending authorization flow. Please start the manual auth flow first.") + } + + const { codeVerifier, state } = this.pendingAuth + + try { + this.log("[claude-code-oauth] Exchanging manual authorization code for tokens") + const credentials = await exchangeManualCodeForTokens(code, codeVerifier, state) + await this.saveCredentials(credentials) + this.log("[claude-code-oauth] Manual auth successful, credentials saved") + this.pendingAuth = null + return credentials + } catch (error) { + this.logError("[claude-code-oauth] Manual code exchange failed:", error) + this.pendingAuth = null + throw error + } + } + + /** + * Check if there is a pending manual auth flow + */ + hasPendingManualAuth(): boolean { + return this.pendingAuth !== null && !this.pendingAuth.server + } + /** * Start a local server to receive the OAuth callback * Returns a promise that resolves when authentication is complete diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 2eec4cb6c88..79b97b5e1bc 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -131,6 +131,7 @@ export interface ExtensionMessage { | "browserSessionUpdate" | "browserSessionNavigate" | "claudeCodeRateLimits" + | "claudeCodeManualAuthStarted" | "customToolsResult" text?: string payload?: any // Add a generic payload for now, can refine later diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 4c3e321dea8..89cdb63ff4f 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -128,6 +128,8 @@ export interface WebviewMessage { | "rooCloudManualUrl" | "claudeCodeSignIn" | "claudeCodeSignOut" + | "claudeCodeStartManualAuth" + | "claudeCodeSubmitManualCode" | "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..6773230960f 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 } 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,43 @@ export const ClaudeCode: React.FC = ({ claudeCodeIsAuthenticated = false, }) => { const { t } = useAppTranslation() + const [showManualAuth, setShowManualAuth] = useState(false) + const [manualAuthCode, setManualAuthCode] = useState("") + const [isManualAuthPending, setIsManualAuthPending] = useState(false) + + // Listen for manual auth started message + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + const message = event.data + if (message.type === "claudeCodeManualAuthStarted") { + setIsManualAuthPending(true) + } + } + + window.addEventListener("message", handleMessage) + return () => window.removeEventListener("message", handleMessage) + }, []) + + const handleStartManualAuth = () => { + setShowManualAuth(true) + setManualAuthCode("") + vscode.postMessage({ type: "claudeCodeStartManualAuth" }) + } + + const handleSubmitManualCode = () => { + if (manualAuthCode.trim()) { + vscode.postMessage({ type: "claudeCodeSubmitManualCode", text: manualAuthCode.trim() }) + setManualAuthCode("") + setShowManualAuth(false) + setIsManualAuthPending(false) + } + } + + const handleCancelManualAuth = () => { + setShowManualAuth(false) + setManualAuthCode("") + setIsManualAuthPending(false) + } return (
@@ -36,15 +73,80 @@ export const ClaudeCode: React.FC = ({ })}
+ ) : showManualAuth ? ( +
+
+ {isManualAuthPending ? ( + <> + {t("settings:providers.claudeCode.manualAuthInstructions", { + defaultValue: + "A browser window has opened. After authorizing, copy the code from the page and paste it below:", + })} + + ) : ( + <> + {t("settings:providers.claudeCode.manualAuthClickStart", { + defaultValue: + "Click 'Start Manual Sign In' to open the authorization page in your browser.", + })} + + )} +
+ {!isManualAuthPending && ( + + )} + {isManualAuthPending && ( + <> + ) => + setManualAuthCode(e.target.value) + } + placeholder={t("settings:providers.claudeCode.authCodePlaceholder", { + defaultValue: "Paste authorization code here", + })} + /> +
+ + +
+ + )} +
) : ( - +
+ + +
)}