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

// src/models/openai.ts - Enhanced config
export interface OpenAIClientConfig {
  apiKey?: string;
  model: string;
  maxTokens?: number;
  baseURL?: string;
  timeoutMs?: number;
  oauth?: {
    enabled: boolean;
    tokenLoader?: () => Promise<OpenAIAuthStore | null>;
    tokenSaver?: (tokens: OpenAIAuthStore) => Promise<void>;
  };
}
```

---

## 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<DeviceAuthResponse> {
  // 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<TokenResponse> {
  // 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<TokenResponse> {
  // 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<OpenAIAuthStore | null>;
    tokenSaver?: (tokens: OpenAIAuthStore) => Promise<void>;
  };
}
```

#### 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 <<EOF
models:
  default:
    provider: openai
    model: gpt-5.2-codex
    oauth_enabled: true
telegram:
  bot_token: \${TELEGRAM_BOT_TOKEN}
  allowed_chat_ids: [123456789]
EOF

flynn start --config /tmp/flynn-oauth-test.yaml

# 4. Send test message and verify response
```

### Mock Testing Setup

```typescript
// test/mocks/openai-oauth.ts
export function mockOpenAIAuthEndpoints() {
  return {
    deviceCode: vi.fn().mockResolvedValue({
      device_auth_id: 'test-device-id',
      user_code: 'ABCD-1234',
      interval: '1',
    }),
    deviceToken: vi.fn().mockResolvedValue({
      authorization_code: 'test-auth-code',
      code_verifier: 'test-verifier',
    }),
    exchangeToken: vi.fn().mockResolvedValue({
      id_token: createTestJwt({ chatgpt_account_id: 'acc-test' }),
      access_token: createTestJwt({ sub: 'user-123' }),
      refresh_token: 'test-refresh',
      expires_in: 3600,
    }),
    refreshToken: vi.fn().mockResolvedValue({
      access_token: 'new-access-token',
      refresh_token: 'test-refresh',
      expires_in: 3600,
    }),
  };
}
```

---

## Migration & Rollout

### Phase 1: Core Implementation (Week 1)
- [ ] Implement `src/auth/openai.ts` with device flow
- [ ] Add unit tests for JWT parsing and account ID extraction
- [ ] Update `src/models/openai.ts` with OAuth support
- [ ] Add mock-based tests for OAuth client

### Phase 2: CLI & Config (Week 2)
- [ ] Implement `flynn login openai` command
- [ ] Update config schema for `oauth_enabled`
- [ ] Update daemon model factory
- [ ] Manual end-to-end testing

### Phase 3: Documentation & Polish (Week 3)
- [ ] Add user documentation (USAGE.md)
- [ ] Add examples to README.md
- [ ] Error handling improvements (expired subscriptions, network failures)
- [ ] Add token status command: `flynn auth status`

---

## Known Limitations & Future Enhancements

### Current Limitations
1. **No browser-based OAuth flow** - Only device flow (headless-friendly)
2. **No automatic subscription verification** - Assumes user has ChatGPT Plus/Pro
3. **Single account support** - No multi-account switching

### Future Enhancements
1. **Browser OAuth flow** - Add local HTTP server for callback (like OpenCode)
2. **Subscription checker** - Verify user has Plus/Pro before allowing config
3. **Token rotation** - Automatic background refresh before expiry
4. **Multi-account** - Support multiple OpenAI accounts with profiles
5. **TUI integration** - In-app login flow instead of separate CLI command

---

## Security Considerations

1. **Token Storage**:
   - Store in `~/.config/flynn/auth.json` with `chmod 600`
   - Never log access tokens in debug output
   - Clear tokens on logout: `flynn logout openai`

2. **Token Refresh**:
   - Refresh tokens valid for ~6 months (OpenAI policy)
   - Handle refresh failures gracefully (prompt re-login)

3. **Rate Limiting**:
   - ChatGPT Plus/Pro has usage limits (not documented publicly)
   - Implement exponential backoff on 429 responses

4. **PKCE Verification**:
   - Device flow includes code_verifier for PKCE
   - Prevent CSRF by validating state (future browser flow)

---

## Dependencies

**No new dependencies required** - Uses existing:
- `openai` (already in package.json)
- Native `fetch` API (Node.js >=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
