mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
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:
parent
2730ef96b8
commit
0948135ce0
9 changed files with 1327 additions and 3 deletions
|
|
@ -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"
|
||||
|
|
|
|||
86
packages/types/src/oauth.ts
Normal file
86
packages/types/src/oauth.ts
Normal 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>
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
324
src/core/auth/chatgpt-auth-manager.ts
Normal file
324
src/core/auth/chatgpt-auth-manager.ts
Normal 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
|
||||
}
|
||||
194
src/core/auth/chatgpt-credentials-manager.ts
Normal file
194
src/core/auth/chatgpt-credentials-manager.ts
Normal 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
|
||||
}
|
||||
162
src/core/auth/oauth-helpers.ts
Normal file
162
src/core/auth/oauth-helpers.ts
Normal 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"
|
||||
}
|
||||
344
src/core/auth/oauth-server.ts
Normal file
344
src/core/auth/oauth-server.ts
Normal 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
|
||||
}
|
||||
}
|
||||
166
src/core/auth/token-exchange.ts
Normal file
166
src/core/auth/token-exchange.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue