mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-06 08:18:39 +00:00
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:
parent
2cd4c2e2d5
commit
63afd92fc5
5 changed files with 290 additions and 10 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -128,6 +128,8 @@ export interface WebviewMessage {
|
|||
| "rooCloudManualUrl"
|
||||
| "claudeCodeSignIn"
|
||||
| "claudeCodeSignOut"
|
||||
| "claudeCodeStartManualAuth"
|
||||
| "claudeCodeSubmitManualCode"
|
||||
| "switchOrganization"
|
||||
| "condenseTaskContextRequest"
|
||||
| "requestIndexingStatus"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue