f0b06dea Andy McCall 1 month ago
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";
58889495 Andy McCall 1 month ago ↶
5
import { fetchTrusting, HubClient, Session } from "./hub";
f0b06dea Andy McCall 1 month ago
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[] = [];
58889495 Andy McCall 1 month ago ↶
16
/** CA files for hubs with self-signed certificates, learnt from remotes. */
17
private readonly cas = new Map<string, string>();
f0b06dea Andy McCall 1 month ago
18
private readonly disposables: vscode.Disposable[] = [];
19
20
constructor(private readonly secrets: vscode.SecretStorage) {
092b8bf9 Andy McCall 1 month ago ↶
21
this.disposables.push(vscode.authentication.registerAuthenticationProvider("oxygenhub", "OxygenHub", this, { supportsMultipleAccounts: true }));
f0b06dea Andy McCall 1 month ago
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 {
58889495 Andy McCall 1 month ago ↶
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);
f0b06dea Andy McCall 1 month ago
56
}
57
58889495 Andy McCall 1 month ago ↶
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
f0b06dea Andy McCall 1 month ago
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
}
58889495 Andy McCall 1 month ago ↶
101
const client = new HubClient(base.replace(/\/$/, ""), token, this.fetchFor(base.replace(/\/$/, "")));
f0b06dea Andy McCall 1 month ago
102
let username: string | undefined;
103
try {
104
username = (await client.me()).username;
105
} catch (e) {
092b8bf9 Andy McCall 1 month ago ↶
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");
f0b06dea Andy McCall 1 month ago
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
}