feat(claude-code): add manual auth code flow for remote environments

Adds out-of-band (OOB) OAuth flow for Claude Code sign-in that works in
remote environments like GitHub Codespaces and SSH sessions where
localhost callbacks do not work.

Changes:
- Add OOB redirect URI constant and buildOobAuthorizationUrl function
- Add exchangeOobCodeForTokens function for manual code exchange
- Add startOobAuthorizationFlow and exchangeManualCode methods to manager
- Add claudeCodeStartOobFlow and claudeCodeExchangeManualCode message types
- Add webview handlers for the new message types
- Update ClaudeCode.tsx with manual auth UI (step-by-step flow)
- Add i18n translations for the new auth flow

Fixes #10531
This commit is contained in:
Roo Code 2026-01-08 16:53:15 +00:00
parent 2cd4c2e2d5
commit 40a9d5a3dd
5 changed files with 346 additions and 11 deletions

View file

@ -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) {

View file

@ -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<ClaudeCodeCredentials> {
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<ClaudeCodeCredentials> {
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

View file

@ -128,6 +128,8 @@ export interface WebviewMessage {
| "rooCloudManualUrl"
| "claudeCodeSignIn"
| "claudeCodeSignOut"
| "claudeCodeStartOobFlow"
| "claudeCodeExchangeManualCode"
| "switchOrganization"
| "condenseTaskContextRequest"
| "requestIndexingStatus"

View file

@ -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<ClaudeCodeProps> = ({
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<string | null>(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 (
<div className="flex flex-col gap-4">
@ -36,15 +85,97 @@ export const ClaudeCode: React.FC<ClaudeCodeProps> = ({
})}
</Button>
</div>
) : showManualAuth ? (
<div className="flex flex-col gap-3">
<div className="text-sm text-vscode-descriptionForeground">
{t("settings:providers.claudeCode.manualAuthDescription", {
defaultValue:
"For remote environments (GitHub Codespaces, SSH, etc.) where localhost callbacks don't work:",
})}
</div>
{!oobFlowStarted ? (
<>
<Button variant="primary" onClick={handleStartOobFlow} className="w-fit">
{t("settings:providers.claudeCode.openAuthPage", {
defaultValue: "1. Open Authentication Page",
})}
</Button>
<div className="text-xs text-vscode-descriptionForeground">
{t("settings:providers.claudeCode.manualAuthStep1", {
defaultValue:
"This will open Anthropic's sign-in page. After signing in, you'll see an authorization code.",
})}
</div>
</>
) : (
<>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">
{t("settings:providers.claudeCode.pasteCodeLabel", {
defaultValue: "2. Paste the authorization code:",
})}
</label>
<VSCodeTextField
value={manualCode}
onInput={(e: any) => setManualCode(e.target.value)}
placeholder={t("settings:providers.claudeCode.codePlaceholder", {
defaultValue: "Paste your authorization code here",
})}
className="w-full"
/>
</div>
<Button
variant="primary"
onClick={handleExchangeCode}
disabled={isExchanging || !manualCode.trim()}
className="w-fit">
{isExchanging
? t("settings:providers.claudeCode.exchangingCode", {
defaultValue: "Signing in...",
})
: t("settings:providers.claudeCode.submitCode", {
defaultValue: "3. Complete Sign In",
})}
</Button>
</>
)}
{exchangeError && <div className="text-sm text-vscode-errorForeground">{exchangeError}</div>}
<Button
variant="secondary"
size="sm"
onClick={() => {
setShowManualAuth(false)
setOobFlowStarted(false)
setManualCode("")
setExchangeError(null)
}}
className="w-fit">
{t("settings:providers.claudeCode.backToNormalAuth", {
defaultValue: "← Back to normal sign in",
})}
</Button>
</div>
) : (
<Button
variant="primary"
onClick={() => vscode.postMessage({ type: "claudeCodeSignIn" })}
className="w-fit">
{t("settings:providers.claudeCode.signInButton", {
defaultValue: "Sign in to Claude Code",
})}
</Button>
<div className="flex flex-col gap-2">
<Button
variant="primary"
onClick={() => vscode.postMessage({ type: "claudeCodeSignIn" })}
className="w-fit">
{t("settings:providers.claudeCode.signInButton", {
defaultValue: "Sign in to Claude Code",
})}
</Button>
<button
onClick={() => setShowManualAuth(true)}
className="text-xs text-vscode-textLink-foreground hover:underline cursor-pointer text-left w-fit">
{t("settings:providers.claudeCode.remoteEnvLink", {
defaultValue: "Using a remote environment? Click here for manual sign in",
})}
</button>
</div>
)}
</div>

View file

@ -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": {