mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-14 23:21:19 +00:00
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:
parent
2cd4c2e2d5
commit
40a9d5a3dd
5 changed files with 346 additions and 11 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -128,6 +128,8 @@ export interface WebviewMessage {
|
|||
| "rooCloudManualUrl"
|
||||
| "claudeCodeSignIn"
|
||||
| "claudeCodeSignOut"
|
||||
| "claudeCodeStartOobFlow"
|
||||
| "claudeCodeExchangeManualCode"
|
||||
| "switchOrganization"
|
||||
| "condenseTaskContextRequest"
|
||||
| "requestIndexingStatus"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue