diff --git a/index.ts b/index.ts index 20ce21b..aeefb96 100644 --- a/index.ts +++ b/index.ts @@ -30,6 +30,9 @@ import { exchangeAuthorizationCode, parseAuthorizationInput, REDIRECT_URI, + isOAuthAuth, + accessTokenExpired, + refreshAccessToken, } from "./lib/auth/auth.js"; import { openBrowserUrl } from "./lib/auth/browser.js"; import { startLocalOAuthServer } from "./lib/auth/server.js"; @@ -41,10 +44,10 @@ import { ERROR_MESSAGES, JWT_CLAIM_PATH, LOG_STAGES, - OPENAI_HEADER_VALUES, - OPENAI_HEADERS, PLUGIN_NAME, PROVIDER_ID, + MAX_ACCOUNTS, + HTTP_STATUS, } from "./lib/constants.js"; import { logRequest, logDebug } from "./lib/logger.js"; import { @@ -52,28 +55,98 @@ import { extractRequestUrl, handleErrorResponse, handleSuccessResponse, - refreshAndUpdateToken, rewriteUrlForCodex, - shouldRefreshToken, transformRequestForCodex, } from "./lib/request/fetch-helpers.js"; -import type { UserConfig } from "./lib/types.js"; +import type { UserConfig, OAuthAuthDetails, TokenSuccess } from "./lib/types.js"; +import { + AccountManager, + formatMultiAccountRefresh, +} from "./lib/accounts/manager.js"; +import { loadAccounts, saveAccounts } from "./lib/accounts/storage.js"; +import { promptAddAnotherAccount } from "./lib/accounts/cli.js"; + +interface AuthenticatedAccount { + refreshToken: string; + accessToken: string; + expiresAt: number; + chatgptAccountId: string; +} + +async function authenticateSingleAccount(): Promise { + const { pkce, state, url } = await createAuthorizationFlow(); + const serverInfo = await startLocalOAuthServer({ state }); + + openBrowserUrl(url); + + if (!serverInfo.ready) { + serverInfo.close(); + console.log(`\nOpen this URL in your browser: ${url}\n`); + const { createInterface } = await import("node:readline/promises"); + const { stdin, stdout } = await import("node:process"); + const rl = createInterface({ input: stdin, output: stdout }); + + try { + const input = await rl.question("Paste the full redirect URL here: "); + const parsed = parseAuthorizationInput(input); + if (!parsed.code) { + return null; + } + const tokens = await exchangeAuthorizationCode( + parsed.code, + pkce.verifier, + REDIRECT_URI, + ); + if (tokens?.type !== "success") { + return null; + } + const decoded = decodeJWT(tokens.access); + const chatgptAccountId = decoded?.[JWT_CLAIM_PATH]?.chatgpt_account_id; + if (!chatgptAccountId) { + return null; + } + return { + refreshToken: tokens.refresh, + accessToken: tokens.access, + expiresAt: tokens.expires, + chatgptAccountId, + }; + } finally { + rl.close(); + } + } + + const result = await serverInfo.waitForCode(state); + serverInfo.close(); + + if (!result) { + return null; + } + + const tokens = await exchangeAuthorizationCode( + result.code, + pkce.verifier, + REDIRECT_URI, + ); + + if (tokens?.type !== "success") { + return null; + } + + const decoded = decodeJWT(tokens.access); + const chatgptAccountId = decoded?.[JWT_CLAIM_PATH]?.chatgpt_account_id; + if (!chatgptAccountId) { + return null; + } + + return { + refreshToken: tokens.refresh, + accessToken: tokens.access, + expiresAt: tokens.expires, + chatgptAccountId, + }; +} -/** - * OpenAI Codex OAuth authentication plugin for opencode - * - * This plugin enables opencode to use OpenAI's Codex backend via ChatGPT Plus/Pro - * OAuth authentication, allowing users to leverage their ChatGPT subscription - * instead of OpenAI Platform API credits. - * - * @example - * ```json - * { - * "plugin": ["opencode-openai-codex-auth"], - * "model": "openai/gpt-5-codex" - * } - * ``` - */ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => { const buildManualOAuthFlow = (pkce: { verifier: string }, url: string) => ({ url, @@ -92,42 +165,20 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => { return tokens?.type === "success" ? tokens : { type: "failed" as const }; }, }); + return { auth: { provider: PROVIDER_ID, - /** - * Loader function that configures OAuth authentication and request handling - * - * This function: - * 1. Validates OAuth authentication - * 2. Extracts ChatGPT account ID from access token - * 3. Loads user configuration from opencode.json - * 4. Fetches Codex system instructions from GitHub (cached) - * 5. Returns SDK configuration with custom fetch implementation - * - * @param getAuth - Function to retrieve current auth state - * @param provider - Provider configuration from opencode.json - * @returns SDK configuration object or empty object for non-OAuth auth - */ async loader(getAuth: () => Promise, provider: unknown) { const auth = await getAuth(); - // Only handle OAuth auth type, skip API key auth - if (auth.type !== "oauth") { + if (!isOAuthAuth(auth)) { return {}; } - // Extract ChatGPT account ID from JWT access token - const decoded = decodeJWT(auth.access); - const accountId = decoded?.[JWT_CLAIM_PATH]?.chatgpt_account_id; + const storedAccounts = await loadAccounts(); + const accountManager = new AccountManager(auth, storedAccounts); - if (!accountId) { - logDebug( - `[${PLUGIN_NAME}] ${ERROR_MESSAGES.NO_ACCOUNT_ID} (skipping plugin)`, - ); - return {}; - } - // Extract user configuration (global + per-model options) const providerConfig = provider as | { options?: Record; models?: UserConfig["models"] } | undefined; @@ -136,48 +187,52 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => { models: providerConfig?.models || {}, }; - // Load plugin configuration and determine CODEX_MODE - // Priority: CODEX_MODE env var > config file > default (true) const pluginConfig = loadPluginConfig(); const codexMode = getCodexMode(pluginConfig); - // Return SDK configuration return { apiKey: DUMMY_API_KEY, baseURL: CODEX_BASE_URL, - /** - * Custom fetch implementation for Codex API - * - * Handles: - * - Token refresh when expired - * - URL rewriting for Codex backend - * - Request body transformation - * - OAuth header injection - * - SSE to JSON conversion for non-tool requests - * - Error handling and logging - * - * @param input - Request URL or Request object - * @param init - Request options - * @returns Response from Codex API - */ async fetch( input: Request | string | URL, init?: RequestInit, ): Promise { - // Step 1: Check and refresh token if needed - let currentAuth = await getAuth(); - if (shouldRefreshToken(currentAuth)) { - currentAuth = await refreshAndUpdateToken(currentAuth, client); + const account = accountManager.getCurrentOrNext(); + if (!account) { + const waitTime = accountManager.getMinWaitTime(); + if (waitTime > 0) { + logDebug( + `[${PLUGIN_NAME}] All accounts rate limited, waiting ${Math.ceil(waitTime / 1000)}s`, + ); + } + throw new Error("All accounts are rate limited"); + } + + let authDetails = accountManager.accountToAuth(account); + + if (accessTokenExpired(authDetails)) { + const refreshResult = await refreshAccessToken(account.refreshToken); + if (refreshResult.type === "failed") { + throw new Error(ERROR_MESSAGES.TOKEN_REFRESH_FAILED); + } + accountManager.updateAccount( + account, + refreshResult.access, + refreshResult.expires, + refreshResult.refresh, + ); + authDetails = accountManager.accountToAuth(account); + + await client.auth.set({ + path: { id: PROVIDER_ID }, + body: accountManager.toAuthDetails(), + }); + await accountManager.save(); } - // Step 2: Extract and rewrite URL for Codex backend const originalUrl = extractRequestUrl(input); const url = rewriteUrlForCodex(originalUrl); - // Step 3: Transform request body with model-specific Codex instructions - // Instructions are fetched per model family (codex-max, codex, gpt-5.1) - // Capture original stream value before transformation - // generateText() sends no stream field, streamText() sends stream=true const originalBody = init?.body ? JSON.parse(init.body as string) : {}; const isStreaming = originalBody.stream === true; @@ -189,34 +244,51 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => { ); const requestInit = transformation?.updatedInit ?? init; - // Step 4: Create headers with OAuth and ChatGPT account info - const accessToken = - currentAuth.type === "oauth" ? currentAuth.access : ""; const headers = createCodexHeaders( requestInit, - accountId, - accessToken, + account.chatgptAccountId, + authDetails.access, { model: transformation?.body.model, promptCacheKey: (transformation?.body as any)?.prompt_cache_key, }, ); - // Step 5: Make request to Codex API const response = await fetch(url, { ...requestInit, headers, }); - // Step 6: Log response logRequest(LOG_STAGES.RESPONSE, { status: response.status, ok: response.ok, statusText: response.statusText, headers: Object.fromEntries(response.headers.entries()), + accountIndex: account.index, + accountCount: accountManager.getAccountCount(), }); - // Step 7: Handle error or success response + if (response.status === HTTP_STATUS.TOO_MANY_REQUESTS) { + const retryAfter = response.headers.get("retry-after"); + const retryMs = retryAfter + ? parseInt(retryAfter, 10) * 1000 + : 60 * 1000; + accountManager.markRateLimited(account, retryMs); + await accountManager.save(); + + logDebug( + `[${PLUGIN_NAME}] Account ${account.index} rate limited, retry after ${Math.ceil(retryMs / 1000)}s`, + ); + + const nextAccount = accountManager.getNext(); + if (nextAccount && nextAccount.index !== account.index) { + accountManager.markSwitched(nextAccount, "rate-limit"); + logDebug( + `[${PLUGIN_NAME}] Switching to account ${nextAccount.index}`, + ); + } + } + if (!response.ok) { return await handleErrorResponse(response); } @@ -225,71 +297,93 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => { }, }; }, - methods: [ - { - label: AUTH_LABELS.OAUTH, - type: "oauth" as const, - /** - * OAuth authorization flow - * - * Steps: - * 1. Generate PKCE challenge and state for security - * 2. Start local OAuth callback server on port 1455 - * 3. Open browser to OpenAI authorization page - * 4. Wait for user to complete login - * 5. Exchange authorization code for tokens - * - * @returns Authorization flow configuration - */ + methods: [ + { + label: AUTH_LABELS.OAUTH, + type: "oauth" as const, authorize: async () => { - const { pkce, state, url } = await createAuthorizationFlow(); - const serverInfo = await startLocalOAuthServer({ state }); + const accounts: AuthenticatedAccount[] = []; - // Attempt to open browser automatically - openBrowserUrl(url); + const firstAccount = await authenticateSingleAccount(); + if (!firstAccount) { + return { + url: "", + instructions: "Authentication cancelled", + method: "auto" as const, + callback: async () => ({ type: "failed" as const }), + }; + } - if (!serverInfo.ready) { - serverInfo.close(); - return buildManualOAuthFlow(pkce, url); + accounts.push(firstAccount); + console.log(`\nAccount 1 authenticated successfully.`); + + while (accounts.length < MAX_ACCOUNTS) { + const addAnother = await promptAddAnotherAccount(accounts.length); + if (!addAnother) { + break; + } + + const nextAccount = await authenticateSingleAccount(); + if (!nextAccount) { + console.log("Skipping this account..."); + continue; + } + + accounts.push(nextAccount); + console.log(`Account ${accounts.length} authenticated successfully.`); } + const refreshParts = accounts.map((acc) => ({ + index: 0, + refreshToken: acc.refreshToken, + chatgptAccountId: acc.chatgptAccountId, + lastUsed: 0, + })); + const combinedRefresh = formatMultiAccountRefresh(refreshParts); + + try { + await saveAccounts({ + version: 1, + accounts: accounts.map((acc, index) => ({ + refreshToken: acc.refreshToken, + chatgptAccountId: acc.chatgptAccountId, + addedAt: Date.now(), + lastUsed: index === 0 ? Date.now() : 0, + })), + activeIndex: 0, + }); + } catch (error) { + console.error("[openai-codex-plugin] Failed to save account metadata:", error); + } + + const firstAcc = accounts[0]!; return { - url, + url: "", + instructions: accounts.length > 1 + ? `Multi-account setup complete! ${accounts.length} accounts configured.` + : AUTH_LABELS.INSTRUCTIONS, method: "auto" as const, - instructions: AUTH_LABELS.INSTRUCTIONS, - callback: async () => { - const result = await serverInfo.waitForCode(state); - serverInfo.close(); - - if (!result) { - return { type: "failed" as const }; - } - - const tokens = await exchangeAuthorizationCode( - result.code, - pkce.verifier, - REDIRECT_URI, - ); - - return tokens?.type === "success" - ? tokens - : { type: "failed" as const }; - }, + callback: async (): Promise => ({ + type: "success", + refresh: combinedRefresh, + access: firstAcc.accessToken, + expires: firstAcc.expiresAt, + }), }; }, + }, + { + label: AUTH_LABELS.OAUTH_MANUAL, + type: "oauth" as const, + authorize: async () => { + const { pkce, url } = await createAuthorizationFlow(); + return buildManualOAuthFlow(pkce, url); }, - { - label: AUTH_LABELS.OAUTH_MANUAL, - type: "oauth" as const, - authorize: async () => { - const { pkce, url } = await createAuthorizationFlow(); - return buildManualOAuthFlow(pkce, url); - }, - }, - { - label: AUTH_LABELS.API_KEY, - type: "api" as const, - }, + }, + { + label: AUTH_LABELS.API_KEY, + type: "api" as const, + }, ], }, }; diff --git a/lib/accounts/cli.ts b/lib/accounts/cli.ts new file mode 100644 index 0000000..ebafd91 --- /dev/null +++ b/lib/accounts/cli.ts @@ -0,0 +1,14 @@ +import { createInterface } from "node:readline/promises"; +import { stdin, stdout } from "node:process"; + +export async function promptAddAnotherAccount(currentCount: number): Promise { + const rl = createInterface({ input: stdin, output: stdout }); + try { + const answer = await rl.question( + `\nYou have ${currentCount} account(s). Add another? [y/N]: `, + ); + return answer.toLowerCase().startsWith("y"); + } finally { + rl.close(); + } +} diff --git a/lib/accounts/manager.ts b/lib/accounts/manager.ts new file mode 100644 index 0000000..fe95075 --- /dev/null +++ b/lib/accounts/manager.ts @@ -0,0 +1,283 @@ +import type { + ManagedAccount, + AccountStorage, + OAuthAuthDetails, +} from "../types.js"; +import { saveAccounts } from "./storage.js"; +import { logDebug } from "../logger.js"; + +const ACCOUNT_SEPARATOR = "||"; +const FIELD_SEPARATOR = "|"; + +function isRateLimited(account: ManagedAccount): boolean { + return ( + account.rateLimitResetTime !== undefined && + Date.now() < account.rateLimitResetTime + ); +} + +function clearExpiredRateLimit(account: ManagedAccount): void { + if ( + account.rateLimitResetTime !== undefined && + Date.now() >= account.rateLimitResetTime + ) { + account.rateLimitResetTime = undefined; + } +} + +export function parseMultiAccountRefresh(refresh: string): ManagedAccount[] { + if (!refresh) { + return []; + } + + const accountStrings = refresh.split(ACCOUNT_SEPARATOR).filter((s) => s.trim()); + + if (accountStrings.length === 0) { + return []; + } + + return accountStrings.map((str, index) => { + const [refreshToken = "", chatgptAccountId = ""] = str.split(FIELD_SEPARATOR); + return { + index, + refreshToken, + chatgptAccountId, + lastUsed: 0, + }; + }); +} + +export function formatMultiAccountRefresh(accounts: ManagedAccount[]): string { + return accounts + .map((acc) => `${acc.refreshToken}${FIELD_SEPARATOR}${acc.chatgptAccountId}`) + .filter((s) => s.trim()) + .join(ACCOUNT_SEPARATOR); +} + +export class AccountManager { + private accounts: ManagedAccount[] = []; + private currentIndex = 0; + private currentAccountIndex = -1; + + constructor(auth: OAuthAuthDetails, storedAccounts?: AccountStorage | null) { + if (storedAccounts && storedAccounts.accounts.length > 0) { + const activeIndex = + typeof storedAccounts.activeIndex === "number" && + storedAccounts.activeIndex >= 0 && + storedAccounts.activeIndex < storedAccounts.accounts.length + ? storedAccounts.activeIndex + : 0; + + this.currentAccountIndex = activeIndex; + this.currentIndex = activeIndex; + + this.accounts = storedAccounts.accounts.map((acc, index) => ({ + index, + refreshToken: acc.refreshToken, + chatgptAccountId: acc.chatgptAccountId, + accessToken: index === activeIndex ? auth.access : undefined, + expiresAt: index === activeIndex ? auth.expires : undefined, + rateLimitResetTime: acc.rateLimitResetTime, + lastUsed: acc.lastUsed, + email: acc.email, + lastSwitchReason: acc.lastSwitchReason, + })); + + logDebug(`AccountManager initialized from storage with ${this.accounts.length} accounts, active: ${activeIndex}`); + } else { + const parsedAccounts = parseMultiAccountRefresh(auth.refresh); + + this.currentAccountIndex = 0; + this.currentIndex = 0; + + if (parsedAccounts.length > 0) { + this.accounts = parsedAccounts.map((acc, index) => ({ + ...acc, + index, + accessToken: index === 0 ? auth.access : undefined, + expiresAt: index === 0 ? auth.expires : undefined, + })); + logDebug(`AccountManager initialized from refresh string with ${this.accounts.length} accounts`); + } else { + const [refreshToken = "", chatgptAccountId = ""] = auth.refresh.split(FIELD_SEPARATOR); + this.accounts.push({ + index: 0, + refreshToken, + chatgptAccountId, + accessToken: auth.access, + expiresAt: auth.expires, + lastUsed: 0, + }); + logDebug("AccountManager initialized with single account"); + } + } + } + + async save(): Promise { + const storage: AccountStorage = { + version: 1, + accounts: this.accounts.map((acc) => ({ + refreshToken: acc.refreshToken, + chatgptAccountId: acc.chatgptAccountId, + email: acc.email, + addedAt: acc.lastUsed || Date.now(), + lastUsed: acc.lastUsed, + lastSwitchReason: acc.lastSwitchReason, + rateLimitResetTime: acc.rateLimitResetTime, + })), + activeIndex: Math.max(0, this.currentAccountIndex), + }; + + await saveAccounts(storage); + } + + getCurrentAccount(): ManagedAccount | null { + if ( + this.currentAccountIndex >= 0 && + this.currentAccountIndex < this.accounts.length + ) { + return this.accounts[this.currentAccountIndex] ?? null; + } + return null; + } + + markSwitched( + account: ManagedAccount, + reason: "rate-limit" | "initial" | "rotation", + ): void { + account.lastSwitchReason = reason; + this.currentAccountIndex = account.index; + } + + getAccountCount(): number { + return this.accounts.length; + } + + getCurrentOrNext(): ManagedAccount | null { + this.accounts.forEach(clearExpiredRateLimit); + + const current = this.getCurrentAccount(); + if (current && !isRateLimited(current)) { + current.lastUsed = Date.now(); + logDebug(`Using current account ${current.index}/${this.accounts.length}`); + return current; + } + + const next = this.getNext(); + if (next) { + this.currentAccountIndex = next.index; + logDebug(`Rotated to account ${next.index}/${this.accounts.length}`); + } else { + logDebug("No available accounts (all rate limited)"); + } + return next; + } + + getNext(): ManagedAccount | null { + const available = this.accounts.filter((a) => !isRateLimited(a)); + + if (available.length === 0) { + return null; + } + + const account = available[this.currentIndex % available.length]; + if (!account) { + return null; + } + + this.currentIndex++; + account.lastUsed = Date.now(); + return account; + } + + markRateLimited(account: ManagedAccount, retryAfterMs: number): void { + account.rateLimitResetTime = Date.now() + retryAfterMs; + logDebug(`Account ${account.index} rate limited, reset in ${Math.ceil(retryAfterMs / 1000)}s`); + } + + updateAccount( + account: ManagedAccount, + accessToken: string, + expiresAt: number, + refreshToken?: string, + ): void { + account.accessToken = accessToken; + account.expiresAt = expiresAt; + if (refreshToken) { + account.refreshToken = refreshToken; + } + logDebug(`Account ${account.index} tokens refreshed, expires in ${Math.ceil((expiresAt - Date.now()) / 1000)}s`); + } + + toAuthDetails(): OAuthAuthDetails { + const current = this.getCurrentAccount() || this.accounts[0]; + if (!current) { + throw new Error("No accounts available"); + } + + return { + type: "oauth", + refresh: formatMultiAccountRefresh(this.accounts), + access: current.accessToken || "", + expires: current.expiresAt || 0, + }; + } + + addAccount( + refreshToken: string, + chatgptAccountId: string, + accessToken?: string, + expiresAt?: number, + email?: string, + ): void { + this.accounts.push({ + index: this.accounts.length, + refreshToken, + chatgptAccountId, + accessToken, + expiresAt, + lastUsed: 0, + email, + }); + } + + removeAccount(index: number): boolean { + if (index < 0 || index >= this.accounts.length) { + return false; + } + this.accounts.splice(index, 1); + this.accounts.forEach((acc, idx) => (acc.index = idx)); + return true; + } + + getAccounts(): ManagedAccount[] { + return [...this.accounts]; + } + + accountToAuth(account: ManagedAccount): OAuthAuthDetails { + return { + type: "oauth", + refresh: `${account.refreshToken}${FIELD_SEPARATOR}${account.chatgptAccountId}`, + access: account.accessToken ?? "", + expires: account.expiresAt ?? 0, + }; + } + + getMinWaitTime(): number { + const available = this.accounts.filter((a) => { + clearExpiredRateLimit(a); + return !isRateLimited(a); + }); + + if (available.length > 0) { + return 0; + } + + const waitTimes = this.accounts + .map((a) => a.rateLimitResetTime) + .filter((t): t is number => t !== undefined) + .map((t) => Math.max(0, t - Date.now())); + + return waitTimes.length > 0 ? Math.min(...waitTimes) : 0; + } +} diff --git a/lib/accounts/storage.ts b/lib/accounts/storage.ts new file mode 100644 index 0000000..764f0d8 --- /dev/null +++ b/lib/accounts/storage.ts @@ -0,0 +1,130 @@ +/** + * Account storage for multi-account support + * Persists account metadata to ~/.opencode/openai-codex-accounts.json + */ + +import { promises as fs } from "node:fs"; +import { dirname, join } from "node:path"; +import { homedir } from "node:os"; +import type { AccountMetadata, AccountStorage } from "../types.js"; + +/** + * Get the platform-specific data directory for opencode + */ +function getDataDir(): string { + const platform = process.platform; + + if (platform === "win32") { + return join( + process.env.APPDATA || join(homedir(), "AppData", "Roaming"), + "opencode", + ); + } + + const xdgData = + process.env.XDG_DATA_HOME || join(homedir(), ".local", "share"); + return join(xdgData, "opencode"); +} + +/** + * Get the storage path for account metadata + */ +export function getStoragePath(): string { + return join(getDataDir(), "openai-codex-accounts.json"); +} + +/** + * Load accounts from storage + * @returns Account storage or null if not found/invalid + */ +export async function loadAccounts(): Promise { + try { + const path = getStoragePath(); + const content = await fs.readFile(path, "utf-8"); + const data = JSON.parse(content) as AccountStorage; + + if (!Array.isArray(data.accounts)) { + console.warn("[openai-codex-plugin] Invalid storage format, ignoring"); + return null; + } + + if (data.version !== 1) { + console.warn( + "[openai-codex-plugin] Unknown storage version, ignoring", + data.version, + ); + return null; + } + + // Validate activeIndex + if ( + typeof data.activeIndex !== "number" || + !Number.isInteger(data.activeIndex) + ) { + data.activeIndex = 0; + } + + if (data.activeIndex < 0 || data.activeIndex >= data.accounts.length) { + data.activeIndex = 0; + } + + return data; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return null; + } + console.error( + "[openai-codex-plugin] Failed to load account storage:", + (error as Error).message, + ); + return null; + } +} + +/** + * Save accounts to storage + * @param storage - Account storage to save + */ +export async function saveAccounts(storage: AccountStorage): Promise { + try { + const path = getStoragePath(); + + await fs.mkdir(dirname(path), { recursive: true }); + + const content = JSON.stringify(storage, null, 2); + await fs.writeFile(path, content, "utf-8"); + } catch (error) { + console.error( + "[openai-codex-plugin] Failed to save account storage:", + (error as Error).message, + ); + throw error; + } +} + +/** + * Create account storage from account data + */ +export function createAccountStorage( + accounts: Array<{ + refreshToken: string; + accessToken?: string; + expiresAt?: number; + chatgptAccountId: string; + email?: string; + }>, +): AccountStorage { + const now = Date.now(); + + return { + version: 1, + accounts: accounts.map((acc, index) => ({ + refreshToken: acc.refreshToken, + chatgptAccountId: acc.chatgptAccountId, + email: acc.email, + addedAt: now, + lastUsed: index === 0 ? now : 0, + })), + activeIndex: 0, + }; +} diff --git a/lib/auth/auth.ts b/lib/auth/auth.ts index 4bb9ac7..039c81c 100644 --- a/lib/auth/auth.ts +++ b/lib/auth/auth.ts @@ -1,6 +1,27 @@ import { generatePKCE } from "@openauthjs/openauth/pkce"; import { randomBytes } from "node:crypto"; -import type { PKCEPair, AuthorizationFlow, TokenResult, ParsedAuthInput, JWTPayload } from "../types.js"; +import type { + PKCEPair, + AuthorizationFlow, + TokenResult, + ParsedAuthInput, + JWTPayload, + OAuthAuthDetails, + Auth, +} from "../types.js"; + +const ACCESS_TOKEN_EXPIRY_BUFFER_MS = 60 * 1000; + +export function isOAuthAuth(auth: Auth): auth is OAuthAuthDetails { + return auth.type === "oauth"; +} + +export function accessTokenExpired(auth: OAuthAuthDetails): boolean { + if (!auth.access || typeof auth.expires !== "number") { + return true; + } + return auth.expires <= Date.now() + ACCESS_TOKEN_EXPIRY_BUFFER_MS; +} // OAuth constants (from openai/codex) export const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; diff --git a/lib/constants.ts b/lib/constants.ts index 0df6dfc..4a3a4a1 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -79,3 +79,6 @@ export const AUTH_LABELS = { INSTRUCTIONS_MANUAL: "After logging in, copy the full redirect URL and paste it here.", } as const; + +/** Multi-account configuration */ +export const MAX_ACCOUNTS = 10; diff --git a/lib/types.ts b/lib/types.ts index 40e7bc4..add779c 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -174,5 +174,75 @@ export interface GitHubRelease { [key: string]: unknown; } +// ============================================================================ +// Multi-Account Support Types +// ============================================================================ + +/** + * Rate limit state tracking reset times per account + */ +export interface RateLimitState { + resetTime?: number; +} + +/** + * Stored account metadata + */ +export interface AccountMetadata { + refreshToken: string; + chatgptAccountId: string; + email?: string; + addedAt: number; + lastUsed: number; + lastSwitchReason?: "rate-limit" | "initial" | "rotation"; + rateLimitResetTime?: number; +} + +/** + * Account storage structure (persisted to disk) + */ +export interface AccountStorage { + version: 1; + accounts: AccountMetadata[]; + activeIndex: number; +} + +/** + * Managed account in memory with runtime state + */ +export interface ManagedAccount { + index: number; + refreshToken: string; + chatgptAccountId: string; + accessToken?: string; + expiresAt?: number; + rateLimitResetTime?: number; + lastUsed: number; + email?: string; + lastSwitchReason?: "rate-limit" | "initial" | "rotation"; +} + +/** + * Multi-account refresh token format + * Multiple accounts separated by || + * Each account: refreshToken|chatgptAccountId + */ +export interface MultiAccountRefresh { + accounts: Array<{ + refreshToken: string; + chatgptAccountId: string; + }>; +} + +/** + * OAuth auth details with type guard + */ +export interface OAuthAuthDetails { + type: "oauth"; + access: string; + refresh: string; + expires: number; +} + // Re-export SDK types for convenience export type { Auth, Provider, Model }; diff --git a/package-lock.json b/package-lock.json index d07d6b3..6d22885 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,9 @@ "@openauthjs/openauth": "^0.4.3", "hono": "^4.10.4" }, + "bin": { + "opencode-openai-codex-auth": "scripts/install-opencode-codex-auth.js" + }, "devDependencies": { "@opencode-ai/plugin": "^1.0.150", "@opencode-ai/sdk": "^1.0.150", diff --git a/package.json b/package.json index a4ba2fb..807f1ef 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,8 @@ "test:coverage": "vitest run --coverage" }, "bin": { - "opencode-openai-codex-auth": "./scripts/install-opencode-codex-auth.js" + "opencode-openai-codex-auth": "./scripts/install-opencode-codex-auth.js", + "codex-accounts": "./scripts/manage-accounts.js" }, "files": [ "dist/", diff --git a/scripts/manage-accounts.js b/scripts/manage-accounts.js new file mode 100644 index 0000000..63018ab --- /dev/null +++ b/scripts/manage-accounts.js @@ -0,0 +1,227 @@ +#!/usr/bin/env node + +import { createInterface } from "node:readline/promises"; +import { stdin, stdout } from "node:process"; + +const args = process.argv.slice(2); +const command = args[0]; + +async function showHelp() { + console.log(` +opencode-openai-codex-auth - Multi-account management + +Commands: + add Add a new account to existing accounts + list List all configured accounts + remove Remove an account by index + help Show this help message + +Examples: + npx opencode-openai-codex-auth add + npx opencode-openai-codex-auth list + npx opencode-openai-codex-auth remove 1 +`); +} + +async function loadModules() { + const { loadAccounts, saveAccounts, getStoragePath } = await import("../dist/lib/accounts/storage.js"); + const { + createAuthorizationFlow, + exchangeAuthorizationCode, + decodeJWT, + REDIRECT_URI, + } = await import("../dist/lib/auth/auth.js"); + const { startLocalOAuthServer } = await import("../dist/lib/auth/server.js"); + const { openBrowserUrl } = await import("../dist/lib/auth/browser.js"); + const { JWT_CLAIM_PATH } = await import("../dist/lib/constants.js"); + + return { + loadAccounts, + saveAccounts, + getStoragePath, + createAuthorizationFlow, + exchangeAuthorizationCode, + decodeJWT, + REDIRECT_URI, + startLocalOAuthServer, + openBrowserUrl, + JWT_CLAIM_PATH, + }; +} + +async function listAccounts() { + const { loadAccounts, getStoragePath } = await loadModules(); + const storage = await loadAccounts(); + + if (!storage || storage.accounts.length === 0) { + console.log("No accounts configured."); + console.log(`Storage path: ${getStoragePath()}`); + return; + } + + console.log(`\nConfigured accounts (${storage.accounts.length}):\n`); + storage.accounts.forEach((acc, index) => { + const active = index === storage.activeIndex ? " (active)" : ""; + const email = acc.email ? ` - ${acc.email}` : ""; + const rateLimited = acc.rateLimitResetTime && acc.rateLimitResetTime > Date.now() + ? ` [rate limited until ${new Date(acc.rateLimitResetTime).toLocaleTimeString()}]` + : ""; + console.log(` ${index}: ${acc.chatgptAccountId.slice(0, 8)}...${email}${active}${rateLimited}`); + }); + console.log(`\nStorage: ${getStoragePath()}`); +} + +async function addAccount() { + const { + loadAccounts, + saveAccounts, + createAuthorizationFlow, + exchangeAuthorizationCode, + decodeJWT, + REDIRECT_URI, + startLocalOAuthServer, + openBrowserUrl, + JWT_CLAIM_PATH, + } = await loadModules(); + + const storage = await loadAccounts() || { + version: 1, + accounts: [], + activeIndex: 0, + }; + + console.log(`\nCurrently have ${storage.accounts.length} account(s). Adding new account...\n`); + + const { pkce, state, url } = await createAuthorizationFlow(); + const serverInfo = await startLocalOAuthServer({ state }); + + openBrowserUrl(url); + + let tokens; + if (!serverInfo.ready) { + serverInfo.close(); + console.log(`Open this URL in your browser:\n${url}\n`); + const rl = createInterface({ input: stdin, output: stdout }); + try { + const input = await rl.question("Paste the full redirect URL here: "); + const urlObj = new URL(input); + const code = urlObj.searchParams.get("code"); + if (!code) { + console.error("No authorization code found in URL"); + process.exit(1); + } + tokens = await exchangeAuthorizationCode(code, pkce.verifier, REDIRECT_URI); + } finally { + rl.close(); + } + } else { + console.log("Waiting for browser authentication..."); + const result = await serverInfo.waitForCode(state); + serverInfo.close(); + + if (!result) { + console.error("Authentication failed or timed out"); + process.exit(1); + } + + tokens = await exchangeAuthorizationCode(result.code, pkce.verifier, REDIRECT_URI); + } + + if (tokens?.type !== "success") { + console.error("Token exchange failed"); + process.exit(1); + } + + const decoded = decodeJWT(tokens.access); + const chatgptAccountId = decoded?.[JWT_CLAIM_PATH]?.chatgpt_account_id; + + if (!chatgptAccountId) { + console.error("Could not extract ChatGPT account ID from token"); + process.exit(1); + } + + const existing = storage.accounts.find(a => a.chatgptAccountId === chatgptAccountId); + if (existing) { + console.log(`\nAccount ${chatgptAccountId.slice(0, 8)}... already exists. Updating tokens.`); + existing.refreshToken = tokens.refresh; + existing.lastUsed = Date.now(); + } else { + storage.accounts.push({ + refreshToken: tokens.refresh, + chatgptAccountId, + addedAt: Date.now(), + lastUsed: 0, + }); + console.log(`\nAdded account ${chatgptAccountId.slice(0, 8)}...`); + } + + await saveAccounts(storage); + console.log(`Total accounts: ${storage.accounts.length}`); + console.log("\nNote: You may need to re-run 'opencode auth login' to update the combined refresh token."); +} + +async function removeAccount() { + const indexArg = args[1]; + if (indexArg === undefined) { + console.error("Usage: opencode-openai-codex-auth remove "); + console.error("Run 'opencode-openai-codex-auth list' to see account indices."); + process.exit(1); + } + + const index = parseInt(indexArg, 10); + if (isNaN(index)) { + console.error(`Invalid index: ${indexArg}`); + process.exit(1); + } + + const { loadAccounts, saveAccounts } = await loadModules(); + const storage = await loadAccounts(); + + if (!storage || storage.accounts.length === 0) { + console.error("No accounts configured."); + process.exit(1); + } + + if (index < 0 || index >= storage.accounts.length) { + console.error(`Index ${index} out of range. Valid: 0-${storage.accounts.length - 1}`); + process.exit(1); + } + + const removed = storage.accounts.splice(index, 1)[0]; + if (storage.activeIndex >= storage.accounts.length) { + storage.activeIndex = Math.max(0, storage.accounts.length - 1); + } + + await saveAccounts(storage); + console.log(`Removed account ${removed.chatgptAccountId.slice(0, 8)}...`); + console.log(`Remaining accounts: ${storage.accounts.length}`); +} + +async function main() { + switch (command) { + case "add": + await addAccount(); + break; + case "list": + await listAccounts(); + break; + case "remove": + await removeAccount(); + break; + case "help": + case "--help": + case "-h": + case undefined: + await showHelp(); + break; + default: + console.error(`Unknown command: ${command}`); + await showHelp(); + process.exit(1); + } +} + +main().catch((error) => { + console.error(`Error: ${error.message}`); + process.exit(1); +}); diff --git a/test/accounts.test.ts b/test/accounts.test.ts new file mode 100644 index 0000000..d53088d --- /dev/null +++ b/test/accounts.test.ts @@ -0,0 +1,245 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + AccountManager, + parseMultiAccountRefresh, + formatMultiAccountRefresh, +} from "../lib/accounts/manager.js"; +import type { OAuthAuthDetails, AccountStorage } from "../lib/types.js"; + +describe("AccountManager", () => { + const createAuth = (refresh: string): OAuthAuthDetails => ({ + type: "oauth", + refresh, + access: "access_token_123", + expires: Date.now() + 3600000, + }); + + describe("parseMultiAccountRefresh", () => { + it("parses single account", () => { + const result = parseMultiAccountRefresh("refresh1|account1"); + expect(result).toHaveLength(1); + expect(result[0].refreshToken).toBe("refresh1"); + expect(result[0].chatgptAccountId).toBe("account1"); + }); + + it("parses multiple accounts", () => { + const result = parseMultiAccountRefresh( + "refresh1|account1||refresh2|account2||refresh3|account3", + ); + expect(result).toHaveLength(3); + expect(result[0].refreshToken).toBe("refresh1"); + expect(result[1].refreshToken).toBe("refresh2"); + expect(result[2].refreshToken).toBe("refresh3"); + }); + + it("handles empty string", () => { + const result = parseMultiAccountRefresh(""); + expect(result).toHaveLength(0); + }); + + it("handles malformed input", () => { + const result = parseMultiAccountRefresh("justrefresh"); + expect(result).toHaveLength(1); + expect(result[0].refreshToken).toBe("justrefresh"); + expect(result[0].chatgptAccountId).toBe(""); + }); + }); + + describe("formatMultiAccountRefresh", () => { + it("formats single account", () => { + const accounts = [ + { index: 0, refreshToken: "refresh1", chatgptAccountId: "account1", lastUsed: 0 }, + ]; + const result = formatMultiAccountRefresh(accounts); + expect(result).toBe("refresh1|account1"); + }); + + it("formats multiple accounts", () => { + const accounts = [ + { index: 0, refreshToken: "refresh1", chatgptAccountId: "account1", lastUsed: 0 }, + { index: 1, refreshToken: "refresh2", chatgptAccountId: "account2", lastUsed: 0 }, + ]; + const result = formatMultiAccountRefresh(accounts); + expect(result).toBe("refresh1|account1||refresh2|account2"); + }); + }); + + describe("constructor", () => { + it("initializes from multi-account refresh string", () => { + const auth = createAuth("refresh1|account1||refresh2|account2"); + const manager = new AccountManager(auth, null); + + expect(manager.getAccountCount()).toBe(2); + const accounts = manager.getAccounts(); + expect(accounts[0].refreshToken).toBe("refresh1"); + expect(accounts[1].refreshToken).toBe("refresh2"); + }); + + it("initializes from stored accounts", () => { + const auth = createAuth("refresh1|account1"); + const stored: AccountStorage = { + version: 1, + accounts: [ + { + refreshToken: "stored_refresh1", + chatgptAccountId: "stored_account1", + addedAt: Date.now(), + lastUsed: Date.now(), + }, + { + refreshToken: "stored_refresh2", + chatgptAccountId: "stored_account2", + addedAt: Date.now(), + lastUsed: 0, + }, + ], + activeIndex: 0, + }; + const manager = new AccountManager(auth, stored); + + expect(manager.getAccountCount()).toBe(2); + const accounts = manager.getAccounts(); + expect(accounts[0].refreshToken).toBe("stored_refresh1"); + expect(accounts[1].refreshToken).toBe("stored_refresh2"); + }); + + it("initializes single account from simple refresh", () => { + const auth = createAuth("simple_refresh|simple_account"); + const manager = new AccountManager(auth, null); + + expect(manager.getAccountCount()).toBe(1); + expect(manager.getAccounts()[0].refreshToken).toBe("simple_refresh"); + }); + }); + + describe("getCurrentOrNext", () => { + it("returns current account if not rate limited", () => { + const auth = createAuth("refresh1|account1||refresh2|account2"); + const manager = new AccountManager(auth, null); + + const account = manager.getCurrentOrNext(); + expect(account).not.toBeNull(); + expect(account!.index).toBe(0); + }); + + it("skips rate limited account", () => { + const auth = createAuth("refresh1|account1||refresh2|account2"); + const manager = new AccountManager(auth, null); + + const first = manager.getCurrentAccount()!; + manager.markRateLimited(first, 60000); + + const next = manager.getCurrentOrNext(); + expect(next).not.toBeNull(); + expect(next!.index).toBe(1); + }); + + it("returns null when all accounts are rate limited", () => { + const auth = createAuth("refresh1|account1||refresh2|account2"); + const manager = new AccountManager(auth, null); + + const accounts = manager.getAccounts(); + accounts.forEach((acc) => manager.markRateLimited(acc, 60000)); + + const next = manager.getCurrentOrNext(); + expect(next).toBeNull(); + }); + }); + + describe("markRateLimited", () => { + it("sets rate limit reset time", () => { + const auth = createAuth("refresh1|account1"); + const manager = new AccountManager(auth, null); + + const account = manager.getCurrentAccount()!; + const before = Date.now(); + manager.markRateLimited(account, 30000); + + expect(account.rateLimitResetTime).toBeGreaterThanOrEqual(before + 30000); + }); + }); + + describe("getMinWaitTime", () => { + it("returns 0 when account available", () => { + const auth = createAuth("refresh1|account1||refresh2|account2"); + const manager = new AccountManager(auth, null); + + expect(manager.getMinWaitTime()).toBe(0); + }); + + it("returns minimum wait time when all rate limited", () => { + const auth = createAuth("refresh1|account1||refresh2|account2"); + const manager = new AccountManager(auth, null); + + const accounts = manager.getAccounts(); + manager.markRateLimited(accounts[0], 60000); + manager.markRateLimited(accounts[1], 30000); + + const waitTime = manager.getMinWaitTime(); + expect(waitTime).toBeGreaterThan(0); + expect(waitTime).toBeLessThanOrEqual(30000); + }); + }); + + describe("addAccount", () => { + it("adds new account", () => { + const auth = createAuth("refresh1|account1"); + const manager = new AccountManager(auth, null); + + expect(manager.getAccountCount()).toBe(1); + + manager.addAccount("refresh2", "account2", "access2", Date.now() + 3600000); + + expect(manager.getAccountCount()).toBe(2); + const accounts = manager.getAccounts(); + expect(accounts[1].refreshToken).toBe("refresh2"); + }); + }); + + describe("removeAccount", () => { + it("removes account by index", () => { + const auth = createAuth("refresh1|account1||refresh2|account2"); + const manager = new AccountManager(auth, null); + + expect(manager.getAccountCount()).toBe(2); + + const result = manager.removeAccount(0); + expect(result).toBe(true); + expect(manager.getAccountCount()).toBe(1); + expect(manager.getAccounts()[0].refreshToken).toBe("refresh2"); + }); + + it("returns false for invalid index", () => { + const auth = createAuth("refresh1|account1"); + const manager = new AccountManager(auth, null); + + expect(manager.removeAccount(-1)).toBe(false); + expect(manager.removeAccount(5)).toBe(false); + }); + }); + + describe("toAuthDetails", () => { + it("returns combined auth details", () => { + const auth = createAuth("refresh1|account1||refresh2|account2"); + const manager = new AccountManager(auth, null); + + const details = manager.toAuthDetails(); + expect(details.type).toBe("oauth"); + expect(details.refresh).toContain("refresh1"); + expect(details.refresh).toContain("refresh2"); + }); + }); + + describe("accountToAuth", () => { + it("converts single account to auth details", () => { + const auth = createAuth("refresh1|account1"); + const manager = new AccountManager(auth, null); + + const account = manager.getCurrentAccount()!; + const details = manager.accountToAuth(account); + + expect(details.type).toBe("oauth"); + expect(details.refresh).toBe("refresh1|account1"); + }); + }); +});