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
This commit is contained in:
Roo Code 2026-01-08 16:49:07 +00:00
parent 2cd4c2e2d5
commit 63afd92fc5
5 changed files with 290 additions and 10 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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<ClaudeCodeProps> = ({
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 (
<div className="flex flex-col gap-4">
@ -36,15 +73,80 @@ export const ClaudeCode: React.FC<ClaudeCodeProps> = ({
})}
</Button>
</div>
) : showManualAuth ? (
<div className="flex flex-col gap-3">
<div className="text-sm text-vscode-descriptionForeground">
{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.",
})}
</>
)}
</div>
{!isManualAuthPending && (
<Button variant="primary" onClick={handleStartManualAuth} className="w-fit">
{t("settings:providers.claudeCode.startManualSignIn", {
defaultValue: "Start Manual Sign In",
})}
</Button>
)}
{isManualAuthPending && (
<>
<VSCodeTextField
value={manualAuthCode}
onInput={(e: React.ChangeEvent<HTMLInputElement>) =>
setManualAuthCode(e.target.value)
}
placeholder={t("settings:providers.claudeCode.authCodePlaceholder", {
defaultValue: "Paste authorization code here",
})}
/>
<div className="flex gap-2">
<Button
variant="primary"
onClick={handleSubmitManualCode}
disabled={!manualAuthCode.trim()}>
{t("settings:providers.claudeCode.submitCode", {
defaultValue: "Submit Code",
})}
</Button>
<Button variant="secondary" onClick={handleCancelManualAuth}>
{t("common:actions.cancel", {
defaultValue: "Cancel",
})}
</Button>
</div>
</>
)}
</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
type="button"
onClick={() => setShowManualAuth(true)}
className="text-xs text-vscode-textLink-foreground hover:underline cursor-pointer w-fit bg-transparent border-none p-0">
{t("settings:providers.claudeCode.useManualSignIn", {
defaultValue: "Using a remote environment? Sign in manually",
})}
</button>
</div>
)}
</div>