# OpenAI OAuth Implementation Plan ## Executive Summary Add ChatGPT Plus/Pro OAuth authentication to Flynn, enabling use of OpenAI models (including Codex) via OAuth tokens instead of API keys. This mimics OpenCode's `CodexAuthPlugin` pattern. **Minimal Viable Approach**: Implement OAuth device flow + token refresh + Codex responses endpoint routing. --- ## Design Overview ### Key Components 1. **OAuth Module** (`src/auth/openai.ts`) - Device flow + token management 2. **OpenAI Client Enhancement** (`src/models/openai.ts`) - OAuth token support 3. **Config Schema Update** (`src/config/schema.ts`) - OAuth config fields 4. **CLI Command** (`src/cli/commands/login.ts`) - Interactive login 5. **Testing Strategy** - Unit tests + integration tests ### API Surfaces ```typescript // src/auth/openai.ts export interface OpenAIAuthStore { id_token: string; access_token: string; refresh_token: string; expires_at: number; account_id?: string; } export interface DeviceAuthResponse { device_auth_id: string; user_code: string; interval: string; } export function requestOpenAIDeviceCode(): Promise export function pollForOpenAIToken(deviceAuthId: string, userCode: string, interval: number): Promise export function refreshOpenAIToken(refreshToken: string): Promise export function loadOpenAIToken(): OpenAIAuthStore | null export function storeOpenAIToken(tokens: TokenResponse): void export function getOpenAIToken(): Promise export function loginOpenAI(onPrompt: (userCode: string, url: string) => void): Promise // src/models/openai.ts - Enhanced config export interface OpenAIClientConfig { apiKey?: string; model: string; maxTokens?: number; baseURL?: string; timeoutMs?: number; oauth?: { enabled: boolean; tokenLoader?: () => Promise; tokenSaver?: (tokens: OpenAIAuthStore) => Promise; }; } ``` --- ## Implementation Details ### 1. OAuth Module (`src/auth/openai.ts`) **Purpose**: Handle OAuth device flow for ChatGPT Plus/Pro authentication. **Key Constants**: ```typescript const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'; const ISSUER = 'https://auth.openai.com'; const CODEX_API_ENDPOINT = 'https://chatgpt.com/backend-api/codex/responses'; ``` **Core Functions**: #### a. Device Flow Initiation ```typescript async function requestOpenAIDeviceCode(): Promise { // POST to https://auth.openai.com/api/accounts/deviceauth/usercode // Body: { client_id: CLIENT_ID } // Returns: { device_auth_id, user_code, interval } } ``` #### b. Token Polling ```typescript async function pollForOpenAIToken( deviceAuthId: string, userCode: string, interval: number ): Promise { // Poll https://auth.openai.com/api/accounts/deviceauth/token // Body: { device_auth_id, user_code } // On success, exchange authorization_code for tokens via /oauth/token } ``` #### c. Token Refresh ```typescript async function refreshOpenAIToken(refreshToken: string): Promise { // POST https://auth.openai.com/oauth/token // Body: { // grant_type: 'refresh_token', // refresh_token: refreshToken, // client_id: CLIENT_ID // } } ``` #### d. Token Storage ```typescript // Storage path: ~/.config/flynn/auth.json interface AuthStore { github?: { ... }; openai?: { id_token: string; access_token: string; refresh_token: string; expires_at: number; account_id?: string; }; } ``` #### e. Account ID Extraction ```typescript function extractAccountId(tokens: TokenResponse): string | undefined { // Parse JWT claims from id_token or access_token // Priority: // 1. claims.chatgpt_account_id // 2. claims['https://api.openai.com/auth'].chatgpt_account_id // 3. claims.organizations[0].id } ``` **File Structure**: ``` src/auth/ ├── github.ts # Existing ├── openai.ts # NEW - OAuth device flow └── index.ts # Export openai.ts exports ``` --- ### 2. OpenAI Client Enhancement (`src/models/openai.ts`) **Changes**: #### a. Config Extension ```typescript export interface OpenAIClientConfig { apiKey?: string; // Now optional when OAuth is used model: string; maxTokens?: number; baseURL?: string; timeoutMs?: number; oauth?: { enabled: boolean; tokenLoader?: () => Promise; tokenSaver?: (tokens: OpenAIAuthStore) => Promise; }; } ``` #### b. Client Constructor Changes ```typescript constructor(config: OpenAIClientConfig) { this.oauthConfig = config.oauth; this.client = new OpenAI({ apiKey: config.apiKey ?? 'dummy-key-for-oauth', // OpenAI SDK requires this baseURL: config.baseURL, timeout: config.timeoutMs ?? 20_000, maxRetries: 0, fetch: this.oauthConfig?.enabled ? this.createOAuthFetch() : undefined, }); // ... } ``` #### c. Custom Fetch for OAuth ```typescript private createOAuthFetch() { return async (url: RequestInfo | URL, init?: RequestInit) => { if (!this.oauthConfig?.tokenLoader) { throw new Error('OAuth enabled but no token loader provided'); } // Load token let auth = await this.oauthConfig.tokenLoader(); // Refresh if expired if (!auth || auth.expires_at < Date.now()) { if (!auth?.refresh_token) { throw new Error('OAuth token expired and no refresh token available'); } const tokens = await refreshOpenAIToken(auth.refresh_token); auth = { id_token: tokens.id_token, access_token: tokens.access_token, refresh_token: tokens.refresh_token, expires_at: Date.now() + (tokens.expires_in ?? 3600) * 1000, account_id: extractAccountId(tokens) || auth.account_id, }; if (this.oauthConfig.tokenSaver) { await this.oauthConfig.tokenSaver(auth); } } // Build headers const headers = new Headers(init?.headers); headers.set('Authorization', `Bearer ${auth.access_token}`); if (auth.account_id) { headers.set('ChatGPT-Account-Id', auth.account_id); } headers.set('originator', 'flynn'); // Rewrite URL to Codex endpoint for supported models const isCodexModel = this.model.includes('codex') || ['gpt-5.1', 'gpt-5.2', 'gpt-5.3'].some(v => this.model.includes(v)); const targetUrl = isCodexModel ? new URL(CODEX_API_ENDPOINT) : (typeof url === 'string' ? new URL(url) : url); return fetch(targetUrl, { ...init, headers }); }; } ``` --- ### 3. Config Schema Update (`src/config/schema.ts`) **Changes**: ```typescript // Line ~46-56: Enhance modelConfigBaseSchema const modelConfigBaseSchema = z.object({ provider: z.enum(MODEL_PROVIDERS), model: z.string(), endpoint: z.string().optional(), api_key: z.string().optional(), auth_token: z.string().optional(), oauth_enabled: z.boolean().optional(), // NEW for: z.array(z.string()).optional(), num_gpu: z.number().optional(), context_window: z.number().optional(), supports_audio: z.boolean().optional(), }); ``` **Example Config**: ```yaml models: default: provider: openai model: gpt-5.2-codex oauth_enabled: true # Use OAuth instead of API key ``` --- ### 4. Model Factory Update (`src/daemon/models.ts`) **Changes**: ```typescript // Line ~51-55: createClientFromConfig() openai case case 'openai': return new OpenAIClient({ model: cfg.model, apiKey: cfg.api_key, oauth: cfg.oauth_enabled ? { enabled: true, tokenLoader: async () => { const { getOpenAIToken } = await import('../auth/openai.js'); return getOpenAIToken(); }, tokenSaver: async (tokens) => { const { storeOpenAIToken } = await import('../auth/openai.js'); storeOpenAIToken(tokens); }, } : undefined, }); ``` --- ### 5. CLI Command (`src/cli/commands/login.ts`) **New File**: ```typescript import { Command } from 'commander'; import { loginOpenAI } from '../../auth/openai.js'; export function createLoginCommand(): Command { const cmd = new Command('login'); cmd .command('openai') .description('Authenticate with OpenAI using ChatGPT Plus/Pro account') .action(async () => { console.log('Starting OpenAI OAuth login...\n'); try { const auth = await loginOpenAI((userCode, url) => { console.log(`\nPlease visit: ${url}`); console.log(`Enter code: ${userCode}\n`); console.log('Waiting for authorization...'); }); console.log('\n✓ Successfully authenticated with OpenAI!'); if (auth.account_id) { console.log(` Account ID: ${auth.account_id}`); } console.log('\nYou can now use OpenAI models with oauth_enabled: true in your config.'); } catch (error) { console.error('Login failed:', error instanceof Error ? error.message : error); process.exit(1); } }); cmd .command('github') .description('Authenticate with GitHub Copilot') .action(async () => { const { loginGitHub } = await import('../../auth/github.js'); // ... existing GitHub login logic }); return cmd; } ``` **Register in** `src/cli/index.ts`: ```typescript import { createLoginCommand } from './commands/login.js'; // ... program.addCommand(createLoginCommand()); ``` --- ## Exact Flow Steps ### User Authentication Flow ``` 1. User runs: flynn login openai 2. CLI requests device code from OpenAI: POST https://auth.openai.com/api/accounts/deviceauth/usercode Body: { client_id: 'app_EMoamEEZ73f0CkXaXp7hrann' } 3. OpenAI returns: { device_auth_id: "uuid-v4", user_code: "ABCD-1234", interval: "5" } 4. CLI displays: "Visit: https://auth.openai.com/codex/device" "Enter code: ABCD-1234" 5. CLI polls for token: POST https://auth.openai.com/api/accounts/deviceauth/token Body: { device_auth_id, user_code } Every 5 seconds (+ 3s safety margin) 6. When user authorizes, OpenAI returns: { authorization_code: "auth-code-xyz", code_verifier: "pkce-verifier" } 7. CLI exchanges code for tokens: POST https://auth.openai.com/oauth/token Body: { grant_type: 'authorization_code', code: authorization_code, redirect_uri: 'https://auth.openai.com/deviceauth/callback', client_id: CLIENT_ID, code_verifier: code_verifier } 8. OpenAI returns tokens: { id_token: "jwt-id-token", access_token: "jwt-access-token", refresh_token: "refresh-token", expires_in: 3600 } 9. CLI extracts account_id from JWT claims 10. CLI stores to ~/.config/flynn/auth.json: { openai: { id_token: "...", access_token: "...", refresh_token: "...", expires_at: 1739123456789, account_id: "org-xxx" } } 11. User updates config.yaml: models: default: provider: openai model: gpt-5.2-codex oauth_enabled: true 12. Flynn daemon starts, creates OpenAI client with OAuth enabled ``` ### Request Flow (OAuth Mode) ``` 1. User sends message via Telegram/TUI 2. Agent calls modelRouter.chat(request) 3. OpenAIClient.chat() invoked 4. Custom fetch interceptor: a. Load token from ~/.config/flynn/auth.json b. Check if expires_at < Date.now() c. If expired: - POST refresh token to /oauth/token - Update stored token d. Set headers: - Authorization: Bearer {access_token} - ChatGPT-Account-Id: {account_id} - originator: flynn e. Rewrite URL to Codex endpoint if model includes 'codex' f. Execute fetch with modified request 5. OpenAI returns response (free for ChatGPT Plus/Pro subscribers) 6. Response parsed and returned to agent ``` --- ## File Changes Summary ### New Files ``` src/auth/openai.ts # OAuth device flow (300 lines) src/cli/commands/login.ts # Login command (80 lines) docs/plans/openai-oauth-implementation.md # This document ``` ### Modified Files ``` src/auth/index.ts # Export openai.ts functions (10 lines changed) src/models/openai.ts # Add OAuth support (150 lines changed) src/config/schema.ts # Add oauth_enabled field (5 lines changed) src/daemon/models.ts # Add OAuth tokenLoader/Saver (15 lines changed) src/cli/index.ts # Register login command (3 lines changed) ``` ### Test Files (New) ``` src/auth/openai.test.ts # Unit tests for OAuth flow src/models/openai.oauth.test.ts # Integration tests for OAuth client ``` --- ## Testing Strategy ### Unit Tests #### 1. JWT Parsing (`src/auth/openai.test.ts`) ```typescript describe('parseJwtClaims', () => { test('parses valid JWT', () => { const token = createTestJwt({ email: 'test@example.com' }); expect(parseJwtClaims(token)).toMatchObject({ email: 'test@example.com' }); }); test('returns undefined for invalid JWT', () => { expect(parseJwtClaims('invalid')).toBeUndefined(); }); }); describe('extractAccountId', () => { test('extracts from chatgpt_account_id', () => { const tokens = { id_token: createTestJwt({ chatgpt_account_id: 'acc-123' }), access_token: '', refresh_token: '', }; expect(extractAccountId(tokens)).toBe('acc-123'); }); test('extracts from organizations array', () => { const tokens = { id_token: createTestJwt({ organizations: [{ id: 'org-123' }] }), access_token: '', refresh_token: '', }; expect(extractAccountId(tokens)).toBe('org-123'); }); }); ``` #### 2. Token Refresh Logic (`src/models/openai.oauth.test.ts`) ```typescript describe('OpenAIClient OAuth', () => { test('refreshes expired token before request', async () => { const expiredAuth = { access_token: 'expired', refresh_token: 'valid-refresh', expires_at: Date.now() - 1000, // Expired }; const client = new OpenAIClient({ model: 'gpt-5.2-codex', oauth: { enabled: true, tokenLoader: async () => expiredAuth, tokenSaver: vi.fn(), }, }); // Mock refreshOpenAIToken vi.mock('../auth/openai.js', () => ({ refreshOpenAIToken: vi.fn().mockResolvedValue({ access_token: 'new-access', refresh_token: 'valid-refresh', expires_in: 3600, }), })); await client.chat({ messages: [{ role: 'user', content: 'test' }], }); expect(refreshOpenAIToken).toHaveBeenCalledWith('valid-refresh'); }); }); ``` ### Integration Tests #### 3. End-to-End OAuth Flow (Manual) ```bash # 1. Run login command flynn login openai # 2. Verify token stored cat ~/.config/flynn/auth.json | jq '.openai' # 3. Start daemon with OAuth config cat > /tmp/flynn-oauth-test.yaml <=18) - Native `crypto` API for JWT parsing --- ## Success Criteria 1. ✅ User can run `flynn login openai` and authenticate 2. ✅ Token persists across daemon restarts 3. ✅ Expired tokens refresh automatically 4. ✅ OpenAI Codex models work via OAuth (free for Plus/Pro users) 5. ✅ Fallback to API key mode if `oauth_enabled: false` 6. ✅ Error messages guide users to fix auth issues --- ## API Endpoint Reference ### OpenAI OAuth Endpoints | Endpoint | Method | Purpose | |----------|--------|---------| | `https://auth.openai.com/api/accounts/deviceauth/usercode` | POST | Initiate device flow | | `https://auth.openai.com/api/accounts/deviceauth/token` | POST | Poll for authorization | | `https://auth.openai.com/oauth/token` | POST | Exchange code / refresh token | | `https://chatgpt.com/backend-api/codex/responses` | POST | Codex completions endpoint | ### Request/Response Formats #### Device Code Request ```json POST /api/accounts/deviceauth/usercode { "client_id": "app_EMoamEEZ73f0CkXaXp7hrann" } ``` **Response**: ```json { "device_auth_id": "uuid-v4", "user_code": "ABCD-1234", "interval": "5" } ``` #### Token Polling Request ```json POST /api/accounts/deviceauth/token { "device_auth_id": "uuid-v4", "user_code": "ABCD-1234" } ``` **Response (pending)**: ```json { "status": 403 } // Keep polling ``` **Response (authorized)**: ```json { "authorization_code": "auth-code-xyz", "code_verifier": "pkce-verifier" } ``` #### Token Exchange Request ```json POST /oauth/token Content-Type: application/x-www-form-urlencoded grant_type=authorization_code &code=auth-code-xyz &redirect_uri=https://auth.openai.com/deviceauth/callback &client_id=app_EMoamEEZ73f0CkXaXp7hrann &code_verifier=pkce-verifier ``` **Response**: ```json { "id_token": "eyJhbGc...", "access_token": "eyJhbGc...", "refresh_token": "refresh-token-xyz", "expires_in": 3600, "token_type": "Bearer" } ``` #### Refresh Token Request ```json POST /oauth/token Content-Type: application/x-www-form-urlencoded grant_type=refresh_token &refresh_token=refresh-token-xyz &client_id=app_EMoamEEZ73f0CkXaXp7hrann ``` **Response**: Same as token exchange #### Codex Request ```json POST /backend-api/codex/responses Authorization: Bearer {access_token} ChatGPT-Account-Id: {account_id} originator: flynn { "model": "gpt-5.2-codex", "messages": [...], "max_tokens": 4096 } ``` --- ## Conclusion This plan provides a **minimal, production-ready** OAuth implementation for Flynn, closely modeled after OpenCode's proven `CodexAuthPlugin`. The device flow approach is headless-friendly and aligns with Flynn's daemon architecture. **Total implementation effort**: ~600 lines of new code + ~200 lines of modifications + comprehensive tests. **Key advantages**: - Free ChatGPT Plus/Pro model access (no API costs) - Proven OAuth flow (matches OpenCode) - Clean separation of concerns (auth module + client enhancement) - Backward compatible (API key mode still works) **Next steps**: 1. Review this plan 2. Implement Phase 1 (core OAuth + tests) 3. Test with real ChatGPT Plus account 4. Iterate on UX and error handling