import { createHash, randomBytes } from 'node:crypto'; import { createServer } from 'node:http'; import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; import { execSync } from 'node:child_process'; import { platform } from 'node:os'; const CLERK_ISSUER = 'https://clerk.alphaxiv.org'; const AUTH_ENDPOINT = `${CLERK_ISSUER}/oauth/authorize`; const TOKEN_ENDPOINT = `${CLERK_ISSUER}/oauth/token`; const REGISTER_ENDPOINT = `${CLERK_ISSUER}/oauth/register`; const CALLBACK_PORT = 9876; const REDIRECT_URI = `http://127.0.0.1:${CALLBACK_PORT}/callback`; const USERINFO_ENDPOINT = `${CLERK_ISSUER}/oauth/userinfo`; const SCOPES = 'profile email offline_access'; function getAuthPath() { const dir = join(homedir(), '.ahub'); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); return join(dir, 'auth.json'); } function loadAuth() { try { return JSON.parse(readFileSync(getAuthPath(), 'utf8')); } catch { return null; } } function saveAuth(data) { writeFileSync(getAuthPath(), JSON.stringify(data, null, 2), 'utf8'); } export function getAccessToken() { const auth = loadAuth(); if (!auth?.access_token) return null; return auth.access_token; } export function getUserId() { const auth = loadAuth(); return auth?.user_id || null; } export function getUserName() { const auth = loadAuth(); return auth?.user_name || null; } async function fetchUserInfo(accessToken) { const res = await fetch(USERINFO_ENDPOINT, { headers: { Authorization: `Bearer ${accessToken}` }, }); if (!res.ok) return null; return await res.json(); } async function registerClient() { const res = await fetch(REGISTER_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ client_name: 'Alpha Hub CLI', redirect_uris: [REDIRECT_URI], grant_types: ['authorization_code'], response_types: ['code'], token_endpoint_auth_method: 'none', }), }); if (!res.ok) throw new Error(`Client registration failed: ${res.status}`); return await res.json(); } function generatePKCE() { const verifier = randomBytes(32).toString('base64url'); const challenge = createHash('sha256').update(verifier).digest('base64url'); return { verifier, challenge }; } function openBrowser(url) { try { const plat = platform(); if (plat === 'darwin') execSync(`open "${url}"`); else if (plat === 'linux') execSync(`xdg-open "${url}"`); else if (plat === 'win32') execSync(`start "" "${url}"`); } catch {} } const SUCCESS_HTML = ` alphaXiv

Logged in to alphaXiv

You can close this tab

`; const ERROR_HTML = ` alphaXiv

Login failed

You can close this tab and try again

`; function startCallbackServer() { return new Promise((resolve, reject) => { const server = createServer(); server.on('error', (err) => { if (err.code === 'EADDRINUSE') { reject(new Error(`Port ${CALLBACK_PORT} is already in use. Close the process using it and try again.`)); } else { reject(err); } }); server.listen(CALLBACK_PORT, '127.0.0.1', () => { resolve(server); }); }); } function waitForCallback(server) { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { server.close(); reject(new Error('Login timed out after 120 seconds')); }, 120000); server.on('request', (req, res) => { const url = new URL(req.url, `http://127.0.0.1:${CALLBACK_PORT}`); if (url.pathname !== '/callback') { res.writeHead(404); res.end(); return; } const code = url.searchParams.get('code'); const error = url.searchParams.get('error'); if (error) { res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(ERROR_HTML); clearTimeout(timeout); server.close(); reject(new Error(`OAuth error: ${error}`)); return; } if (code) { res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(SUCCESS_HTML); clearTimeout(timeout); server.close(); resolve(code); } }); }); } async function exchangeCode(code, clientId, codeVerifier) { const body = new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: REDIRECT_URI, client_id: clientId, code_verifier: codeVerifier, }); const res = await fetch(TOKEN_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString(), }); if (!res.ok) { const text = await res.text(); throw new Error(`Token exchange failed (${res.status}): ${text}`); } return await res.json(); } export async function refreshAccessToken() { const auth = loadAuth(); if (!auth?.refresh_token || !auth?.client_id) return null; const body = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: auth.refresh_token, client_id: auth.client_id, }); const res = await fetch(TOKEN_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString(), }); if (!res.ok) return null; const tokens = await res.json(); saveAuth({ ...auth, access_token: tokens.access_token, refresh_token: tokens.refresh_token || auth.refresh_token, expires_at: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : auth.expires_at, }); return tokens.access_token; } export async function login() { const registration = await registerClient(); const clientId = registration.client_id; const { verifier, challenge } = generatePKCE(); const state = randomBytes(16).toString('hex'); const authUrl = new URL(AUTH_ENDPOINT); authUrl.searchParams.set('client_id', clientId); authUrl.searchParams.set('redirect_uri', REDIRECT_URI); authUrl.searchParams.set('response_type', 'code'); authUrl.searchParams.set('scope', SCOPES); authUrl.searchParams.set('code_challenge', challenge); authUrl.searchParams.set('code_challenge_method', 'S256'); authUrl.searchParams.set('state', state); const server = await startCallbackServer(); process.stderr.write('Opening browser for alphaXiv login...\n'); openBrowser(authUrl.toString()); process.stderr.write(`If browser didn't open, visit:\n${authUrl.toString()}\n\n`); process.stderr.write('Waiting for login...\n'); const code = await waitForCallback(server); const tokens = await exchangeCode(code, clientId, verifier); const userInfo = await fetchUserInfo(tokens.access_token); saveAuth({ client_id: clientId, access_token: tokens.access_token, refresh_token: tokens.refresh_token, expires_at: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : null, user_id: userInfo?.sub || null, user_name: userInfo?.name || userInfo?.preferred_username || null, user_email: userInfo?.email || null, }); return { tokens, userInfo }; } export async function getValidToken() { let token = getAccessToken(); if (token) { const auth = loadAuth(); if (auth?.expires_at && Date.now() > auth.expires_at - 60000) { token = await refreshAccessToken(); } if (token) return token; } return null; } export function isLoggedIn() { return !!getAccessToken(); } export function logout() { try { writeFileSync(getAuthPath(), '{}', 'utf8'); } catch { } }