// The `oxygenhub` authentication provider (spec §4): a session is a hub // base URL and a personal access token, kept in SecretStorage. import * as vscode from "vscode"; import { fetchTrusting, HubClient, Session } from "./hub"; const KEY = "oxygenhub.sessions"; export class HubAuth implements vscode.AuthenticationProvider, vscode.Disposable { private readonly emitter = new vscode.EventEmitter(); readonly onDidChangeSessions = this.emitter.event; private readonly onDidChangeEmitter = new vscode.EventEmitter(); /** Fires when the set of hubs changes. */ readonly onDidChange = this.onDidChangeEmitter.event; private sessions: Session[] = []; /** CA files for hubs with self-signed certificates, learnt from remotes. */ private readonly cas = new Map(); private readonly disposables: vscode.Disposable[] = []; constructor(private readonly secrets: vscode.SecretStorage) { this.disposables.push(vscode.authentication.registerAuthenticationProvider("oxygenhub", "OxygenHub", this, { supportsMultipleAccounts: true })); } async load(): Promise { const raw = await this.secrets.get(KEY); this.sessions = raw ? (JSON.parse(raw) as Session[]) : []; } private async save(): Promise { await this.secrets.store(KEY, JSON.stringify(this.sessions)); this.onDidChangeEmitter.fire(); } all(): Session[] { return [...this.sessions]; } bases(): string[] { const configured = vscode.workspace.getConfiguration("oxygenhub").get("hubs") ?? []; return [...new Set([...this.sessions.map((s) => s.base), ...configured.map((b) => b.replace(/\/$/, ""))])]; } /** The session for a hub base URL, if signed in. */ sessionFor(base: string): Session | undefined { return this.sessions.find((s) => s.base === base.replace(/\/$/, "")); } clientFor(base: string): HubClient { const b = base.replace(/\/$/, ""); return new HubClient(b, this.sessionFor(b)?.token, this.fetchFor(b)); } /** Remember the CA file a remote names for a hub (`remote.NAME.ca`). */ setCa(base: string, caFile: string): void { this.cas.set(base.replace(/\/$/, ""), caFile); } /** How to reach a hub: trusting its CA file when one is known (from a * checkout's remote, or the `oxygenhub.caFiles` setting), else plain fetch. */ fetchFor(base: string): typeof fetch { const configured = vscode.workspace.getConfiguration("oxygenhub").get>("caFiles") ?? {}; const ca = this.cas.get(base) ?? configured[base] ?? configured[`${base}/`]; return ca ? fetchTrusting(ca) : fetch; } private toVs(s: Session): vscode.AuthenticationSession { return { id: s.base, accessToken: s.token, account: { id: `${s.username ?? "?"}@${s.base}`, label: `${s.username ?? "?"} on ${s.base}` }, scopes: [] }; } async getSessions(): Promise { return this.sessions.map((s) => this.toVs(s)); } async createSession(): Promise { const s = await this.signIn(); if (!s) { throw new Error("Sign in cancelled"); } return this.toVs(s); } async removeSession(id: string): Promise { const before = this.sessions.find((s) => s.base === id); this.sessions = this.sessions.filter((s) => s.base !== id); await this.save(); if (before) { this.emitter.fire({ added: [], removed: [this.toVs(before)], changed: [] }); } } /** Ask for a hub and a token, verify them, and keep the session. */ async signIn(defaultBase?: string): Promise { const base = await vscode.window.showInputBox({ prompt: "Hub URL", value: defaultBase ?? this.bases()[0] ?? "https://", validateInput: (v) => (/^https?:\/\/[^/]+/.test(v) ? undefined : "A URL such as https://hub.example:7343") }); if (!base) { return undefined; } const token = await vscode.window.showInputBox({ prompt: `Personal access token for ${base} (Settings › Tokens on the hub; scopes repo:read repo:write proposals:write issues:write)`, password: true }); if (!token) { return undefined; } const client = new HubClient(base.replace(/\/$/, ""), token, this.fetchFor(base.replace(/\/$/, ""))); let username: string | undefined; try { username = (await client.me()).username; } catch (e) { const pick = await vscode.window.showWarningMessage(`OxygenHub: could not read your account (${(e as Error).message}); the token may lack user:read. Keep it anyway?`, "Keep", "Cancel"); if (pick !== "Keep") { return undefined; } } const session: Session = { base: base.replace(/\/$/, ""), token, username }; this.sessions = [...this.sessions.filter((s) => s.base !== session.base), session]; await this.save(); this.emitter.fire({ added: [this.toVs(session)], removed: [], changed: [] }); return session; } dispose(): void { this.disposables.forEach((d) => d.dispose()); } }