Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
127 changes: 127 additions & 0 deletions src/integrations/claude-code/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/shared/ExtensionMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/shared/WebviewMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ export interface WebviewMessage {
| "rooCloudManualUrl"
| "claudeCodeSignIn"
| "claudeCodeSignOut"
| "claudeCodeStartManualAuth"
| "claudeCodeSubmitManualCode"
| "switchOrganization"
| "condenseTaskContextRequest"
| "requestIndexingStatus"
Expand Down
122 changes: 112 additions & 10 deletions webview-ui/src/components/settings/providers/ClaudeCode.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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">
Expand All @@ -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>

Expand Down
Loading