# 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<DeviceAuthResponse>
export async function pollForOpenAIToken(deviceAuthId: string, userCode: string, interval: number): Promise<TokenResponse>
export async function refreshOpenAIToken(refreshToken: string): Promise<TokenResponse>
export function loadOpenAIToken(): OpenAIAuthStore | null
export function storeOpenAIToken(tokens: TokenResponse): void
export async function getOpenAIToken(): Promise<OpenAIAuthStore | null>
export async function loginOpenAI(onPrompt: (userCode: string, url: string) => void): Promise<OpenAIAuthStore>
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<OpenAIAuthStore | null>;
    tokenSaver?: (tokens: OpenAIAuthStore) => Promise<void>;
  };
}
```

#### 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
