Files
flynn/src/auth/github.ts
T
William Valentin 6090508bad style: auto-fix ESLint issues (curly braces and formatting)
- Add curly braces to all if/else/for/while statements
- Fix indentation and trailing spaces
- Auto-fixed 372 linting errors using eslint --fix
- Remaining issues are warnings only (non-null assertions, explicit any types)
2026-02-11 10:30:24 -08:00

165 lines
4.5 KiB
TypeScript

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<DeviceCodeResponse> {
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<DeviceCodeResponse>;
}
/**
* 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<string> {
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<string, unknown>;
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<string> {
const deviceCode = await requestDeviceCode();
onPrompt(deviceCode.user_code, deviceCode.verification_uri);
const token = await pollForToken(deviceCode.device_code, deviceCode.interval);
storeToken(token);
return token;
}