Files
git-forge-plugin/auth.mjs
T

175 lines
7.9 KiB
JavaScript

// Account authorization for the settings panel: gh (GitHub) and tea-cli (Gitea/Forgejo).
// All credentials are stored by the CLIs themselves; this module only drives them.
import { spawn } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { ForgeError, listAccounts, publicAccount, resetAccountCache, run } from './forge.mjs'
const TEA_BINARIES = ['tea-cli', 'tea']
const TEA_MISSING = 'neither tea-cli nor tea is installed on the host'
let teaBinary // the binary that worked last (tried first). A miss is never cached, so installing one later just works.
async function which(cmd) {
const r = await run(cmd, ['--version'], { timeoutMs: 10000 })
return r.code === 0 ? (r.stdout || r.stderr).replace(/\x1b\[[0-9;]*m/g, "").trim().split("\n")[0] : null
}
/** Candidate order for tea commands: the last working binary first, then the rest of tea-cli / tea. */
const teaOrder = () => (teaBinary ? [teaBinary, ...TEA_BINARIES.filter((b) => b !== teaBinary)] : TEA_BINARIES)
/** First of tea-cli / tea that runs on this host, or null. */
async function teaBin() {
for (const b of teaOrder()) if (await which(b)) return (teaBinary = b)
return null
}
/**
* Run a tea command with whichever of tea-cli / tea exists: both names are tried, and a binary that is not
* there (ENOENT) is skipped. Resolves like `run`, plus the `bin` that ran it.
*/
async function runTea(args, opts) {
for (const bin of teaOrder()) {
const r = await run(bin, args, opts)
if (r.error?.code === 'ENOENT') continue
teaBinary = bin
return { ...r, bin }
}
throw new ForgeError(TEA_MISSING, 400)
}
export async function cliStatus() {
const gh = await which('gh')
const tb = await teaBin()
const tea = tb ? await which(tb) : null
return {
gh: { available: !!gh, version: gh ?? '' },
tea: { available: !!tea, version: tea ?? '', binary: tb ?? '' },
}
}
const done = () => { resetAccountCache() }
const fail = (r, what) => new ForgeError(`${what} failed: ${(r.stderr || r.stdout || '').trim().split('\n').slice(-3).join(' ') || `exit ${r.code}`}`, 400)
const cleanHost = (h) => String(h || 'github.com').trim().replace(/^https?:\/\//, '').replace(/\/.*$/, '') || 'github.com'
/** Add a Gitea/Forgejo login with a token (or user+password, from which tea creates a token). */
export async function giteaLogin({ name, url, token, user, password, insecure }) {
if (!url) throw new ForgeError('server URL is required', 400)
if (!token && !(user && password)) throw new ForgeError('an access token (or user and password) is required', 400)
let u
try { u = new URL(/^https?:\/\//.test(url) ? url : `https://${url}`) } catch { throw new ForgeError('invalid server URL', 400) }
const args = ['logins', 'add', '--url', u.origin + u.pathname.replace(/\/+$/, ''), '--name', name || u.hostname]
// Secrets go through the environment, not argv (visible in the process list).
const env = {}
if (token) env.GITEA_SERVER_TOKEN = token
else { env.GITEA_SERVER_USER = user; env.GITEA_SERVER_PASSWORD = password }
if (insecure) args.push('--insecure')
const r = await runTea(args, { env, timeoutMs: 60000 })
if (r.code !== 0) throw fail(r, `${r.bin} logins add`)
done()
return { ok: true, message: (r.stdout || '').trim() }
}
/** Log in to GitHub with a personal access token. */
export async function githubTokenLogin({ hostname, token }) {
if (!token) throw new ForgeError('token is required', 400)
const r = await run('gh', ['auth', 'login', '--hostname', cleanHost(hostname), '--git-protocol', 'https', '--with-token'], { input: String(token).trim() + '\n', timeoutMs: 60000 })
if (r.code !== 0) throw fail(r, 'gh auth login')
done()
return { ok: true }
}
// ---- GitHub device (web) flow ------------------------------------------------
const flows = new Map()
const FLOW_TTL = 15 * 60 * 1000
function publicFlow(f) {
return { id: f.id, host: f.host, state: f.state, code: f.code, verifyUrl: f.verifyUrl, message: f.message }
}
/** Start `gh auth login --web`; resolves once the one-time code is known. */
export function githubDeviceStart({ hostname }) {
for (const f of flows.values()) if (f.state === 'pending') { try { f.child.kill() } catch { /* gone */ } f.state = 'cancelled' }
const host = cleanHost(hostname)
const id = randomUUID()
const child = spawn('gh', ['auth', 'login', '--hostname', host, '--web', '--git-protocol', 'https', '--skip-ssh-key'], {
env: { ...process.env, BROWSER: 'true', GH_BROWSER: 'true', GH_PROMPT_DISABLED: '', NO_COLOR: '1' },
stdio: ['pipe', 'pipe', 'pipe'],
})
const flow = { id, host, child, state: 'pending', code: '', verifyUrl: '', message: '', output: '', at: Date.now() }
flows.set(id, flow)
const timer = setTimeout(() => { if (flow.state === 'pending') { flow.state = 'error'; flow.message = 'timed out'; child.kill() } }, FLOW_TTL)
return new Promise((resolve, reject) => {
let settled = false
const settle = (fn) => { if (!settled) { settled = true; fn() } }
const onData = (buf) => {
flow.output += String(buf)
const code = flow.output.match(/one-time code:\s*([A-Z0-9]{4}-[A-Z0-9]{4})/)
const url = flow.output.match(/(https?:\/\/\S+\/login\/device)/)
if (code) flow.code = code[1]
if (url) flow.verifyUrl = url[1]
if (/Press Enter/i.test(flow.output)) { try { child.stdin.write('\n') } catch { /* closed */ } }
if (flow.code) settle(() => resolve(publicFlow({ ...flow, verifyUrl: flow.verifyUrl || `https://${host}/login/device` })))
}
child.stdout.on('data', onData)
child.stderr.on('data', onData)
try { child.stdin.write('\n') } catch { /* closed */ }
child.on('error', (e) => { flow.state = 'error'; flow.message = e.message; settle(() => reject(new ForgeError(`gh: ${e.message}`, 400))) })
child.on('close', (code) => {
clearTimeout(timer)
if (flow.state === 'pending') {
flow.state = code === 0 ? 'success' : 'error'
if (code !== 0) flow.message = flow.output.trim().split('\n').slice(-2).join(' ') || `gh exited with ${code}`
}
if (flow.state === 'success') done()
settle(() => (flow.state === 'error' ? reject(new ForgeError(flow.message, 400)) : resolve(publicFlow(flow))))
})
setTimeout(() => settle(() => reject(new ForgeError('gh did not print a device code', 500))), 20000)
})
}
export function githubDeviceStatus(id) {
const f = flows.get(id)
if (!f) throw new ForgeError('unknown login flow', 404)
if (f.state !== 'pending' && Date.now() - f.at > FLOW_TTL) flows.delete(id)
return publicFlow(f)
}
export function githubDeviceCancel(id) {
const f = flows.get(id)
if (f && f.state === 'pending') { f.state = 'cancelled'; try { f.child.kill() } catch { /* gone */ } }
return { ok: true }
}
// ---- logout / listing --------------------------------------------------------
export async function logout(accountId) {
const accounts = await listAccounts({ refresh: true })
const a = accounts.find((x) => x.id === accountId)
if (!a) throw new ForgeError(`unknown account ${accountId}`, 404)
let r
if (a.kind === 'github') r = await run('gh', ['auth', 'logout', '--hostname', a.host, '--user', a.user], { timeoutMs: 30000 })
else r = await runTea(['logins', 'delete', a.name], { timeoutMs: 30000 })
if (r.code !== 0) throw fail(r, 'logout')
done()
return { ok: true }
}
/** Accounts plus a live check of each token (who the API says we are). */
export async function accountsWithStatus({ verify = false } = {}) {
const accounts = await listAccounts({ refresh: true })
const { api } = await import('./forge.mjs')
return Promise.all(accounts.map(async (a) => {
const out = { ...publicAccount(a), active: a.active !== false, status: 'unknown', login: '' }
if (!verify) return out
try {
const me = await api(a, 'GET', '/user', { signal: AbortSignal.timeout(10000) })
out.status = 'ok'
out.login = me?.login ?? me?.username ?? ''
} catch (e) {
out.status = 'error'
out.error = String(e?.message ?? e)
}
return out
}))
}