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
55 changes: 55 additions & 0 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
136 changes: 136 additions & 0 deletions src/integrations/claude-code/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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
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"
| "claudeCodeStartOobFlow"
| "claudeCodeExchangeManualCode"
| "switchOrganization"
| "condenseTaskContextRequest"
| "requestIndexingStatus"
Expand Down
Loading
Loading