feat: add OpenAI OAuth, strict model overrides, and Gmail pull mode
This commit is contained in:
@@ -0,0 +1,430 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user