# OpenAI OAuth Implementation - File Changes Checklist ## Quick Summary Add ChatGPT Plus/Pro OAuth to Flynn using device flow + Codex responses endpoint. **Core Change**: OAuth device flow → token storage → auto-refresh → custom fetch interceptor → Codex endpoint routing. --- ## File Changes ### 1. NEW: `src/auth/openai.ts` (~300 lines) ```typescript // Key exports: export interface OpenAIAuthStore { id_token: string; access_token: string; refresh_token: string; expires_at: number; account_id?: string; } export async function requestOpenAIDeviceCode(): Promise export async function pollForOpenAIToken(deviceAuthId: string, userCode: string, interval: number): Promise export async function refreshOpenAIToken(refreshToken: string): Promise export function loadOpenAIToken(): OpenAIAuthStore | null export function storeOpenAIToken(tokens: TokenResponse): void export async function getOpenAIToken(): Promise export async function loginOpenAI(onPrompt: (userCode: string, url: string) => void): Promise export function extractAccountId(tokens: TokenResponse): string | undefined export function parseJwtClaims(token: string): IdTokenClaims | undefined ``` **Constants**: ```typescript const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'; const ISSUER = 'https://auth.openai.com'; const CODEX_API_ENDPOINT = 'https://chatgpt.com/backend-api/codex/responses'; const AUTH_FILE = '~/.config/flynn/auth.json'; ``` --- ### 2. MODIFY: `src/auth/index.ts` (+8 lines) ```typescript // Add exports: export { requestOpenAIDeviceCode, pollForOpenAIToken, refreshOpenAIToken, loadOpenAIToken, storeOpenAIToken, getOpenAIToken, loginOpenAI, extractAccountId, parseJwtClaims, type OpenAIAuthStore, } from './openai.js'; ``` --- ### 3. MODIFY: `src/models/openai.ts` (+100 lines) #### Add to interface (line ~4-10): ```typescript export interface OpenAIClientConfig { apiKey?: string; // Now optional when OAuth model: string; maxTokens?: number; baseURL?: string; timeoutMs?: number; oauth?: { // NEW enabled: boolean; tokenLoader?: () => Promise; tokenSaver?: (tokens: OpenAIAuthStore) => Promise; }; } ``` #### Add to class (line ~55-60): ```typescript export class OpenAIClient implements ModelClient { private client: OpenAI; private model: string; private defaultMaxTokens: number; private oauthConfig?: OpenAIClientConfig['oauth']; // NEW constructor(config: OpenAIClientConfig) { const timeoutMs = config.timeoutMs ?? 20_000; this.oauthConfig = config.oauth; // NEW this.client = new OpenAI({ apiKey: config.apiKey ?? (config.oauth?.enabled ? 'dummy-oauth-key' : undefined), // MODIFY baseURL: config.baseURL, timeout: timeoutMs, maxRetries: 0, fetch: config.oauth?.enabled ? this.createOAuthFetch() : undefined, // NEW }); this.model = config.model; this.defaultMaxTokens = config.maxTokens ?? 4096; } // NEW METHOD private createOAuthFetch() { return async (url: RequestInfo | URL, init?: RequestInit) => { if (!this.oauthConfig?.tokenLoader) { throw new Error('OAuth enabled but no token loader provided'); } // Load + refresh token if needed let auth = await this.oauthConfig.tokenLoader(); if (!auth || auth.expires_at < Date.now()) { const { refreshOpenAIToken } = await import('../auth/openai.js'); if (!auth?.refresh_token) { throw new Error('OAuth token expired - run `flynn login openai`'); } 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 to Codex endpoint for supported models const CODEX_API_ENDPOINT = 'https://chatgpt.com/backend-api/codex/responses'; 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 }); }; } // ... rest unchanged } ``` --- ### 4. MODIFY: `src/config/schema.ts` (+1 line) Line ~46-56: ```typescript 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(), }); ``` --- ### 5. MODIFY: `src/daemon/models.ts` (+15 lines) Line ~51-55 (openai case): ```typescript case 'openai': return new OpenAIClient({ model: cfg.model, apiKey: cfg.api_key, oauth: cfg.oauth_enabled ? { // NEW 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, }); ``` --- ### 6. NEW: `src/cli/commands/login.ts` (~80 lines) ```typescript import { Command } from 'commander'; import { loginOpenAI } from '../../auth/openai.js'; import { loginGitHub } from '../../auth/github.js'; export function createLoginCommand(): Command { const cmd = new Command('login') .description('Authenticate with external services'); cmd .command('openai') .description('Authenticate with OpenAI (ChatGPT Plus/Pro)') .action(async () => { console.log('Starting OpenAI OAuth login...\n'); try { const auth = await loginOpenAI((userCode, url) => { console.log(`\nVisit: ${url}`); console.log(`Enter code: ${userCode}\n`); console.log('Waiting for authorization...'); }); console.log('\n✓ Successfully authenticated!'); if (auth.account_id) { console.log(` Account ID: ${auth.account_id}`); } console.log('\nUpdate config.yaml:'); console.log(' models.default.oauth_enabled: true'); } 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 () => { try { await loginGitHub((userCode, verificationUri) => { console.log(`\nVisit: ${verificationUri}`); console.log(`Enter code: ${userCode}\n`); }); console.log('✓ GitHub authentication successful!'); } catch (error) { console.error('Login failed:', error instanceof Error ? error.message : error); process.exit(1); } }); return cmd; } ``` --- ### 7. MODIFY: `src/cli/index.ts` (+2 lines) Add import: ```typescript import { createLoginCommand } from './commands/login.js'; ``` Register command (after other commands): ```typescript program.addCommand(createLoginCommand()); ``` --- ### 8. NEW: `src/auth/openai.test.ts` (~150 lines) ```typescript import { describe, test, expect } from 'vitest'; import { parseJwtClaims, extractAccountId } from './openai.js'; function createTestJwt(payload: object): string { const header = Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url'); const body = Buffer.from(JSON.stringify(payload)).toString('base64url'); return `${header}.${body}.sig`; } describe('parseJwtClaims', () => { test('parses valid JWT', () => { const token = createTestJwt({ email: 'test@example.com' }); const claims = parseJwtClaims(token); expect(claims).toMatchObject({ email: 'test@example.com' }); }); test('returns undefined for invalid JWT', () => { expect(parseJwtClaims('invalid')).toBeUndefined(); expect(parseJwtClaims('only.two')).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', () => { const tokens = { id_token: createTestJwt({ organizations: [{ id: 'org-123' }] }), access_token: '', refresh_token: '', }; expect(extractAccountId(tokens)).toBe('org-123'); }); test('returns undefined when no account found', () => { const tokens = { id_token: createTestJwt({ email: 'test@example.com' }), access_token: '', refresh_token: '', }; expect(extractAccountId(tokens)).toBeUndefined(); }); }); ``` --- ## Usage Flow ### 1. Login ```bash flynn login openai # Output: # Visit: https://auth.openai.com/codex/device # Enter code: ABCD-1234 # # ✓ Successfully authenticated! # Account ID: org-xyz # # Update config.yaml: # models.default.oauth_enabled: true ``` ### 2. Update Config ```yaml # config.yaml models: default: provider: openai model: gpt-5.2-codex # or gpt-5.3-codex, gpt-5.1-codex-max, etc. oauth_enabled: true ``` ### 3. Start Daemon ```bash flynn start # OAuth token loaded automatically # Requests route to Codex endpoint # Token auto-refreshes when expired ``` --- ## Testing Commands ```bash # Build pnpm build # Unit tests pnpm test src/auth/openai.test.ts # Manual integration test flynn login openai flynn start --config /tmp/test-oauth.yaml # Send message via Telegram/TUI ``` --- ## API Endpoints Used | Step | Endpoint | Method | |------|----------|--------| | 1. Device code | `https://auth.openai.com/api/accounts/deviceauth/usercode` | POST | | 2. Poll auth | `https://auth.openai.com/api/accounts/deviceauth/token` | POST | | 3. Exchange token | `https://auth.openai.com/oauth/token` | POST | | 4. Refresh token | `https://auth.openai.com/oauth/token` | POST | | 5. Codex request | `https://chatgpt.com/backend-api/codex/responses` | POST | --- ## Error Handling ```typescript // Token expired + no refresh throw new Error('OAuth token expired - run `flynn login openai`'); // Subscription lapsed // OpenAI returns 403 with body: { detail: "subscription_required" } // Invalid model // OpenAI returns 404 if model not available for user tier ``` --- ## Rollout Checklist - [ ] Implement `src/auth/openai.ts` - [ ] Add unit tests (JWT parsing, account extraction) - [ ] Update `src/models/openai.ts` with OAuth support - [ ] Update config schema - [ ] Update model factory - [ ] Implement `flynn login openai` command - [ ] Manual test with real ChatGPT Plus account - [ ] Document in README.md - [ ] Commit with message: "feat: add OpenAI OAuth support for ChatGPT Plus/Pro" --- ## Success Metrics ✅ User authenticates with `flynn login openai` ✅ Token persists in `~/.config/flynn/auth.json` ✅ Daemon starts with `oauth_enabled: true` ✅ Requests succeed using Codex endpoint ✅ Token auto-refreshes on expiry ✅ Graceful error on subscription lapse