From 0948135ce0180af59cde50b74d6639d01d72579f Mon Sep 17 00:00:00 2001 From: Roo Code Date: Tue, 12 Aug 2025 14:14:15 +0000 Subject: [PATCH] feat: add OAuth authentication support for ChatGPT Plus/Pro accounts - Add OAuth types and configuration for ChatGPT authentication - Implement PKCE and state validation helpers for secure OAuth flow - Create OAuth callback server to handle authorization codes - Add token exchange functionality to get API keys from OAuth tokens - Implement SecretStorage manager for ChatGPT credentials - Update OpenAI provider to support chatgpt auth mode - Add main authentication manager to orchestrate the OAuth flow This allows users with ChatGPT Plus/Pro subscriptions to authenticate using their existing accounts instead of requiring separate API billing. Compatible with Codex CLI authentication flow. Fixes #6993 --- packages/types/src/index.ts | 1 + packages/types/src/oauth.ts | 86 +++++ packages/types/src/provider-settings.ts | 1 + src/api/providers/openai.ts | 52 ++- src/core/auth/chatgpt-auth-manager.ts | 324 +++++++++++++++++ src/core/auth/chatgpt-credentials-manager.ts | 194 +++++++++++ src/core/auth/oauth-helpers.ts | 162 +++++++++ src/core/auth/oauth-server.ts | 344 +++++++++++++++++++ src/core/auth/token-exchange.ts | 166 +++++++++ 9 files changed, 1327 insertions(+), 3 deletions(-) create mode 100644 packages/types/src/oauth.ts create mode 100644 src/core/auth/chatgpt-auth-manager.ts create mode 100644 src/core/auth/chatgpt-credentials-manager.ts create mode 100644 src/core/auth/oauth-helpers.ts create mode 100644 src/core/auth/oauth-server.ts create mode 100644 src/core/auth/token-exchange.ts diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index dcbb1c4f54f..ac1a4dc3a34 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -12,6 +12,7 @@ export * from "./mcp.js" export * from "./message.js" export * from "./mode.js" export * from "./model.js" +export * from "./oauth.js" export * from "./provider-settings.js" export * from "./sharing.js" export * from "./task.js" diff --git a/packages/types/src/oauth.ts b/packages/types/src/oauth.ts new file mode 100644 index 00000000000..933f7f81ece --- /dev/null +++ b/packages/types/src/oauth.ts @@ -0,0 +1,86 @@ +import { z } from "zod" + +/** + * OAuth configuration for ChatGPT authentication + */ +export const CHATGPT_OAUTH_CONFIG = { + clientId: "app_EMoamEEZ73f0CkXaXp7hrann", // Codex CLI client ID for compatibility + authorizationUrl: "https://auth.openai.com/oauth/authorize", + tokenUrl: "https://auth.openai.com/oauth/token", + redirectUri: "http://localhost:1455/auth/callback", + defaultPort: 1455, + scopes: ["openid", "profile", "email", "offline_access"], +} as const + +/** + * OAuth tokens structure + */ +export const oauthTokensSchema = z.object({ + accessToken: z.string(), + idToken: z.string(), + refreshToken: z.string(), + expiresIn: z.number().optional(), + tokenType: z.string().optional(), +}) + +export type OAuthTokens = z.infer + +/** + * ChatGPT credentials stored in SecretStorage + */ +export const chatGptCredentialsSchema = z.object({ + apiKey: z.string().optional(), // Exchanged API key + idToken: z.string(), + refreshToken: z.string(), + lastRefreshIso: z.string().optional(), + responseId: z.string().optional(), // For conversation continuity +}) + +export type ChatGptCredentials = z.infer + +/** + * Codex CLI auth.json structure for import + */ +export const codexAuthJsonSchema = z.object({ + OPENAI_API_KEY: z.string().optional(), + tokens: z + .object({ + id_token: z.string(), + access_token: z.string().optional(), + refresh_token: z.string().optional(), + }) + .optional(), + last_refresh: z.string().optional(), +}) + +export type CodexAuthJson = z.infer + +/** + * OAuth state for CSRF protection + */ +export interface OAuthState { + state: string + codeVerifier: string + timestamp: number +} + +/** + * Token exchange request for getting API key from OAuth tokens + */ +export interface TokenExchangeRequest { + grant_type: "urn:ietf:params:oauth:grant-type:token-exchange" + requested_token_type: "openai-api-key" + subject_token: string // ID token + subject_token_type: "urn:ietf:params:oauth:token-type:id_token" + client_id: string +} + +/** + * OAuth error response + */ +export const oauthErrorSchema = z.object({ + error: z.string(), + error_description: z.string().optional(), +}) + +export type OAuthError = z.infer diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index e6b5c4ca260..d9294b1214d 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -155,6 +155,7 @@ const openAiSchema = baseProviderSettingsSchema.extend({ openAiStreamingEnabled: z.boolean().optional(), openAiHostHeader: z.string().optional(), // Keep temporarily for backward compatibility during migration. openAiHeaders: z.record(z.string(), z.string()).optional(), + openAiAuthMode: z.enum(["apiKey", "chatgpt"]).optional(), // New: Authentication mode }) const ollamaSchema = baseProviderSettingsSchema.extend({ diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index eed719cf0fb..8fff772aa79 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -29,17 +29,47 @@ import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from ". // compatible with the OpenAI API. We can also rename it to `OpenAIHandler`. export class OpenAiHandler extends BaseProvider implements SingleCompletionHandler { protected options: ApiHandlerOptions - private client: OpenAI + private client!: OpenAI // Using definite assignment assertion since we initialize it + private apiKeyPromise?: Promise constructor(options: ApiHandlerOptions) { super() this.options = options + // Initialize the client asynchronously if using ChatGPT auth + if (this.options.openAiAuthMode === "chatgpt") { + this.apiKeyPromise = this.getApiKeyFromChatGpt() + this.apiKeyPromise + .then((apiKey) => { + this.initializeClient(apiKey) + }) + .catch((error) => { + console.error("Failed to get API key from ChatGPT:", error) + }) + } else { + // Initialize immediately for regular API key mode + this.initializeClient(this.options.openAiApiKey ?? "not-provided") + } + } + + private async getApiKeyFromChatGpt(): Promise { + // Lazy import to avoid circular dependencies + const { getCredentialsManager } = await import("../../core/auth/chatgpt-credentials-manager") + const credentialsManager = getCredentialsManager() + const apiKey = await credentialsManager.getApiKey() + + if (!apiKey) { + throw new Error("No API key found for ChatGPT authentication. Please sign in with your ChatGPT account.") + } + + return apiKey + } + + private initializeClient(apiKey: string): void { const baseURL = this.options.openAiBaseUrl ?? "https://api.openai.com/v1" - const apiKey = this.options.openAiApiKey ?? "not-provided" const isAzureAiInference = this._isAzureAiInference(this.options.openAiBaseUrl) const urlHost = this._getUrlHost(this.options.openAiBaseUrl) - const isAzureOpenAi = urlHost === "azure.com" || urlHost.endsWith(".azure.com") || options.openAiUseAzure + const isAzureOpenAi = urlHost === "azure.com" || urlHost.endsWith(".azure.com") || this.options.openAiUseAzure const headers = { ...DEFAULT_HEADERS, @@ -77,6 +107,14 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { + // Ensure client is initialized for ChatGPT auth mode + if (this.apiKeyPromise) { + await this.apiKeyPromise + } + + if (!this.client) { + throw new Error("OpenAI client not initialized") + } const { info: modelInfo, reasoning } = this.getModel() const modelUrl = this.options.openAiBaseUrl ?? "" const modelId = this.options.openAiModelId ?? "" @@ -256,6 +294,14 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl async completePrompt(prompt: string): Promise { try { + // Ensure client is initialized for ChatGPT auth mode + if (this.apiKeyPromise) { + await this.apiKeyPromise + } + + if (!this.client) { + throw new Error("OpenAI client not initialized") + } const isAzureAiInference = this._isAzureAiInference(this.options.openAiBaseUrl) const model = this.getModel() const modelInfo = model.info diff --git a/src/core/auth/chatgpt-auth-manager.ts b/src/core/auth/chatgpt-auth-manager.ts new file mode 100644 index 00000000000..fe4f4e703eb --- /dev/null +++ b/src/core/auth/chatgpt-auth-manager.ts @@ -0,0 +1,324 @@ +import * as vscode from "vscode" +import { + generateState, + generatePKCE, + buildAuthorizationUrl, + storeOAuthState, + findAvailablePort, + isTokenExpired, +} from "./oauth-helpers" +import { OAuthCallbackServer, exchangeCodeForTokens, refreshTokens } from "./oauth-server" +import { exchangeTokenForApiKey, redeemComplimentaryCredits } from "./token-exchange" +import { ChatGptCredentialsManager, initializeCredentialsManager } from "./chatgpt-credentials-manager" +import type { OAuthState } from "@roo-code/types" + +/** + * Main manager for ChatGPT OAuth authentication + */ +export class ChatGptAuthManager { + private credentialsManager: ChatGptCredentialsManager + + constructor(private context: vscode.ExtensionContext) { + this.credentialsManager = initializeCredentialsManager(context) + } + + /** + * Sign in with ChatGPT using OAuth + */ + async signIn(): Promise { + try { + // Check if already authenticated + if (await this.credentialsManager.isAuthenticated()) { + const result = await vscode.window.showInformationMessage( + "You are already signed in with ChatGPT. Do you want to sign in with a different account?", + "Yes", + "No", + ) + + if (result !== "Yes") { + return true + } + + // Clear existing credentials + await this.credentialsManager.clearCredentials() + } + + // Generate OAuth parameters + const state = generateState() + const { codeVerifier, codeChallenge } = generatePKCE() + + // Find available port for callback server + const port = await findAvailablePort() + + // Store state for CSRF protection + const oauthState: OAuthState = { + state, + codeVerifier, + timestamp: Date.now(), + } + await storeOAuthState(this.context, oauthState) + + // Start callback server + const server = new OAuthCallbackServer(port) + await server.start(this.context) + + // Build authorization URL + const authUrl = buildAuthorizationUrl(state, codeChallenge, port) + + // Open browser for authentication + await vscode.env.openExternal(vscode.Uri.parse(authUrl)) + + // Show progress notification + const authPromise = vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "Signing in with ChatGPT", + cancellable: true, + }, + async (progress, token) => { + progress.report({ message: "Waiting for authentication..." }) + + // Wait for authorization code + const code = await Promise.race([ + server.waitForCode(), + new Promise((_, reject) => { + token.onCancellationRequested(() => { + reject(new Error("Authentication cancelled")) + }) + }), + ]) + + progress.report({ increment: 33, message: "Exchanging code for tokens..." }) + + // Exchange code for tokens + const tokens = await exchangeCodeForTokens(code, codeVerifier, port) + + progress.report({ increment: 33, message: "Getting API key..." }) + + // Exchange tokens for API key + const apiKey = await exchangeTokenForApiKey(tokens.idToken) + + if (!apiKey) { + throw new Error("Failed to obtain API key") + } + + // Store credentials + await this.credentialsManager.storeCredentials({ + apiKey, + idToken: tokens.idToken, + refreshToken: tokens.refreshToken, + lastRefreshIso: new Date().toISOString(), + }) + + progress.report({ increment: 34, message: "Authentication complete!" }) + + // Try to redeem complimentary credits (best effort) + redeemComplimentaryCredits(tokens.idToken).catch(() => {}) + + return true + }, + ) + + const result = await authPromise + server.stop() + + if (result) { + vscode.window.showInformationMessage("Successfully signed in with ChatGPT!") + } + + return result + } catch (error: any) { + vscode.window.showErrorMessage(`Authentication failed: ${error.message}`) + return false + } + } + + /** + * Sign out from ChatGPT + */ + async signOut(): Promise { + const result = await vscode.window.showWarningMessage( + "Are you sure you want to sign out from ChatGPT?", + "Yes", + "No", + ) + + if (result === "Yes") { + await this.credentialsManager.clearCredentials() + vscode.window.showInformationMessage("Signed out from ChatGPT") + } + } + + /** + * Refresh credentials if needed + */ + async refreshCredentials(): Promise { + try { + const credentials = await this.credentialsManager.getCredentials() + + if (!credentials) { + return false + } + + // Check if tokens need refresh + if (!isTokenExpired(credentials.idToken)) { + return true // No refresh needed + } + + // Refresh tokens + const tokens = await refreshTokens(credentials.refreshToken) + + // Exchange new tokens for API key + const apiKey = await exchangeTokenForApiKey(tokens.idToken) + + if (!apiKey) { + throw new Error("Failed to obtain API key after refresh") + } + + // Update stored credentials + await this.credentialsManager.updateTokens( + tokens.idToken, + tokens.refreshToken || credentials.refreshToken, + apiKey, + ) + + return true + } catch (error: any) { + console.error("Failed to refresh credentials:", error) + return false + } + } + + /** + * Get authentication status + */ + async getAuthStatus(): Promise<{ + isAuthenticated: boolean + hasApiKey: boolean + needsRefresh?: boolean + }> { + return this.credentialsManager.getAuthStatus() + } + + /** + * Import credentials from Codex CLI auth.json file + */ + async importFromCodexCli(): Promise { + try { + const homeDir = process.env.HOME || process.env.USERPROFILE + if (!homeDir) { + throw new Error("Could not determine home directory") + } + + const authJsonPath = `${homeDir}/.codex/auth.json` + const fs = await import("fs/promises") + + // Check if file exists + try { + await fs.access(authJsonPath) + } catch { + vscode.window.showErrorMessage( + "Codex CLI auth.json not found. Please ensure you have authenticated with Codex CLI first.", + ) + return false + } + + // Read and parse auth.json + const authJsonContent = await fs.readFile(authJsonPath, "utf-8") + const authJson = JSON.parse(authJsonContent) + + // Validate structure + if (!authJson.tokens?.id_token || !authJson.tokens?.refresh_token) { + throw new Error("Invalid auth.json format") + } + + // Store credentials + await this.credentialsManager.storeCredentials({ + apiKey: authJson.OPENAI_API_KEY, + idToken: authJson.tokens.id_token, + refreshToken: authJson.tokens.refresh_token, + lastRefreshIso: authJson.last_refresh || new Date().toISOString(), + }) + + vscode.window.showInformationMessage("Successfully imported credentials from Codex CLI") + return true + } catch (error: any) { + vscode.window.showErrorMessage(`Failed to import from Codex CLI: ${error.message}`) + return false + } + } + + /** + * Import credentials from pasted auth.json content + */ + async importFromPaste(): Promise { + try { + const input = await vscode.window.showInputBox({ + prompt: "Paste the contents of your Codex CLI auth.json file", + placeHolder: '{"OPENAI_API_KEY": "...", "tokens": {...}}', + ignoreFocusOut: true, + validateInput: (value) => { + if (!value) { + return "Please paste the auth.json content" + } + try { + JSON.parse(value) + return null + } catch { + return "Invalid JSON format" + } + }, + }) + + if (!input) { + return false + } + + const authJson = JSON.parse(input) + + // Validate structure + if (!authJson.tokens?.id_token || !authJson.tokens?.refresh_token) { + throw new Error("Invalid auth.json format - missing required tokens") + } + + // Store credentials + await this.credentialsManager.storeCredentials({ + apiKey: authJson.OPENAI_API_KEY, + idToken: authJson.tokens.id_token, + refreshToken: authJson.tokens.refresh_token, + lastRefreshIso: authJson.last_refresh || new Date().toISOString(), + }) + + vscode.window.showInformationMessage("Successfully imported credentials") + return true + } catch (error: any) { + vscode.window.showErrorMessage(`Failed to import credentials: ${error.message}`) + return false + } + } +} + +/** + * Global instance of the auth manager + */ +let authManager: ChatGptAuthManager | undefined + +/** + * Initialize the auth manager + */ +export function initializeAuthManager(context: vscode.ExtensionContext): ChatGptAuthManager { + if (!authManager) { + authManager = new ChatGptAuthManager(context) + } + return authManager +} + +/** + * Get the auth manager instance + */ +export function getAuthManager(): ChatGptAuthManager { + if (!authManager) { + throw new Error("ChatGptAuthManager not initialized. Call initializeAuthManager first.") + } + return authManager +} diff --git a/src/core/auth/chatgpt-credentials-manager.ts b/src/core/auth/chatgpt-credentials-manager.ts new file mode 100644 index 00000000000..578d6fe2820 --- /dev/null +++ b/src/core/auth/chatgpt-credentials-manager.ts @@ -0,0 +1,194 @@ +import * as vscode from "vscode" +import { type ChatGptCredentials, chatGptCredentialsSchema } from "@roo-code/types" + +/** + * Manages ChatGPT credentials in VS Code's SecretStorage + */ +export class ChatGptCredentialsManager { + private static readonly API_KEY_KEY = "roo.openai.chatgpt.apiKey" + private static readonly ID_TOKEN_KEY = "roo.openai.chatgpt.idToken" + private static readonly REFRESH_TOKEN_KEY = "roo.openai.chatgpt.refreshToken" + private static readonly LAST_REFRESH_KEY = "roo.openai.chatgpt.lastRefreshIso" + private static readonly RESPONSE_ID_KEY = "roo.openai.chatgpt.responseId" + + constructor(private context: vscode.ExtensionContext) {} + + /** + * Store ChatGPT credentials in SecretStorage + */ + async storeCredentials(credentials: ChatGptCredentials): Promise { + const promises: Thenable[] = [] + + if (credentials.apiKey !== undefined) { + promises.push(this.context.secrets.store(ChatGptCredentialsManager.API_KEY_KEY, credentials.apiKey)) + } + + promises.push(this.context.secrets.store(ChatGptCredentialsManager.ID_TOKEN_KEY, credentials.idToken)) + promises.push(this.context.secrets.store(ChatGptCredentialsManager.REFRESH_TOKEN_KEY, credentials.refreshToken)) + + if (credentials.lastRefreshIso) { + promises.push( + this.context.secrets.store(ChatGptCredentialsManager.LAST_REFRESH_KEY, credentials.lastRefreshIso), + ) + } + + if (credentials.responseId) { + promises.push(this.context.secrets.store(ChatGptCredentialsManager.RESPONSE_ID_KEY, credentials.responseId)) + } + + await Promise.all(promises) + } + + /** + * Retrieve ChatGPT credentials from SecretStorage + */ + async getCredentials(): Promise { + const [apiKey, idToken, refreshToken, lastRefreshIso, responseId] = await Promise.all([ + this.context.secrets.get(ChatGptCredentialsManager.API_KEY_KEY), + this.context.secrets.get(ChatGptCredentialsManager.ID_TOKEN_KEY), + this.context.secrets.get(ChatGptCredentialsManager.REFRESH_TOKEN_KEY), + this.context.secrets.get(ChatGptCredentialsManager.LAST_REFRESH_KEY), + this.context.secrets.get(ChatGptCredentialsManager.RESPONSE_ID_KEY), + ]) + + // If no ID token, user is not authenticated + if (!idToken || !refreshToken) { + return null + } + + const credentials: ChatGptCredentials = { + apiKey, + idToken, + refreshToken, + lastRefreshIso, + responseId, + } + + // Validate the credentials structure + const result = chatGptCredentialsSchema.safeParse(credentials) + if (!result.success) { + console.error("Invalid ChatGPT credentials in storage:", result.error) + return null + } + + return result.data + } + + /** + * Get just the API key + */ + async getApiKey(): Promise { + return this.context.secrets.get(ChatGptCredentialsManager.API_KEY_KEY) + } + + /** + * Update just the API key + */ + async updateApiKey(apiKey: string): Promise { + await this.context.secrets.store(ChatGptCredentialsManager.API_KEY_KEY, apiKey) + } + + /** + * Update the response ID for conversation continuity + */ + async updateResponseId(responseId: string): Promise { + await this.context.secrets.store(ChatGptCredentialsManager.RESPONSE_ID_KEY, responseId) + } + + /** + * Update tokens after refresh + */ + async updateTokens(idToken: string, refreshToken: string, apiKey?: string): Promise { + const promises: Thenable[] = [ + this.context.secrets.store(ChatGptCredentialsManager.ID_TOKEN_KEY, idToken), + this.context.secrets.store(ChatGptCredentialsManager.REFRESH_TOKEN_KEY, refreshToken), + this.context.secrets.store(ChatGptCredentialsManager.LAST_REFRESH_KEY, new Date().toISOString()), + ] + + if (apiKey) { + promises.push(this.context.secrets.store(ChatGptCredentialsManager.API_KEY_KEY, apiKey)) + } + + await Promise.all(promises) + } + + /** + * Clear all ChatGPT credentials + */ + async clearCredentials(): Promise { + await Promise.all([ + this.context.secrets.delete(ChatGptCredentialsManager.API_KEY_KEY), + this.context.secrets.delete(ChatGptCredentialsManager.ID_TOKEN_KEY), + this.context.secrets.delete(ChatGptCredentialsManager.REFRESH_TOKEN_KEY), + this.context.secrets.delete(ChatGptCredentialsManager.LAST_REFRESH_KEY), + this.context.secrets.delete(ChatGptCredentialsManager.RESPONSE_ID_KEY), + ]) + } + + /** + * Check if user is authenticated with ChatGPT + */ + async isAuthenticated(): Promise { + const credentials = await this.getCredentials() + return credentials !== null && !!credentials.idToken && !!credentials.refreshToken + } + + /** + * Get authentication status with details + */ + async getAuthStatus(): Promise<{ + isAuthenticated: boolean + hasApiKey: boolean + lastRefresh?: string + needsRefresh?: boolean + }> { + const credentials = await this.getCredentials() + + if (!credentials) { + return { + isAuthenticated: false, + hasApiKey: false, + } + } + + // Check if tokens need refresh (older than 28 days) + let needsRefresh = false + if (credentials.lastRefreshIso) { + const lastRefresh = new Date(credentials.lastRefreshIso) + const daysSinceRefresh = (Date.now() - lastRefresh.getTime()) / (1000 * 60 * 60 * 24) + needsRefresh = daysSinceRefresh > 28 + } + + return { + isAuthenticated: true, + hasApiKey: !!credentials.apiKey, + lastRefresh: credentials.lastRefreshIso, + needsRefresh, + } + } +} + +/** + * Global instance of the credentials manager + */ +let credentialsManager: ChatGptCredentialsManager | undefined + +/** + * Initialize the credentials manager + */ +export function initializeCredentialsManager(context: vscode.ExtensionContext): ChatGptCredentialsManager { + if (!credentialsManager) { + credentialsManager = new ChatGptCredentialsManager(context) + } + return credentialsManager +} + +/** + * Get the credentials manager instance + */ +export function getCredentialsManager(): ChatGptCredentialsManager { + if (!credentialsManager) { + throw new Error("ChatGptCredentialsManager not initialized. Call initializeCredentialsManager first.") + } + return credentialsManager +} diff --git a/src/core/auth/oauth-helpers.ts b/src/core/auth/oauth-helpers.ts new file mode 100644 index 00000000000..ac7116f415c --- /dev/null +++ b/src/core/auth/oauth-helpers.ts @@ -0,0 +1,162 @@ +import * as crypto from "crypto" +import * as vscode from "vscode" +import { CHATGPT_OAUTH_CONFIG, type OAuthState } from "@roo-code/types" + +/** + * Generate a cryptographically secure random string for OAuth state + */ +export function generateState(): string { + return crypto.randomBytes(32).toString("base64url") +} + +/** + * Generate PKCE code verifier and challenge + */ +export function generatePKCE(): { codeVerifier: string; codeChallenge: string } { + // Generate a random code verifier (43-128 characters) + const codeVerifier = crypto.randomBytes(32).toString("base64url") + + // Generate the code challenge using SHA256 + const codeChallenge = crypto.createHash("sha256").update(codeVerifier).digest("base64url") + + return { codeVerifier, codeChallenge } +} + +/** + * Build the OAuth authorization URL + */ +export function buildAuthorizationUrl( + state: string, + codeChallenge: string, + port: number = CHATGPT_OAUTH_CONFIG.defaultPort, +): string { + const params = new URLSearchParams({ + response_type: "code", + client_id: CHATGPT_OAUTH_CONFIG.clientId, + redirect_uri: `http://localhost:${port}/auth/callback`, + scope: CHATGPT_OAUTH_CONFIG.scopes.join(" "), + state, + code_challenge: codeChallenge, + code_challenge_method: "S256", + id_token_add_organizations: "true", + codex_cli_simplified_flow: "true", // For Codex CLI compatibility + }) + + return `${CHATGPT_OAUTH_CONFIG.authorizationUrl}?${params.toString()}` +} + +/** + * Store OAuth state for CSRF protection + */ +export async function storeOAuthState(context: vscode.ExtensionContext, state: OAuthState): Promise { + await context.globalState.update("roo.openai.oauth.state", state) +} + +/** + * Retrieve and validate OAuth state + */ +export async function validateOAuthState( + context: vscode.ExtensionContext, + receivedState: string, +): Promise { + const storedState = context.globalState.get("roo.openai.oauth.state") + + if (!storedState) { + return null + } + + // Check if state matches + if (storedState.state !== receivedState) { + return null + } + + // Check if state is not expired (5 minutes timeout) + const now = Date.now() + if (now - storedState.timestamp > 5 * 60 * 1000) { + await context.globalState.update("roo.openai.oauth.state", undefined) + return null + } + + // Clear the state after validation + await context.globalState.update("roo.openai.oauth.state", undefined) + + return storedState +} + +/** + * Find an available port for the OAuth callback server + */ +export async function findAvailablePort(preferredPort: number = CHATGPT_OAUTH_CONFIG.defaultPort): Promise { + const net = await import("net") + + return new Promise((resolve) => { + const server = net.createServer() + + server.listen(preferredPort, "127.0.0.1", () => { + const port = (server.address() as any).port + server.close(() => resolve(port)) + }) + + server.on("error", () => { + // If preferred port is busy, let the OS assign a random port + server.listen(0, "127.0.0.1", () => { + const port = (server.address() as any).port + server.close(() => resolve(port)) + }) + }) + }) +} + +/** + * Parse JWT token to extract claims (without verification) + */ +export function parseJWT(token: string): any { + try { + const parts = token.split(".") + if (parts.length !== 3) { + return null + } + + const payload = parts[1] + const decoded = Buffer.from(payload, "base64url").toString("utf-8") + return JSON.parse(decoded) + } catch { + return null + } +} + +/** + * Check if a token is expired or about to expire + */ +export function isTokenExpired(token: string, bufferSeconds: number = 300): boolean { + const claims = parseJWT(token) + if (!claims || !claims.exp) { + return true + } + + const now = Math.floor(Date.now() / 1000) + return claims.exp - bufferSeconds <= now +} + +/** + * Format error message for user display + */ +export function formatOAuthError(error: any): string { + if (typeof error === "string") { + return error + } + + if (error?.error_description) { + return error.error_description + } + + if (error?.error) { + return `OAuth error: ${error.error}` + } + + if (error?.message) { + return error.message + } + + return "An unknown error occurred during authentication" +} diff --git a/src/core/auth/oauth-server.ts b/src/core/auth/oauth-server.ts new file mode 100644 index 00000000000..fda671bd8c4 --- /dev/null +++ b/src/core/auth/oauth-server.ts @@ -0,0 +1,344 @@ +import * as http from "http" +import * as url from "url" +import * as vscode from "vscode" +import axios from "axios" +import { CHATGPT_OAUTH_CONFIG, type OAuthTokens, type OAuthError } from "@roo-code/types" +import { validateOAuthState, formatOAuthError } from "./oauth-helpers" + +/** + * OAuth callback server for handling the authorization code + */ +export class OAuthCallbackServer { + private server: http.Server | null = null + private port: number + private codePromise: Promise + private codeResolve!: (code: string) => void + private codeReject!: (error: Error) => void + + constructor(port: number) { + this.port = port + this.codePromise = new Promise((resolve, reject) => { + this.codeResolve = resolve + this.codeReject = reject + }) + } + + /** + * Start the OAuth callback server + */ + async start(context: vscode.ExtensionContext): Promise { + return new Promise((resolve, reject) => { + this.server = http.createServer(async (req, res) => { + const parsedUrl = url.parse(req.url || "", true) + + if (parsedUrl.pathname === "/auth/callback") { + await this.handleCallback(req, res, context) + } else { + res.writeHead(404) + res.end("Not found") + } + }) + + this.server.listen(this.port, "127.0.0.1", () => { + resolve() + }) + + this.server.on("error", (error) => { + reject(error) + }) + }) + } + + /** + * Handle the OAuth callback + */ + private async handleCallback( + req: http.IncomingMessage, + res: http.ServerResponse, + context: vscode.ExtensionContext, + ): Promise { + const parsedUrl = url.parse(req.url || "", true) + const { code, state, error, error_description } = parsedUrl.query + + // Handle error response + if (error) { + const errorMessage = formatOAuthError({ error, error_description }) + this.sendErrorResponse(res, errorMessage) + this.codeReject(new Error(errorMessage)) + return + } + + // Validate required parameters + if (!code || typeof code !== "string" || !state || typeof state !== "string") { + const errorMessage = "Missing authorization code or state" + this.sendErrorResponse(res, errorMessage) + this.codeReject(new Error(errorMessage)) + return + } + + // Validate state for CSRF protection + const validState = await validateOAuthState(context, state) + if (!validState) { + const errorMessage = "Invalid or expired state parameter" + this.sendErrorResponse(res, errorMessage) + this.codeReject(new Error(errorMessage)) + return + } + + // Send success response to browser + this.sendSuccessResponse(res) + + // Resolve with the authorization code + this.codeResolve(code) + } + + /** + * Send success response to browser + */ + private sendSuccessResponse(res: http.ServerResponse): void { + const html = ` + + + + Authentication Successful + + + +
+
+

Authentication Successful!

+

You have successfully signed in with ChatGPT. You can now close this window and return to VS Code.

+
+ + + + ` + + res.writeHead(200, { "Content-Type": "text/html" }) + res.end(html) + } + + /** + * Send error response to browser + */ + private sendErrorResponse(res: http.ServerResponse, errorMessage: string): void { + const html = ` + + + + Authentication Failed + + + +
+
+

Authentication Failed

+

There was an error during authentication. Please try again.

+
${errorMessage}
+
+ + + ` + + res.writeHead(400, { "Content-Type": "text/html" }) + res.end(html) + } + + /** + * Wait for the authorization code + */ + async waitForCode(): Promise { + return this.codePromise + } + + /** + * Stop the server + */ + stop(): void { + if (this.server) { + this.server.close() + this.server = null + } + } +} + +/** + * Exchange authorization code for tokens + */ +export async function exchangeCodeForTokens( + code: string, + codeVerifier: string, + port: number = CHATGPT_OAUTH_CONFIG.defaultPort, +): Promise { + try { + const response = await axios.post( + CHATGPT_OAUTH_CONFIG.tokenUrl, + new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: `http://localhost:${port}/auth/callback`, + client_id: CHATGPT_OAUTH_CONFIG.clientId, + code_verifier: codeVerifier, + }), + { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }, + ) + + const { access_token, id_token, refresh_token, expires_in, token_type } = response.data + + if (!id_token || !refresh_token) { + throw new Error("Missing required tokens in response") + } + + return { + accessToken: access_token, + idToken: id_token, + refreshToken: refresh_token, + expiresIn: expires_in, + tokenType: token_type, + } + } catch (error: any) { + if (error.response?.data) { + throw new Error(formatOAuthError(error.response.data)) + } + throw error + } +} + +/** + * Refresh tokens using refresh token + */ +export async function refreshTokens(refreshToken: string): Promise { + try { + const response = await axios.post( + CHATGPT_OAUTH_CONFIG.tokenUrl, + new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: CHATGPT_OAUTH_CONFIG.clientId, + }), + { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }, + ) + + const { access_token, id_token, refresh_token, expires_in, token_type } = response.data + + if (!id_token) { + throw new Error("Missing ID token in refresh response") + } + + return { + accessToken: access_token, + idToken: id_token, + refreshToken: refresh_token || refreshToken, // Use new refresh token if provided + expiresIn: expires_in, + tokenType: token_type, + } + } catch (error: any) { + if (error.response?.data) { + throw new Error(formatOAuthError(error.response.data)) + } + throw error + } +} diff --git a/src/core/auth/token-exchange.ts b/src/core/auth/token-exchange.ts new file mode 100644 index 00000000000..a6bf16fa6d3 --- /dev/null +++ b/src/core/auth/token-exchange.ts @@ -0,0 +1,166 @@ +import axios from "axios" +import * as vscode from "vscode" +import { CHATGPT_OAUTH_CONFIG, type TokenExchangeRequest } from "@roo-code/types" +import { parseJWT, formatOAuthError } from "./oauth-helpers" + +/** + * Exchange OAuth ID token for an OpenAI API key + */ +export async function exchangeTokenForApiKey(idToken: string): Promise { + try { + // Parse the ID token to check for organization/project + const claims = parseJWT(idToken) + if (!claims) { + throw new Error("Invalid ID token format") + } + + // Check if user has organization or project access + const hasOrgAccess = claims.organizations && claims.organizations.length > 0 + const hasProjectAccess = claims.projects && claims.projects.length > 0 + const isPersonalAllowed = claims.personal_access === true + + if (!hasOrgAccess && !hasProjectAccess && !isPersonalAllowed) { + // User needs to complete Platform onboarding + const result = await vscode.window.showWarningMessage( + "Your ChatGPT account needs to be set up for API access. Please complete the OpenAI Platform onboarding to continue.", + "Open Platform Setup", + "Cancel", + ) + + if (result === "Open Platform Setup") { + vscode.env.openExternal(vscode.Uri.parse("https://platform.openai.com/onboarding")) + } + + return null + } + + // Perform token exchange + const tokenExchangeRequest: TokenExchangeRequest = { + grant_type: "urn:ietf:params:oauth:grant-type:token-exchange", + requested_token_type: "openai-api-key", + subject_token: idToken, + subject_token_type: "urn:ietf:params:oauth:token-type:id_token", + client_id: CHATGPT_OAUTH_CONFIG.clientId, + } + + const response = await axios.post( + CHATGPT_OAUTH_CONFIG.tokenUrl, + new URLSearchParams(tokenExchangeRequest as any), + { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }, + ) + + const { access_token } = response.data + + if (!access_token) { + throw new Error("No API key returned from token exchange") + } + + return access_token + } catch (error: any) { + // Handle specific error cases + if (error.response?.status === 403) { + await vscode.window.showErrorMessage( + "Your ChatGPT account doesn't have API access. Please ensure you have a Plus or Pro subscription and have completed Platform setup.", + ) + return null + } + + if (error.response?.data) { + const errorMessage = formatOAuthError(error.response.data) + await vscode.window.showErrorMessage(`Token exchange failed: ${errorMessage}`) + return null + } + + throw error + } +} + +/** + * Attempt to redeem complimentary credits for Plus/Pro users + * This is a best-effort operation and failures are non-fatal + */ +export async function redeemComplimentaryCredits(idToken: string): Promise { + try { + // Parse the ID token to check subscription status + const claims = parseJWT(idToken) + if (!claims) { + return + } + + // Check if user has Plus or Pro subscription + const hasPlus = claims.subscription?.includes("plus") + const hasPro = claims.subscription?.includes("pro") + + if (!hasPlus && !hasPro) { + return // Not eligible for complimentary credits + } + + // Attempt to redeem credits + await axios.post( + "https://api.openai.com/v1/billing/redeem_credits", + { + id_token: idToken, + }, + { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${idToken}`, + }, + }, + ) + + // Success - notify user + vscode.window.showInformationMessage("Complimentary API credits have been applied to your account!") + } catch (error) { + // Silently fail - this is a best-effort operation + console.log("Failed to redeem complimentary credits:", error) + } +} + +/** + * Validate that an API key is working + */ +export async function validateApiKey(apiKey: string): Promise { + try { + // Make a simple API call to validate the key + const response = await axios.get("https://api.openai.com/v1/models", { + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }) + + return response.status === 200 + } catch (error: any) { + if (error.response?.status === 401) { + return false // Invalid API key + } + // For other errors, assume the key might be valid but there's a network issue + return true + } +} + +/** + * Get organization and project information from ID token + */ +export function getTokenMetadata(idToken: string): { + organizations?: string[] + projects?: string[] + email?: string + name?: string +} { + const claims = parseJWT(idToken) + if (!claims) { + return {} + } + + return { + organizations: claims.organizations, + projects: claims.projects, + email: claims.email, + name: claims.name, + } +}