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

431 lines
12 KiB
Markdown

# 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