-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathauthenticationProvider.ts
More file actions
370 lines (331 loc) · 13.4 KB
/
authenticationProvider.ts
File metadata and controls
370 lines (331 loc) · 13.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
import {
authentication,
AuthenticationProvider,
AuthenticationProviderAuthenticationSessionsChangeEvent,
AuthenticationProviderSessionOptions,
AuthenticationSession,
Disposable,
Event,
EventEmitter,
extensions,
SecretStorage,
ThemeIcon,
window,
workspace,
} from "vscode";
import { ServerManagerAuthenticationSession } from "./authenticationSession";
import { globalState } from "./commonActivate";
import { getServerSpec } from "./api/getServerSpec";
import { logout, makeRESTRequest } from "./makeRESTRequest";
export const AUTHENTICATION_PROVIDER = "intersystems-server-credentials";
const AUTHENTICATION_PROVIDER_LABEL = "InterSystems Server Credentials";
export class ServerManagerAuthenticationProvider implements AuthenticationProvider, Disposable {
public static id = AUTHENTICATION_PROVIDER;
public static label = AUTHENTICATION_PROVIDER_LABEL;
public static secretKeyPrefix = "credentialProvider:";
public static sessionId(serverName: string, userName: string): string {
const canonicalUserName = userName.toLowerCase();
return `${serverName}/${canonicalUserName}`;
}
public static credentialKey(sessionId: string): string {
return `${ServerManagerAuthenticationProvider.secretKeyPrefix}${sessionId}`;
}
private _initializedDisposable: Disposable | undefined;
private readonly _secretStorage;
private _sessions: ServerManagerAuthenticationSession[] = [];
private _checkedSessions: ServerManagerAuthenticationSession[] = [];
private _serverManagerExtension = extensions.getExtension("intersystems-community.servermanager");
private _onDidChangeSessions = new EventEmitter<AuthenticationProviderAuthenticationSessionsChangeEvent>();
get onDidChangeSessions(): Event<AuthenticationProviderAuthenticationSessionsChangeEvent> {
return this._onDidChangeSessions.event;
}
constructor(private readonly secretStorage: SecretStorage) {
this._secretStorage = secretStorage;
}
public dispose(): void {
this._initializedDisposable?.dispose();
}
// This function is called first when `vscode.authentication.getSession` is called.
public async getSessions(scopes: readonly string[] = [], options: AuthenticationProviderSessionOptions): Promise<AuthenticationSession[]> {
await this._ensureInitialized();
let sessions = this._sessions;
// Filter to return only those that match all supplied scopes, which are positional and case-insensitive.
for (let index = 0; index < scopes.length; index++) {
sessions = sessions.filter((session) => session.scopes[index] === scopes[index].toLowerCase());
}
if (options.account) {
const accountParts = options.account.id.split("/");
const serverName = accountParts.shift();
const userName = accountParts.join('/').toLowerCase();
if (serverName && userName) {
sessions = sessions.filter((session) => session.scopes[0] === serverName && session.scopes[1] === userName);
}
}
if (sessions.length === 1) {
if (!(await this._isStillValid(sessions[0]))) {
sessions = [];
}
}
return sessions || [];
}
// This function is called after `this.getSessions` is called, and only when:
// - `this.getSessions` returns nothing but `createIfNone` was `true` in call to `vscode.authentication.getSession`
// - `vscode.authentication.getSession` was called with `forceNewSession: true` or
// `forceNewSession: {detail: "Reason message for modal dialog"}` (proposed API since 1.59, finalized in 1.63)
// - The end user initiates the "silent" auth flow via the Accounts menu
public async createSession(scopes: string[]): Promise<AuthenticationSession> {
await this._ensureInitialized();
let serverName = scopes[0] ?? "";
if (!serverName) {
// Prompt for the server name.
if (!this._serverManagerExtension) {
throw new Error(`InterSystems Server Manager extension is not available to provide server selection for ${AUTHENTICATION_PROVIDER_LABEL}.`);
}
if (!this._serverManagerExtension.isActive) {
await this._serverManagerExtension.activate();
}
serverName = await this._serverManagerExtension.exports.pickServer() ?? "";
if (!serverName) {
throw new Error(`${AUTHENTICATION_PROVIDER_LABEL}: Server name is required.`);
}
}
let userName = scopes[1] ?? "";
if (!userName) {
// Prompt for the username.
const enteredUserName = await window.showInputBox({
ignoreFocusOut: true,
placeHolder: `Username on server '${serverName}'`,
prompt: "Enter the username to access the InterSystems server with. Leave blank for unauthenticated access as 'UnknownUser'.",
title: `${AUTHENTICATION_PROVIDER_LABEL}: Username on InterSystems server '${serverName}'`,
});
if (typeof enteredUserName === "undefined") {
throw new Error(`${AUTHENTICATION_PROVIDER_LABEL}: Username is required.`);
}
userName = enteredUserName === "" ? "UnknownUser" : enteredUserName;
}
// Return existing session if found
const sessionId = ServerManagerAuthenticationProvider.sessionId(serverName, userName);
let existingSession = this._sessions.find((s) => s.id === sessionId);
if (existingSession) {
if (this._checkedSessions.find((s) => s.id === sessionId)) {
return existingSession;
}
// Check if the session is still valid
if (await this._isStillValid(existingSession)) {
this._checkedSessions.push(existingSession);
return existingSession;
}
}
let password: string | undefined = "";
if (userName !== "UnknownUser") {
// Seek password in secret storage
const credentialKey = ServerManagerAuthenticationProvider.credentialKey(sessionId);
password = await this.secretStorage.get(credentialKey);
if (!password) {
// Prompt for password
const doInputBox = async (): Promise<string | undefined> => {
return await new Promise<string | undefined>((resolve, reject) => {
const inputBox = window.createInputBox();
inputBox.value = "";
inputBox.password = true;
inputBox.title = `${AUTHENTICATION_PROVIDER_LABEL}: Password for user '${userName}'`;
inputBox.placeholder = `Password for user '${userName}' on '${serverName}'`;
inputBox.prompt = "Optionally use $(key) button above to store password";
inputBox.ignoreFocusOut = true;
inputBox.buttons = [
{
iconPath: new ThemeIcon("key"),
tooltip: "Store Password Securely in Workstation Keychain",
},
];
async function done(secretStorage?: SecretStorage) {
// Return the password, having stored it if storage was passed
const enteredPassword = inputBox.value;
if (secretStorage && enteredPassword) {
await secretStorage.store(credentialKey, enteredPassword);
console.log(`Stored password at ${credentialKey}`);
}
// Resolve the promise and tidy up
resolve(enteredPassword);
inputBox.dispose();
}
inputBox.onDidTriggerButton((_button) => {
// We only added the one button, which stores the password
done(this.secretStorage);
});
inputBox.onDidAccept(() => {
// User pressed Enter
done();
});
inputBox.onDidHide(() => {
// User pressed Escape
resolve(undefined);
inputBox.dispose();
});
inputBox.show();
});
};
password = await doInputBox();
if (!password) {
throw new Error(`${AUTHENTICATION_PROVIDER_LABEL}: Password is required.`);
}
}
}
// We have all we need to create the session object
const session = new ServerManagerAuthenticationSession(serverName, userName, password);
// Update this._sessions and raise the event to notify
const added: AuthenticationSession[] = [];
const changed: AuthenticationSession[] = [];
const index = this._sessions.findIndex((item) => item.id === session.id);
if (index !== -1) {
this._sessions[index] = session;
changed.push(session);
} else {
// No point re-sorting here because onDidChangeSessions always appends added items to the provider's entries in the Accounts menu
this._sessions.push(session);
added.push(session);
}
await this._storeStrippedSessions();
this._onDidChangeSessions.fire({ added, removed: [], changed });
return session;
}
private async _isStillValid(session: ServerManagerAuthenticationSession): Promise<boolean> {
if (this._checkedSessions.find((s) => s.id === session.id)) {
return true;
}
const serverSpec = await getServerSpec(session.serverName);
if (serverSpec) {
serverSpec.username = session.userName;
serverSpec.password = session.accessToken;
const response = await makeRESTRequest("HEAD", serverSpec);
if (response?.status === 401) {
await this._removeSession(session.id, true);
return false;
}
// Immediately log out the session created by credentials test
await logout(session.serverName);
}
this._checkedSessions.push(session);
return true;
}
// This function is called when the end user signs out of the account.
public async removeSession(sessionId: string): Promise<void> {
this._removeSession(sessionId);
}
private async _removeSession(sessionId: string, alwaysDeletePassword = false): Promise<void> {
const index = this._sessions.findIndex((item) => item.id === sessionId);
const session = this._sessions[index];
const credentialKey = ServerManagerAuthenticationProvider.credentialKey(sessionId);
let deletePassword = false;
const hasStoredPassword = await this.secretStorage.get(credentialKey) !== undefined;
if (alwaysDeletePassword) {
deletePassword = hasStoredPassword;
} else {
if (hasStoredPassword) {
const passwordOption = workspace.getConfiguration("intersystemsServerManager.credentialsProvider")
.get<string>("deletePasswordOnSignout", "ask");
deletePassword = (passwordOption === "always");
if (passwordOption === "ask") {
const choice = await window.showWarningMessage(
`Do you want to keep the password or delete it?`,
{ detail: `The ${AUTHENTICATION_PROVIDER_LABEL} account you signed out (${session.account.label}) is currently storing its password securely on your workstation.`, modal: true },
{ title: "Keep", isCloseAffordance: true },
{ title: "Delete", isCloseAffordance: false },
);
deletePassword = (choice?.title === "Delete");
}
}
}
if (deletePassword) {
// Delete from secret storage
await this.secretStorage.delete(credentialKey);
console.log(`${AUTHENTICATION_PROVIDER_LABEL}: Deleted password at ${credentialKey}`);
}
if (index > -1) {
// Remove session here so we don't store it
this._sessions.splice(index, 1);
}
await this._storeStrippedSessions();
this._onDidChangeSessions.fire({ added: [], removed: [session], changed: [] });
}
private async _ensureInitialized(): Promise<void> {
if (this._initializedDisposable === undefined) {
// Get the previously-persisted array of sessions that were stripped of their accessTokens (aka passwords)
await this._reloadSessions();
this._initializedDisposable = Disposable.from(
// This onDidChange event happens when the secret storage changes in _any window_ since
// secrets are shared across all open windows.
this.secretStorage.onDidChange(async (e) => {
for (const session of this._sessions) {
const credentialKey = ServerManagerAuthenticationProvider.credentialKey(session.id);
if (credentialKey === e.key) {
const password = await this.secretStorage.get(credentialKey);
// Only look up the session in _sessions after the await for password has completed,
// in case _sessions has been changed elsewhere in the meantime
const index = this._sessions.findIndex((sess) => sess.id === session.id);
if (index > -1) {
if (!password) {
this._sessions.splice(index, 1);
} else {
this._sessions[index] = new ServerManagerAuthenticationSession(
session.serverName,
session.userName,
password,
);
}
}
}
}
}),
// This fires when the user initiates a "silent" auth flow via the Accounts menu.
authentication.onDidChangeSessions(async (e) => {
if (e.provider.id === ServerManagerAuthenticationProvider.id) {
// TODO what, of anything?
}
}),
);
}
}
private async _reloadSessions() {
const strippedSessions = globalState.get<ServerManagerAuthenticationSession[]>(
"authenticationProvider.strippedSessions",
[],
);
// Build our array of sessions for which non-empty accessTokens were securely persisted
this._sessions = (await Promise.all(
strippedSessions.map(async (session) => {
const credentialKey = ServerManagerAuthenticationProvider.credentialKey(session.id);
const accessToken = await this._secretStorage.get(credentialKey);
return new ServerManagerAuthenticationSession(session.serverName, session.userName, accessToken);
}),
)).filter((session) => session.accessToken).sort((a, b) => {
const aUserNameLowercase = a.userName.toLowerCase();
const bUserNameLowercase = b.userName.toLowerCase();
if (aUserNameLowercase < bUserNameLowercase) {
return -1;
}
if (aUserNameLowercase > bUserNameLowercase) {
return 1;
}
if (a.serverName < b.serverName) {
return -1;
}
return 1;
});
}
private async _storeStrippedSessions() {
// Build an array of sessions with passwords blanked
const strippedSessions = this._sessions.map((session) => {
return new ServerManagerAuthenticationSession(
session.serverName,
session.userName,
"",
);
});
// Persist it
await globalState.update(
"authenticationProvider.strippedSessions",
strippedSessions,
);
}
}