Files
flynn/docs/plans/openai-oauth-checklist.md
T

12 KiB

OpenAI OAuth Implementation - File Changes Checklist

Archived (2026-02-18): Historical implementation checklist. Canonical status is tracked in docs/plans/state.json; unchecked boxes here are not active backlog unless explicitly re-opened.

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)

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

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)

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

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

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:

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

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)

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:

import { createLoginCommand } from './commands/login.js';

Register command (after other commands):

program.addCommand(createLoginCommand());

8. NEW: src/auth/openai.test.ts (~150 lines)

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

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

# 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

flynn start
# OAuth token loaded automatically
# Requests route to Codex endpoint
# Token auto-refreshes when expired

Testing Commands

# 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

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