Skip to content

Commit 0948135

Browse files
committed
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
1 parent 2730ef9 commit 0948135

File tree

9 files changed

+1327
-3
lines changed

9 files changed

+1327
-3
lines changed

packages/types/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export * from "./mcp.js"
1212
export * from "./message.js"
1313
export * from "./mode.js"
1414
export * from "./model.js"
15+
export * from "./oauth.js"
1516
export * from "./provider-settings.js"
1617
export * from "./sharing.js"
1718
export * from "./task.js"

packages/types/src/oauth.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { z } from "zod"
2+
3+
/**
4+
* OAuth configuration for ChatGPT authentication
5+
*/
6+
export const CHATGPT_OAUTH_CONFIG = {
7+
clientId: "app_EMoamEEZ73f0CkXaXp7hrann", // Codex CLI client ID for compatibility
8+
authorizationUrl: "https://auth.openai.com/oauth/authorize",
9+
tokenUrl: "https://auth.openai.com/oauth/token",
10+
redirectUri: "http://localhost:1455/auth/callback",
11+
defaultPort: 1455,
12+
scopes: ["openid", "profile", "email", "offline_access"],
13+
} as const
14+
15+
/**
16+
* OAuth tokens structure
17+
*/
18+
export const oauthTokensSchema = z.object({
19+
accessToken: z.string(),
20+
idToken: z.string(),
21+
refreshToken: z.string(),
22+
expiresIn: z.number().optional(),
23+
tokenType: z.string().optional(),
24+
})
25+
26+
export type OAuthTokens = z.infer<typeof oauthTokensSchema>
27+
28+
/**
29+
* ChatGPT credentials stored in SecretStorage
30+
*/
31+
export const chatGptCredentialsSchema = z.object({
32+
apiKey: z.string().optional(), // Exchanged API key
33+
idToken: z.string(),
34+
refreshToken: z.string(),
35+
lastRefreshIso: z.string().optional(),
36+
responseId: z.string().optional(), // For conversation continuity
37+
})
38+
39+
export type ChatGptCredentials = z.infer<typeof chatGptCredentialsSchema>
40+
41+
/**
42+
* Codex CLI auth.json structure for import
43+
*/
44+
export const codexAuthJsonSchema = z.object({
45+
OPENAI_API_KEY: z.string().optional(),
46+
tokens: z
47+
.object({
48+
id_token: z.string(),
49+
access_token: z.string().optional(),
50+
refresh_token: z.string().optional(),
51+
})
52+
.optional(),
53+
last_refresh: z.string().optional(),
54+
})
55+
56+
export type CodexAuthJson = z.infer<typeof codexAuthJsonSchema>
57+
58+
/**
59+
* OAuth state for CSRF protection
60+
*/
61+
export interface OAuthState {
62+
state: string
63+
codeVerifier: string
64+
timestamp: number
65+
}
66+
67+
/**
68+
* Token exchange request for getting API key from OAuth tokens
69+
*/
70+
export interface TokenExchangeRequest {
71+
grant_type: "urn:ietf:params:oauth:grant-type:token-exchange"
72+
requested_token_type: "openai-api-key"
73+
subject_token: string // ID token
74+
subject_token_type: "urn:ietf:params:oauth:token-type:id_token"
75+
client_id: string
76+
}
77+
78+
/**
79+
* OAuth error response
80+
*/
81+
export const oauthErrorSchema = z.object({
82+
error: z.string(),
83+
error_description: z.string().optional(),
84+
})
85+
86+
export type OAuthError = z.infer<typeof oauthErrorSchema>

packages/types/src/provider-settings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ const openAiSchema = baseProviderSettingsSchema.extend({
155155
openAiStreamingEnabled: z.boolean().optional(),
156156
openAiHostHeader: z.string().optional(), // Keep temporarily for backward compatibility during migration.
157157
openAiHeaders: z.record(z.string(), z.string()).optional(),
158+
openAiAuthMode: z.enum(["apiKey", "chatgpt"]).optional(), // New: Authentication mode
158159
})
159160

160161
const ollamaSchema = baseProviderSettingsSchema.extend({

src/api/providers/openai.ts

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,17 +29,47 @@ import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from ".
2929
// compatible with the OpenAI API. We can also rename it to `OpenAIHandler`.
3030
export class OpenAiHandler extends BaseProvider implements SingleCompletionHandler {
3131
protected options: ApiHandlerOptions
32-
private client: OpenAI
32+
private client!: OpenAI // Using definite assignment assertion since we initialize it
33+
private apiKeyPromise?: Promise<string>
3334

3435
constructor(options: ApiHandlerOptions) {
3536
super()
3637
this.options = options
3738

39+
// Initialize the client asynchronously if using ChatGPT auth
40+
if (this.options.openAiAuthMode === "chatgpt") {
41+
this.apiKeyPromise = this.getApiKeyFromChatGpt()
42+
this.apiKeyPromise
43+
.then((apiKey) => {
44+
this.initializeClient(apiKey)
45+
})
46+
.catch((error) => {
47+
console.error("Failed to get API key from ChatGPT:", error)
48+
})
49+
} else {
50+
// Initialize immediately for regular API key mode
51+
this.initializeClient(this.options.openAiApiKey ?? "not-provided")
52+
}
53+
}
54+
55+
private async getApiKeyFromChatGpt(): Promise<string> {
56+
// Lazy import to avoid circular dependencies
57+
const { getCredentialsManager } = await import("../../core/auth/chatgpt-credentials-manager")
58+
const credentialsManager = getCredentialsManager()
59+
const apiKey = await credentialsManager.getApiKey()
60+
61+
if (!apiKey) {
62+
throw new Error("No API key found for ChatGPT authentication. Please sign in with your ChatGPT account.")
63+
}
64+
65+
return apiKey
66+
}
67+
68+
private initializeClient(apiKey: string): void {
3869
const baseURL = this.options.openAiBaseUrl ?? "https://api.openai.com/v1"
39-
const apiKey = this.options.openAiApiKey ?? "not-provided"
4070
const isAzureAiInference = this._isAzureAiInference(this.options.openAiBaseUrl)
4171
const urlHost = this._getUrlHost(this.options.openAiBaseUrl)
42-
const isAzureOpenAi = urlHost === "azure.com" || urlHost.endsWith(".azure.com") || options.openAiUseAzure
72+
const isAzureOpenAi = urlHost === "azure.com" || urlHost.endsWith(".azure.com") || this.options.openAiUseAzure
4373

4474
const headers = {
4575
...DEFAULT_HEADERS,
@@ -77,6 +107,14 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
77107
messages: Anthropic.Messages.MessageParam[],
78108
metadata?: ApiHandlerCreateMessageMetadata,
79109
): ApiStream {
110+
// Ensure client is initialized for ChatGPT auth mode
111+
if (this.apiKeyPromise) {
112+
await this.apiKeyPromise
113+
}
114+
115+
if (!this.client) {
116+
throw new Error("OpenAI client not initialized")
117+
}
80118
const { info: modelInfo, reasoning } = this.getModel()
81119
const modelUrl = this.options.openAiBaseUrl ?? ""
82120
const modelId = this.options.openAiModelId ?? ""
@@ -256,6 +294,14 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
256294

257295
async completePrompt(prompt: string): Promise<string> {
258296
try {
297+
// Ensure client is initialized for ChatGPT auth mode
298+
if (this.apiKeyPromise) {
299+
await this.apiKeyPromise
300+
}
301+
302+
if (!this.client) {
303+
throw new Error("OpenAI client not initialized")
304+
}
259305
const isAzureAiInference = this._isAzureAiInference(this.options.openAiBaseUrl)
260306
const model = this.getModel()
261307
const modelInfo = model.info

0 commit comments

Comments
 (0)