OXYGENHUB
◆ main oxygenhub-vscode / src / auth.ts
4.9 KiB · last changed Reach self-signed hubs with the remote's CA (0.7.0) 1 month ago source blame history raw
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
}