Files
flynn/docs/plans/openai-oauth-implementation.md

21 KiB

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

// 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:

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

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

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

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

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

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

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

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

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:

// 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:

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:

// 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:

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:

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)

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)

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)

# 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

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

POST /api/accounts/deviceauth/usercode
{
  "client_id": "app_EMoamEEZ73f0CkXaXp7hrann"
}

Response:

{
  "device_auth_id": "uuid-v4",
  "user_code": "ABCD-1234",
  "interval": "5"
}

Token Polling Request

POST /api/accounts/deviceauth/token
{
  "device_auth_id": "uuid-v4",
  "user_code": "ABCD-1234"
}

Response (pending):

{ "status": 403 }  // Keep polling

Response (authorized):

{
  "authorization_code": "auth-code-xyz",
  "code_verifier": "pkce-verifier"
}

Token Exchange Request

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:

{
  "id_token": "eyJhbGc...",
  "access_token": "eyJhbGc...",
  "refresh_token": "refresh-token-xyz",
  "expires_in": 3600,
  "token_type": "Bearer"
}

Refresh Token Request

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

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