-
Notifications
You must be signed in to change notification settings - Fork 28k
/
github.ts
383 lines (328 loc) · 13.6 KB
/
github.ts
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
371
372
373
374
375
376
377
378
379
380
381
382
383
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import TelemetryReporter from '@vscode/extension-telemetry';
import { Keychain } from './common/keychain';
import { GitHubServer, IGitHubServer } from './githubServer';
import { PromiseAdapter, arrayEquals, promiseFromEvent } from './common/utils';
import { ExperimentationTelemetry } from './common/experimentationService';
import { Log } from './common/logger';
import { crypto } from './node/crypto';
import { TIMED_OUT_ERROR, USER_CANCELLATION_ERROR } from './common/errors';
interface SessionData {
id: string;
account?: {
label?: string;
displayName?: string;
id: string;
};
scopes: string[];
accessToken: string;
}
export enum AuthProviderType {
github = 'github',
githubEnterprise = 'github-enterprise'
}
export class UriEventHandler extends vscode.EventEmitter<vscode.Uri> implements vscode.UriHandler {
private readonly _pendingNonces = new Map<string, string[]>();
private readonly _codeExchangePromises = new Map<string, { promise: Promise<string>; cancel: vscode.EventEmitter<void> }>();
public handleUri(uri: vscode.Uri) {
this.fire(uri);
}
public async waitForCode(logger: Log, scopes: string, nonce: string, token: vscode.CancellationToken) {
const existingNonces = this._pendingNonces.get(scopes) || [];
this._pendingNonces.set(scopes, [...existingNonces, nonce]);
let codeExchangePromise = this._codeExchangePromises.get(scopes);
if (!codeExchangePromise) {
codeExchangePromise = promiseFromEvent(this.event, this.handleEvent(logger, scopes));
this._codeExchangePromises.set(scopes, codeExchangePromise);
}
try {
return await Promise.race([
codeExchangePromise.promise,
new Promise<string>((_, reject) => setTimeout(() => reject(TIMED_OUT_ERROR), 300_000)), // 5min timeout
promiseFromEvent<void, string>(token.onCancellationRequested, (_, __, reject) => { reject(USER_CANCELLATION_ERROR); }).promise
]);
} finally {
this._pendingNonces.delete(scopes);
codeExchangePromise?.cancel.fire();
this._codeExchangePromises.delete(scopes);
}
}
private handleEvent: (logger: Log, scopes: string) => PromiseAdapter<vscode.Uri, string> =
(logger: Log, scopes) => (uri, resolve, reject) => {
const query = new URLSearchParams(uri.query);
const code = query.get('code');
const nonce = query.get('nonce');
if (!code) {
reject(new Error('No code'));
return;
}
if (!nonce) {
reject(new Error('No nonce'));
return;
}
const acceptedNonces = this._pendingNonces.get(scopes) || [];
if (!acceptedNonces.includes(nonce)) {
// A common scenario of this happening is if you:
// 1. Trigger a sign in with one set of scopes
// 2. Before finishing 1, you trigger a sign in with a different set of scopes
// In this scenario we should just return and wait for the next UriHandler event
// to run as we are probably still waiting on the user to hit 'Continue'
logger.info('Nonce not found in accepted nonces. Skipping this execution...');
return;
}
resolve(code);
};
}
export class GitHubAuthenticationProvider implements vscode.AuthenticationProvider, vscode.Disposable {
private readonly _sessionChangeEmitter = new vscode.EventEmitter<vscode.AuthenticationProviderAuthenticationSessionsChangeEvent>();
private readonly _logger: Log;
private readonly _githubServer: IGitHubServer;
private readonly _telemetryReporter: ExperimentationTelemetry;
private readonly _keychain: Keychain;
private readonly _accountsSeen = new Set<string>();
private readonly _disposable: vscode.Disposable | undefined;
private _sessionsPromise: Promise<vscode.AuthenticationSession[]>;
constructor(
private readonly context: vscode.ExtensionContext,
uriHandler: UriEventHandler,
ghesUri?: vscode.Uri
) {
const { aiKey } = context.extension.packageJSON as { name: string; version: string; aiKey: string };
this._telemetryReporter = new ExperimentationTelemetry(context, new TelemetryReporter(aiKey));
const type = ghesUri ? AuthProviderType.githubEnterprise : AuthProviderType.github;
this._logger = new Log(type);
this._keychain = new Keychain(
this.context,
type === AuthProviderType.github
? `${type}.auth`
: `${ghesUri?.authority}${ghesUri?.path}.ghes.auth`,
this._logger);
this._githubServer = new GitHubServer(
this._logger,
this._telemetryReporter,
uriHandler,
context.extension.extensionKind,
ghesUri);
// Contains the current state of the sessions we have available.
this._sessionsPromise = this.readSessions().then((sessions) => {
// fire telemetry after a second to allow the workbench to focus on loading
setTimeout(() => sessions.forEach(s => this.afterSessionLoad(s)), 1000);
return sessions;
});
this._disposable = vscode.Disposable.from(
this._telemetryReporter,
vscode.authentication.registerAuthenticationProvider(type, this._githubServer.friendlyName, this, { supportsMultipleAccounts: false }),
this.context.secrets.onDidChange(() => this.checkForUpdates())
);
}
dispose() {
this._disposable?.dispose();
}
get onDidChangeSessions() {
return this._sessionChangeEmitter.event;
}
async getSessions(scopes?: string[]): Promise<vscode.AuthenticationSession[]> {
// For GitHub scope list, order doesn't matter so we immediately sort the scopes
const sortedScopes = scopes?.sort() || [];
this._logger.info(`Getting sessions for ${sortedScopes.length ? sortedScopes.join(',') : 'all scopes'}...`);
const sessions = await this._sessionsPromise;
const finalSessions = sortedScopes.length
? sessions.filter(session => arrayEquals([...session.scopes].sort(), sortedScopes))
: sessions;
this._logger.info(`Got ${finalSessions.length} sessions for ${sortedScopes?.join(',') ?? 'all scopes'}...`);
return finalSessions;
}
private async afterSessionLoad(session: vscode.AuthenticationSession): Promise<void> {
// We only want to fire a telemetry if we haven't seen this account yet in this session.
if (!this._accountsSeen.has(session.account.id)) {
this._accountsSeen.add(session.account.id);
this._githubServer.sendAdditionalTelemetryInfo(session);
}
}
private async checkForUpdates() {
const previousSessions = await this._sessionsPromise;
this._sessionsPromise = this.readSessions();
const storedSessions = await this._sessionsPromise;
const added: vscode.AuthenticationSession[] = [];
const removed: vscode.AuthenticationSession[] = [];
storedSessions.forEach(session => {
const matchesExisting = previousSessions.some(s => s.id === session.id);
// Another window added a session to the keychain, add it to our state as well
if (!matchesExisting) {
this._logger.info('Adding session found in keychain');
added.push(session);
}
});
previousSessions.forEach(session => {
const matchesExisting = storedSessions.some(s => s.id === session.id);
// Another window has logged out, remove from our state
if (!matchesExisting) {
this._logger.info('Removing session no longer found in keychain');
removed.push(session);
}
});
if (added.length || removed.length) {
this._sessionChangeEmitter.fire({ added, removed, changed: [] });
}
}
private async readSessions(): Promise<vscode.AuthenticationSession[]> {
let sessionData: SessionData[];
try {
this._logger.info('Reading sessions from keychain...');
const storedSessions = await this._keychain.getToken();
if (!storedSessions) {
return [];
}
this._logger.info('Got stored sessions!');
try {
sessionData = JSON.parse(storedSessions);
} catch (e) {
await this._keychain.deleteToken();
throw e;
}
} catch (e) {
this._logger.error(`Error reading token: ${e}`);
return [];
}
// TODO: eventually remove this Set because we should only have one session per set of scopes.
const scopesSeen = new Set<string>();
const sessionPromises = sessionData.map(async (session: SessionData) => {
// For GitHub scope list, order doesn't matter so we immediately sort the scopes
const scopesStr = [...session.scopes].sort().join(' ');
if (scopesSeen.has(scopesStr)) {
return undefined;
}
let userInfo: { id: string; accountName: string } | undefined;
if (!session.account) {
try {
userInfo = await this._githubServer.getUserInfo(session.accessToken);
this._logger.info(`Verified session with the following scopes: ${scopesStr}`);
} catch (e) {
// Remove sessions that return unauthorized response
if (e.message === 'Unauthorized') {
return undefined;
}
}
}
this._logger.trace(`Read the following session from the keychain with the following scopes: ${scopesStr}`);
scopesSeen.add(scopesStr);
return {
id: session.id,
account: {
label: session.account
? session.account.label ?? session.account.displayName ?? '<unknown>'
: userInfo?.accountName ?? '<unknown>',
id: session.account?.id ?? userInfo?.id ?? '<unknown>'
},
// we set this to session.scopes to maintain the original order of the scopes requested
// by the extension that called getSession()
scopes: session.scopes,
accessToken: session.accessToken
};
});
const verifiedSessions = (await Promise.allSettled(sessionPromises))
.filter(p => p.status === 'fulfilled')
.map(p => (p as PromiseFulfilledResult<vscode.AuthenticationSession | undefined>).value)
.filter(<T>(p?: T): p is T => Boolean(p));
this._logger.info(`Got ${verifiedSessions.length} verified sessions.`);
if (verifiedSessions.length !== sessionData.length) {
await this.storeSessions(verifiedSessions);
}
return verifiedSessions;
}
private async storeSessions(sessions: vscode.AuthenticationSession[]): Promise<void> {
this._logger.info(`Storing ${sessions.length} sessions...`);
this._sessionsPromise = Promise.resolve(sessions);
await this._keychain.setToken(JSON.stringify(sessions));
this._logger.info(`Stored ${sessions.length} sessions!`);
}
public async createSession(scopes: string[]): Promise<vscode.AuthenticationSession> {
try {
// For GitHub scope list, order doesn't matter so we use a sorted scope to determine
// if we've got a session already.
const sortedScopes = [...scopes].sort();
/* __GDPR__
"login" : {
"owner": "TylerLeonhardt",
"comment": "Used to determine how much usage the GitHub Auth Provider gets.",
"scopes": { "classification": "PublicNonPersonalData", "purpose": "FeatureInsight", "comment": "Used to determine what scope combinations are being requested." }
}
*/
this._telemetryReporter?.sendTelemetryEvent('login', {
scopes: JSON.stringify(scopes),
});
const scopeString = sortedScopes.join(' ');
const token = await this._githubServer.login(scopeString);
const session = await this.tokenToSession(token, scopes);
this.afterSessionLoad(session);
const sessions = await this._sessionsPromise;
const sessionIndex = sessions.findIndex(s => s.id === session.id || arrayEquals([...s.scopes].sort(), sortedScopes));
if (sessionIndex > -1) {
sessions.splice(sessionIndex, 1, session);
} else {
sessions.push(session);
}
await this.storeSessions(sessions);
this._sessionChangeEmitter.fire({ added: [session], removed: [], changed: [] });
this._logger.info('Login success!');
return session;
} catch (e) {
// If login was cancelled, do not notify user.
if (e === 'Cancelled' || e.message === 'Cancelled') {
/* __GDPR__
"loginCancelled" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often users cancel the login flow." }
*/
this._telemetryReporter?.sendTelemetryEvent('loginCancelled');
throw e;
}
/* __GDPR__
"loginFailed" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often users run into an error login flow." }
*/
this._telemetryReporter?.sendTelemetryEvent('loginFailed');
vscode.window.showErrorMessage(vscode.l10n.t('Sign in failed: {0}', `${e}`));
this._logger.error(e);
throw e;
}
}
private async tokenToSession(token: string, scopes: string[]): Promise<vscode.AuthenticationSession> {
const userInfo = await this._githubServer.getUserInfo(token);
return {
id: crypto.getRandomValues(new Uint32Array(2)).reduce((prev, curr) => prev += curr.toString(16), ''),
accessToken: token,
account: { label: userInfo.accountName, id: userInfo.id },
scopes
};
}
public async removeSession(id: string) {
try {
/* __GDPR__
"logout" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often users log out of an account." }
*/
this._telemetryReporter?.sendTelemetryEvent('logout');
this._logger.info(`Logging out of ${id}`);
const sessions = await this._sessionsPromise;
const sessionIndex = sessions.findIndex(session => session.id === id);
if (sessionIndex > -1) {
const session = sessions[sessionIndex];
sessions.splice(sessionIndex, 1);
await this.storeSessions(sessions);
await this._githubServer.logout(session);
this._sessionChangeEmitter.fire({ added: [], removed: [session], changed: [] });
} else {
this._logger.error('Session not found');
}
} catch (e) {
/* __GDPR__
"logoutFailed" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often logging out of an account fails." }
*/
this._telemetryReporter?.sendTelemetryEvent('logoutFailed');
vscode.window.showErrorMessage(vscode.l10n.t('Sign out failed: {0}', `${e}`));
this._logger.error(e);
throw e;
}
}
}