fix: use vscode.env.asExternalUri() for Claude Code OAuth in remote environments

Fixes authentication in remote environments (GitHub Codespaces, etc.) by using
vscode.env.asExternalUri() to dynamically convert the localhost redirect URI
to an externally accessible URL.

Changes:
- Modified startAuthorizationFlow() to accept vscode.env and vscode.Uri.parse
- Uses asExternalUri() to transform localhost:54545/callback to forwarded URL
- Updated buildAuthorizationUrl() and exchangeCodeForTokens() to accept custom redirectUri
- Added tests for custom redirect URI functionality

Fixes #10531
This commit is contained in:
Roo Code 2026-01-09 11:26:11 +00:00
parent 2ff08b5e5c
commit b441e44372
3 changed files with 77 additions and 9 deletions

View file

@ -2343,7 +2343,9 @@ export const webviewMessageHandler = async (
case "claudeCodeSignIn": {
try {
const { claudeCodeOAuthManager } = await import("../../integrations/claude-code/oauth")
const authUrl = claudeCodeOAuthManager.startAuthorizationFlow()
// Pass vscode.env and vscode.Uri.parse to support remote environments (GitHub Codespaces, etc.)
// vscode.env.asExternalUri() will convert localhost URLs to forwarded URLs
const authUrl = await claudeCodeOAuthManager.startAuthorizationFlow(vscode.env, vscode.Uri.parse)
// Open the authorization URL in the browser
await vscode.env.openExternal(vscode.Uri.parse(authUrl))

View file

@ -137,6 +137,31 @@ describe("Claude Code OAuth", () => {
expect(params.get("response_type")).toBe("code")
expect(params.get("state")).toBe(state)
})
test("should use custom redirect URI when provided (for remote environments)", () => {
const codeChallenge = "test-code-challenge"
const state = "test-state"
const customRedirectUri = "https://codespace-name-54545.app.github.dev/callback"
const url = buildAuthorizationUrl(codeChallenge, state, customRedirectUri)
const parsedUrl = new URL(url)
const params = parsedUrl.searchParams
expect(params.get("redirect_uri")).toBe(customRedirectUri)
// Other parameters should still be correct
expect(params.get("client_id")).toBe(CLAUDE_CODE_OAUTH_CONFIG.clientId)
expect(params.get("code_challenge")).toBe(codeChallenge)
expect(params.get("state")).toBe(state)
})
test("should fall back to default redirect URI when custom is undefined", () => {
const codeChallenge = "test-code-challenge"
const state = "test-state"
const url = buildAuthorizationUrl(codeChallenge, state, undefined)
const parsedUrl = new URL(url)
const params = parsedUrl.searchParams
expect(params.get("redirect_uri")).toBe(CLAUDE_CODE_OAUTH_CONFIG.redirectUri)
})
})
describe("isTokenExpired", () => {

View file

@ -1,7 +1,7 @@
import * as crypto from "crypto"
import * as http from "http"
import { URL } from "url"
import type { ExtensionContext } from "vscode"
import type { ExtensionContext, Uri } from "vscode"
import { z } from "zod"
// OAuth Configuration
@ -147,11 +147,14 @@ export function generateUserId(email?: string): string {
/**
* Builds the authorization URL for OAuth flow
* @param codeChallenge PKCE code challenge
* @param state CSRF protection state
* @param redirectUri The redirect URI to use (may be externally mapped for remote environments)
*/
export function buildAuthorizationUrl(codeChallenge: string, state: string): string {
export function buildAuthorizationUrl(codeChallenge: string, state: string, redirectUri?: string): string {
const params = new URLSearchParams({
client_id: CLAUDE_CODE_OAUTH_CONFIG.clientId,
redirect_uri: CLAUDE_CODE_OAUTH_CONFIG.redirectUri,
redirect_uri: redirectUri ?? CLAUDE_CODE_OAUTH_CONFIG.redirectUri,
scope: CLAUDE_CODE_OAUTH_CONFIG.scopes,
code_challenge: codeChallenge,
code_challenge_method: "S256",
@ -164,18 +167,23 @@ export function buildAuthorizationUrl(codeChallenge: string, state: string): str
/**
* Exchanges the authorization code for tokens
* @param code The authorization code
* @param codeVerifier The PKCE code verifier
* @param state The CSRF state
* @param redirectUri The redirect URI used in the authorization request (must match)
*/
export async function exchangeCodeForTokens(
code: string,
codeVerifier: string,
state: string,
redirectUri?: string,
): Promise<ClaudeCodeCredentials> {
const body = {
code,
state,
grant_type: "authorization_code",
client_id: CLAUDE_CODE_OAUTH_CONFIG.clientId,
redirect_uri: CLAUDE_CODE_OAUTH_CONFIG.redirectUri,
redirect_uri: redirectUri ?? CLAUDE_CODE_OAUTH_CONFIG.redirectUri,
code_verifier: codeVerifier,
}
@ -277,6 +285,7 @@ export class ClaudeCodeOAuthManager {
private pendingAuth: {
codeVerifier: string
state: string
redirectUri: string
server?: http.Server
} | null = null
@ -468,9 +477,17 @@ export class ClaudeCodeOAuthManager {
/**
* Start the OAuth authorization flow
* Returns the authorization URL to open in browser
* Uses vscode.env.asExternalUri() to support remote environments (GitHub Codespaces, etc.)
* @param vscodeEnv The vscode.env object for URI transformation
* @param parseUri Function to parse a string into a Uri (e.g., vscode.Uri.parse)
* @returns The authorization URL to open in browser
*/
startAuthorizationFlow(): string {
async startAuthorizationFlow(
vscodeEnv?: {
asExternalUri: (uri: Uri) => Thenable<Uri>
},
parseUri?: (value: string) => Uri,
): Promise<string> {
// Cancel any existing authorization flow before starting a new one
this.cancelAuthorizationFlow()
@ -478,12 +495,30 @@ export class ClaudeCodeOAuthManager {
const codeChallenge = generateCodeChallenge(codeVerifier)
const state = generateState()
// Determine the redirect URI
// In remote environments (Codespaces, etc.), use asExternalUri to get the forwarded URL
let resolvedRedirectUri: string = CLAUDE_CODE_OAUTH_CONFIG.redirectUri
if (vscodeEnv && parseUri) {
try {
// Parse the localhost URI and transform it using asExternalUri
const localUri = parseUri(CLAUDE_CODE_OAUTH_CONFIG.redirectUri)
const externalUri = await vscodeEnv.asExternalUri(localUri)
resolvedRedirectUri = externalUri.toString()
this.log(`[claude-code-oauth] Resolved redirect URI: ${resolvedRedirectUri}`)
} catch (error) {
// Fall back to localhost if asExternalUri fails
this.logError("[claude-code-oauth] Failed to resolve external URI, using localhost:", error)
}
}
this.pendingAuth = {
codeVerifier,
state,
redirectUri: resolvedRedirectUri,
}
return buildAuthorizationUrl(codeChallenge, state)
return buildAuthorizationUrl(codeChallenge, state, resolvedRedirectUri)
}
/**
@ -545,7 +580,13 @@ export class ClaudeCodeOAuthManager {
}
try {
const credentials = await exchangeCodeForTokens(code, this.pendingAuth.codeVerifier, state)
// Use the same redirect URI that was used in the authorization request
const credentials = await exchangeCodeForTokens(
code,
this.pendingAuth.codeVerifier,
state,
this.pendingAuth.redirectUri,
)
await this.saveCredentials(credentials)