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
This commit is contained in:
Roo Code 2025-08-12 14:14:15 +00:00
parent 2730ef96b8
commit 0948135ce0
9 changed files with 1327 additions and 3 deletions

View file

@ -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"

View file

@ -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<typeof oauthTokensSchema>
/**
* 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<typeof chatGptCredentialsSchema>
/**
* 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<typeof codexAuthJsonSchema>
/**
* 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<typeof oauthErrorSchema>

View file

@ -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({

View file

@ -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<string>
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<string> {
// 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<string> {
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

View file

@ -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<boolean> {
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<never>((_, 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<void> {
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<boolean> {
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<boolean> {
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<boolean> {
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
}

View file

@ -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<void> {
const promises: Thenable<void>[] = []
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<ChatGptCredentials | null> {
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<string | undefined> {
return this.context.secrets.get(ChatGptCredentialsManager.API_KEY_KEY)
}
/**
* Update just the API key
*/
async updateApiKey(apiKey: string): Promise<void> {
await this.context.secrets.store(ChatGptCredentialsManager.API_KEY_KEY, apiKey)
}
/**
* Update the response ID for conversation continuity
*/
async updateResponseId(responseId: string): Promise<void> {
await this.context.secrets.store(ChatGptCredentialsManager.RESPONSE_ID_KEY, responseId)
}
/**
* Update tokens after refresh
*/
async updateTokens(idToken: string, refreshToken: string, apiKey?: string): Promise<void> {
const promises: Thenable<void>[] = [
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<void> {
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<boolean> {
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
}

View file

@ -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<void> {
await context.globalState.update("roo.openai.oauth.state", state)
}
/**
* Retrieve and validate OAuth state
*/
export async function validateOAuthState(
context: vscode.ExtensionContext,
receivedState: string,
): Promise<OAuthState | null> {
const storedState = context.globalState.get<OAuthState>("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<number> {
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"
}

View file

@ -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<string>
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<void> {
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<void> {
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 = `
<!DOCTYPE html>
<html>
<head>
<title>Authentication Successful</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.container {
background: white;
padding: 40px;
border-radius: 10px;
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
text-align: center;
max-width: 400px;
}
h1 {
color: #2d3748;
margin-bottom: 10px;
}
p {
color: #718096;
margin-bottom: 20px;
}
.checkmark {
width: 60px;
height: 60px;
margin: 0 auto 20px;
background: #48bb78;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
}
.checkmark::after {
content: "✓";
color: white;
font-size: 30px;
font-weight: bold;
}
</style>
</head>
<body>
<div class="container">
<div class="checkmark"></div>
<h1>Authentication Successful!</h1>
<p>You have successfully signed in with ChatGPT. You can now close this window and return to VS Code.</p>
</div>
<script>
// Auto-close after 3 seconds
setTimeout(() => window.close(), 3000);
</script>
</body>
</html>
`
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 = `
<!DOCTYPE html>
<html>
<head>
<title>Authentication Failed</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: linear-gradient(135deg, #f56565 0%, #c53030 100%);
}
.container {
background: white;
padding: 40px;
border-radius: 10px;
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
text-align: center;
max-width: 400px;
}
h1 {
color: #2d3748;
margin-bottom: 10px;
}
p {
color: #718096;
margin-bottom: 20px;
}
.error-icon {
width: 60px;
height: 60px;
margin: 0 auto 20px;
background: #f56565;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
}
.error-icon::after {
content: "✕";
color: white;
font-size: 30px;
font-weight: bold;
}
.error-message {
background: #fff5f5;
border: 1px solid #feb2b2;
border-radius: 5px;
padding: 10px;
color: #c53030;
margin-top: 20px;
}
</style>
</head>
<body>
<div class="container">
<div class="error-icon"></div>
<h1>Authentication Failed</h1>
<p>There was an error during authentication. Please try again.</p>
<div class="error-message">${errorMessage}</div>
</div>
</body>
</html>
`
res.writeHead(400, { "Content-Type": "text/html" })
res.end(html)
}
/**
* Wait for the authorization code
*/
async waitForCode(): Promise<string> {
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<OAuthTokens> {
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<OAuthTokens> {
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
}
}

View file

@ -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<string | null> {
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<void> {
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<boolean> {
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,
}
}