import { readFileSync, writeFileSync, mkdirSync, chmodSync } from 'fs'; import { resolve } from 'path'; import { homedir } from 'os'; const COPILOT_CLIENT_ID = 'Ov23li8tweQw6odWQebz'; const DEVICE_CODE_URL = 'https://github.com/login/device/code'; const TOKEN_URL = 'https://github.com/login/oauth/access_token'; const POLLING_SAFETY_MARGIN_MS = 3000; const AUTH_DIR = resolve(homedir(), '.config/flynn'); const AUTH_FILE = resolve(AUTH_DIR, 'auth.json'); export interface DeviceCodeResponse { device_code: string; user_code: string; verification_uri: string; expires_in: number; interval: number; } interface AuthStore { github?: { access_token: string; created_at: string; }; } /** * Request a device code from GitHub to start the OAuth device flow. */ export async function requestDeviceCode(): Promise { const response = await fetch(DEVICE_CODE_URL, { method: 'POST', headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', }, body: JSON.stringify({ client_id: COPILOT_CLIENT_ID }), }); if (!response.ok) { throw new Error(`Failed to request device code: ${response.status} ${response.statusText}`); } return response.json() as Promise; } /** * Poll GitHub for an access token after the user has entered the device code. * Blocks until the user authorizes or the code expires. */ export async function pollForToken(deviceCode: string, interval: number): Promise { let currentInterval = interval; while (true) { await new Promise(r => setTimeout(r, currentInterval * 1000 + POLLING_SAFETY_MARGIN_MS)); const response = await fetch(TOKEN_URL, { method: 'POST', headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', }, body: JSON.stringify({ client_id: COPILOT_CLIENT_ID, device_code: deviceCode, grant_type: 'urn:ietf:params:oauth:grant-type:device_code', }), }); const data = await response.json() as Record; if (data.access_token) { return data.access_token as string; } if (data.error === 'authorization_pending') { continue; } if (data.error === 'slow_down') { // Add 5 seconds as per GitHub spec currentInterval = (data.interval as number) ?? currentInterval + 5; continue; } if (data.error === 'expired_token') { throw new Error('Device code expired. Please try again.'); } if (data.error === 'access_denied') { throw new Error('Authorization was denied by the user.'); } throw new Error(`OAuth error: ${data.error ?? 'unknown'} - ${data.error_description ?? ''}`); } } /** * Load a previously stored GitHub OAuth token from disk. * Returns null if no token is stored or the file doesn't exist. */ export function loadStoredToken(): string | null { try { const raw = readFileSync(AUTH_FILE, 'utf-8'); const store = JSON.parse(raw) as AuthStore; return store.github?.access_token ?? null; } catch { return null; } } /** * Store a GitHub OAuth token to disk with secure permissions. */ export function storeToken(token: string): void { mkdirSync(AUTH_DIR, { recursive: true }); let store: AuthStore = {}; try { const raw = readFileSync(AUTH_FILE, 'utf-8'); store = JSON.parse(raw) as AuthStore; } catch { // File doesn't exist yet — start fresh } store.github = { access_token: token, created_at: new Date().toISOString(), }; writeFileSync(AUTH_FILE, JSON.stringify(store, null, 2) + '\n', 'utf-8'); chmodSync(AUTH_FILE, 0o600); } /** * Get a GitHub token from any available source. * Priority: GITHUB_TOKEN env var → stored OAuth token → null */ export function getGitHubToken(): string | null { // 1. Environment variable const envToken = process.env.GITHUB_TOKEN; if (envToken) {return envToken;} // 2. Stored OAuth token return loadStoredToken(); } /** * Run the full GitHub OAuth device flow interactively. * @param onPrompt Callback to display the user code and verification URL to the user. * @returns The access token. */ export async function loginGitHub( onPrompt: (userCode: string, verificationUri: string) => void, ): Promise { const deviceCode = await requestDeviceCode(); onPrompt(deviceCode.user_code, deviceCode.verification_uri); const token = await pollForToken(deviceCode.device_code, deviceCode.interval); storeToken(token); return token; }