feat: add OpenAI OAuth, strict model overrides, and Gmail pull mode

This commit is contained in:
William Valentin
2026-02-13 14:55:40 -08:00
parent 8f644d5e25
commit 955b9e28e0
50 changed files with 5955 additions and 160 deletions
+13
View File
@@ -7,3 +7,16 @@ export {
loginGitHub,
type DeviceCodeResponse,
} from './github.js';
export {
loadStoredOpenAIAuth,
storeOpenAIAuth,
clearOpenAIAuth,
refreshOpenAIAuth,
ensureValidOpenAIAuth,
loginOpenAI,
parseJwtClaims,
extractAccountId,
type OpenAIOAuthInfo,
type IdTokenClaims,
} from './openai.js';
+43
View File
@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest';
import { parseJwtClaims, extractAccountId } from './openai.js';
function base64UrlEncode(obj: unknown): string {
return Buffer.from(JSON.stringify(obj)).toString('base64url');
}
function makeJwt(payload: Record<string, unknown>): string {
const header = base64UrlEncode({ alg: 'none', typ: 'JWT' });
const body = base64UrlEncode(payload);
// Signature is ignored by parseJwtClaims.
return `${header}.${body}.sig`;
}
describe('OpenAI OAuth helpers', () => {
it('parseJwtClaims returns undefined for non-jwt strings', () => {
expect(parseJwtClaims('not-a-jwt')).toBeUndefined();
});
it('parseJwtClaims parses base64url payload', () => {
const token = makeJwt({ chatgpt_account_id: 'acct_123' });
const claims = parseJwtClaims(token);
expect(claims?.chatgpt_account_id).toBe('acct_123');
});
it('extractAccountId prefers chatgpt_account_id', () => {
const tokens = {
access_token: makeJwt({ chatgpt_account_id: 'acct_a' }),
refresh_token: 'rt',
id_token: makeJwt({ chatgpt_account_id: 'acct_b' }),
};
expect(extractAccountId(tokens)).toBe('acct_b');
});
it('extractAccountId falls back to organizations[0].id', () => {
const tokens = {
access_token: makeJwt({ organizations: [{ id: 'org_1' }] }),
refresh_token: 'rt',
};
expect(extractAccountId(tokens)).toBe('org_1');
});
});
+281
View File
@@ -0,0 +1,281 @@
import { readFileSync, writeFileSync, mkdirSync, chmodSync } from 'fs';
import { resolve } from 'path';
import { homedir } from 'os';
const ISSUER = 'https://auth.openai.com';
const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
const DEVICE_URL = `${ISSUER}/codex/device`;
const DEVICE_CODE_URL = `${ISSUER}/api/accounts/deviceauth/usercode`;
const DEVICE_TOKEN_URL = `${ISSUER}/api/accounts/deviceauth/token`;
const TOKEN_URL = `${ISSUER}/oauth/token`;
const POLLING_SAFETY_MARGIN_MS = 3000;
const REFRESH_SAFETY_MARGIN_MS = 30_000;
const AUTH_DIR = resolve(homedir(), '.config/flynn');
const AUTH_FILE = resolve(AUTH_DIR, 'auth.json');
export interface OpenAIOAuthInfo {
access_token: string;
refresh_token: string;
/** Epoch millis. */
expires_at: number;
/** Optional account/org id used for subscription routing. */
account_id?: string;
created_at: string;
}
interface AuthStore {
// Leave github entry untyped here so this module does not depend on github.ts.
github?: unknown;
openai?: OpenAIOAuthInfo;
}
interface DeviceAuthResponse {
device_auth_id: string;
user_code: string;
interval: string;
}
interface DeviceTokenResponse {
authorization_code: string;
code_verifier: string;
}
interface TokenResponse {
id_token?: string;
access_token: string;
refresh_token: string;
expires_in?: number;
}
export interface IdTokenClaims {
chatgpt_account_id?: string;
organizations?: Array<{ id: string }>;
'https://api.openai.com/auth'?: {
chatgpt_account_id?: string;
};
}
function safeJsonParse<T>(raw: string): T | null {
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}
function readAuthStore(): AuthStore {
try {
const raw = readFileSync(AUTH_FILE, 'utf-8');
const parsed = safeJsonParse<AuthStore>(raw);
return parsed ?? {};
} catch {
return {};
}
}
function writeAuthStore(store: AuthStore): void {
mkdirSync(AUTH_DIR, { recursive: true });
writeFileSync(AUTH_FILE, JSON.stringify(store, null, 2) + '\n', 'utf-8');
chmodSync(AUTH_FILE, 0o600);
}
export function loadStoredOpenAIAuth(): OpenAIOAuthInfo | null {
const store = readAuthStore();
return store.openai ?? null;
}
export function storeOpenAIAuth(info: OpenAIOAuthInfo): void {
const store = readAuthStore();
store.openai = info;
writeAuthStore(store);
}
export function clearOpenAIAuth(): void {
const store = readAuthStore();
delete store.openai;
writeAuthStore(store);
}
export function parseJwtClaims(token: string): IdTokenClaims | undefined {
const parts = token.split('.');
if (parts.length !== 3) {return undefined;}
try {
return JSON.parse(Buffer.from(parts[1], 'base64url').toString()) as IdTokenClaims;
} catch {
return undefined;
}
}
function extractAccountIdFromClaims(claims: IdTokenClaims): string | undefined {
return claims.chatgpt_account_id
?? claims['https://api.openai.com/auth']?.chatgpt_account_id
?? claims.organizations?.[0]?.id;
}
export function extractAccountId(tokens: TokenResponse): string | undefined {
const idToken = tokens.id_token;
if (idToken) {
const claims = parseJwtClaims(idToken);
const id = claims && extractAccountIdFromClaims(claims);
if (id) {return id;}
}
const accessToken = tokens.access_token;
if (accessToken) {
const claims = parseJwtClaims(accessToken);
return claims ? extractAccountIdFromClaims(claims) : undefined;
}
return undefined;
}
async function requestDeviceAuth(): Promise<DeviceAuthResponse> {
const response = await fetch(DEVICE_CODE_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': 'flynn',
},
body: JSON.stringify({ client_id: CLIENT_ID }),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`OpenAI device auth start failed (${response.status}): ${body}`);
}
return response.json() as Promise<DeviceAuthResponse>;
}
async function pollDeviceToken(deviceAuthId: string, userCode: string, intervalMs: number): Promise<DeviceTokenResponse> {
while (true) {
await new Promise(r => setTimeout(r, intervalMs + POLLING_SAFETY_MARGIN_MS));
const response = await fetch(DEVICE_TOKEN_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': 'flynn',
},
body: JSON.stringify({ device_auth_id: deviceAuthId, user_code: userCode }),
});
if (response.ok) {
return response.json() as Promise<DeviceTokenResponse>;
}
// OpenCode treats 403/404 as "pending".
if (response.status === 403 || response.status === 404) {
continue;
}
const body = await response.text();
throw new Error(`OpenAI device auth token failed (${response.status}): ${body}`);
}
}
async function exchangeAuthorizationCode(authCode: string, codeVerifier: string): Promise<TokenResponse> {
const response = await fetch(TOKEN_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'flynn',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: authCode,
redirect_uri: `${ISSUER}/deviceauth/callback`,
client_id: CLIENT_ID,
code_verifier: codeVerifier,
}).toString(),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`OpenAI token exchange failed (${response.status}): ${body}`);
}
return response.json() as Promise<TokenResponse>;
}
export async function refreshOpenAIAuth(refreshToken: string): Promise<TokenResponse> {
const response = await fetch(TOKEN_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'flynn',
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: CLIENT_ID,
}).toString(),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`OpenAI token refresh failed (${response.status}): ${body}`);
}
return response.json() as Promise<TokenResponse>;
}
/**
* Ensure we have a valid (non-expired) OpenAI OAuth access token.
* Refreshes and persists the token if needed.
*/
export async function ensureValidOpenAIAuth(): Promise<OpenAIOAuthInfo> {
const current = loadStoredOpenAIAuth();
if (!current) {
throw new Error('OpenAI OAuth is not configured. Run `flynn openai-auth` to authenticate.');
}
if (current.expires_at > Date.now() + REFRESH_SAFETY_MARGIN_MS) {
return current;
}
const refreshed = await refreshOpenAIAuth(current.refresh_token);
const expiresAt = Date.now() + (refreshed.expires_in ?? 3600) * 1000;
const accountId = extractAccountId(refreshed) ?? current.account_id;
const updated: OpenAIOAuthInfo = {
access_token: refreshed.access_token,
refresh_token: refreshed.refresh_token,
expires_at: expiresAt,
account_id: accountId,
created_at: current.created_at,
};
storeOpenAIAuth(updated);
return updated;
}
/**
* Run the OpenAI Codex device flow interactively.
* @param onPrompt Callback to display the user code and verification URL to the user.
*/
export async function loginOpenAI(
onPrompt: (userCode: string, verificationUri: string) => void,
): Promise<OpenAIOAuthInfo> {
const device = await requestDeviceAuth();
const intervalMs = Math.max(parseInt(device.interval) || 5, 1) * 1000;
onPrompt(device.user_code, DEVICE_URL);
const deviceToken = await pollDeviceToken(device.device_auth_id, device.user_code, intervalMs);
const tokens = await exchangeAuthorizationCode(deviceToken.authorization_code, deviceToken.code_verifier);
const expiresAt = Date.now() + (tokens.expires_in ?? 3600) * 1000;
const accountId = extractAccountId(tokens);
const info: OpenAIOAuthInfo = {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_at: expiresAt,
...(accountId ? { account_id: accountId } : {}),
created_at: new Date().toISOString(),
};
storeOpenAIAuth(info);
return info;
}