| 1 |
// The `oxygenhub` authentication provider (spec §4): a session is a hub |
| 2 |
// base URL and a personal access token, kept in SecretStorage. |
| 3 |
|
| 4 |
import * as vscode from "vscode"; |
| 5 |
import { fetchTrusting, HubClient, Session } from "./hub"; |
| 6 |
|
| 7 |
const KEY = "oxygenhub.sessions"; |
| 8 |
|
| 9 |
export class HubAuth implements vscode.AuthenticationProvider, vscode.Disposable { |
| 10 |
private readonly emitter = new vscode.EventEmitter<vscode.AuthenticationProviderAuthenticationSessionsChangeEvent>(); |
| 11 |
readonly onDidChangeSessions = this.emitter.event; |
| 12 |
private readonly onDidChangeEmitter = new vscode.EventEmitter<void>(); |
| 13 |
/** Fires when the set of hubs changes. */ |
| 14 |
readonly onDidChange = this.onDidChangeEmitter.event; |
| 15 |
private sessions: Session[] = []; |
| 16 |
/** CA files for hubs with self-signed certificates, learnt from remotes. */ |
| 17 |
private readonly cas = new Map<string, string>(); |
| 18 |
private readonly disposables: vscode.Disposable[] = []; |
| 19 |
|
| 20 |
constructor(private readonly secrets: vscode.SecretStorage) { |
| 21 |
this.disposables.push(vscode.authentication.registerAuthenticationProvider("oxygenhub", "OxygenHub", this, { supportsMultipleAccounts: true })); |
| 22 |
} |
| 23 |
|
| 24 |
async load(): Promise<void> { |
| 25 |
const raw = await this.secrets.get(KEY); |
| 26 |
this.sessions = raw ? (JSON.parse(raw) as Session[]) : []; |
| 27 |
} |
| 28 |
|
| 29 |
private async save(): Promise<void> { |
| 30 |
await this.secrets.store(KEY, JSON.stringify(this.sessions)); |
| 31 |
this.onDidChangeEmitter.fire(); |
| 32 |
} |
| 33 |
|
| 34 |
all(): Session[] { |
| 35 |
return [...this.sessions]; |
| 36 |
} |
| 37 |
|
| 38 |
bases(): string[] { |
| 39 |
const configured = vscode.workspace.getConfiguration("oxygenhub").get<string[]>("hubs") ?? []; |
| 40 |
return [...new Set([...this.sessions.map((s) => s.base), ...configured.map((b) => b.replace(/\/$/, ""))])]; |
| 41 |
} |
| 42 |
|
| 43 |
/** The session for a hub base URL, if signed in. */ |
| 44 |
sessionFor(base: string): Session | undefined { |
| 45 |
return this.sessions.find((s) => s.base === base.replace(/\/$/, "")); |
| 46 |
} |
| 47 |
|
| 48 |
clientFor(base: string): HubClient { |
| 49 |
const b = base.replace(/\/$/, ""); |
| 50 |
return new HubClient(b, this.sessionFor(b)?.token, this.fetchFor(b)); |
| 51 |
} |
| 52 |
|
| 53 |
/** Remember the CA file a remote names for a hub (`remote.NAME.ca`). */ |
| 54 |
setCa(base: string, caFile: string): void { |
| 55 |
this.cas.set(base.replace(/\/$/, ""), caFile); |
| 56 |
} |
| 57 |
|
| 58 |
/** How to reach a hub: trusting its CA file when one is known (from a |
| 59 |
* checkout's remote, or the `oxygenhub.caFiles` setting), else plain fetch. */ |
| 60 |
fetchFor(base: string): typeof fetch { |
| 61 |
const configured = vscode.workspace.getConfiguration("oxygenhub").get<Record<string, string>>("caFiles") ?? {}; |
| 62 |
const ca = this.cas.get(base) ?? configured[base] ?? configured[`${base}/`]; |
| 63 |
return ca ? fetchTrusting(ca) : fetch; |
| 64 |
} |
| 65 |
|
| 66 |
private toVs(s: Session): vscode.AuthenticationSession { |
| 67 |
return { id: s.base, accessToken: s.token, account: { id: `${s.username ?? "?"}@${s.base}`, label: `${s.username ?? "?"} on ${s.base}` }, scopes: [] }; |
| 68 |
} |
| 69 |
|
| 70 |
async getSessions(): Promise<vscode.AuthenticationSession[]> { |
| 71 |
return this.sessions.map((s) => this.toVs(s)); |
| 72 |
} |
| 73 |
|
| 74 |
async createSession(): Promise<vscode.AuthenticationSession> { |
| 75 |
const s = await this.signIn(); |
| 76 |
if (!s) { |
| 77 |
throw new Error("Sign in cancelled"); |
| 78 |
} |
| 79 |
return this.toVs(s); |
| 80 |
} |
| 81 |
|
| 82 |
async removeSession(id: string): Promise<void> { |
| 83 |
const before = this.sessions.find((s) => s.base === id); |
| 84 |
this.sessions = this.sessions.filter((s) => s.base !== id); |
| 85 |
await this.save(); |
| 86 |
if (before) { |
| 87 |
this.emitter.fire({ added: [], removed: [this.toVs(before)], changed: [] }); |
| 88 |
} |
| 89 |
} |
| 90 |
|
| 91 |
/** Ask for a hub and a token, verify them, and keep the session. */ |
| 92 |
async signIn(defaultBase?: string): Promise<Session | undefined> { |
| 93 |
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") }); |
| 94 |
if (!base) { |
| 95 |
return undefined; |
| 96 |
} |
| 97 |
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 }); |
| 98 |
if (!token) { |
| 99 |
return undefined; |
| 100 |
} |
| 101 |
const client = new HubClient(base.replace(/\/$/, ""), token, this.fetchFor(base.replace(/\/$/, ""))); |
| 102 |
let username: string | undefined; |
| 103 |
try { |
| 104 |
username = (await client.me()).username; |
| 105 |
} catch (e) { |
| 106 |
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"); |
| 107 |
if (pick !== "Keep") { |
| 108 |
return undefined; |
| 109 |
} |
| 110 |
} |
| 111 |
const session: Session = { base: base.replace(/\/$/, ""), token, username }; |
| 112 |
this.sessions = [...this.sessions.filter((s) => s.base !== session.base), session]; |
| 113 |
await this.save(); |
| 114 |
this.emitter.fire({ added: [this.toVs(session)], removed: [], changed: [] }); |
| 115 |
return session; |
| 116 |
} |
| 117 |
|
| 118 |
dispose(): void { |
| 119 |
this.disposables.forEach((d) => d.dispose()); |
| 120 |
} |
| 121 |
} |