437 lines
12 KiB
Markdown
437 lines
12 KiB
Markdown
# 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)
|
|
|
|
```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
|
|
|
|
|
|
|