Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/github-authentication/src/flows.ts
3314 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import * as path from 'path';
7
import { ProgressLocation, Uri, commands, env, l10n, window } from 'vscode';
8
import { Log } from './common/logger';
9
import { Config } from './config';
10
import { UriEventHandler } from './github';
11
import { fetching } from './node/fetch';
12
import { crypto } from './node/crypto';
13
import { LoopbackAuthServer } from './node/authServer';
14
import { promiseFromEvent } from './common/utils';
15
import { isHostedGitHubEnterprise } from './common/env';
16
import { NETWORK_ERROR, TIMED_OUT_ERROR, USER_CANCELLATION_ERROR } from './common/errors';
17
18
interface IGitHubDeviceCodeResponse {
19
device_code: string;
20
user_code: string;
21
verification_uri: string;
22
interval: number;
23
}
24
25
interface IFlowOptions {
26
// GitHub.com
27
readonly supportsGitHubDotCom: boolean;
28
// A GitHub Enterprise Server that is hosted by an organization
29
readonly supportsGitHubEnterpriseServer: boolean;
30
// A GitHub Enterprise Server that is hosted by GitHub for an organization
31
readonly supportsHostedGitHubEnterprise: boolean;
32
33
// Runtimes - there are constraints on which runtimes support which flows
34
readonly supportsWebWorkerExtensionHost: boolean;
35
readonly supportsRemoteExtensionHost: boolean;
36
37
// Clients - see `isSupportedClient` in `common/env.ts` for what constitutes a supported client
38
readonly supportsSupportedClients: boolean;
39
readonly supportsUnsupportedClients: boolean;
40
41
// Configurations - some flows require a client secret
42
readonly supportsNoClientSecret: boolean;
43
}
44
45
export const enum GitHubTarget {
46
DotCom,
47
Enterprise,
48
HostedEnterprise
49
}
50
51
export const enum ExtensionHost {
52
WebWorker,
53
Remote,
54
Local
55
}
56
57
export interface IFlowQuery {
58
target: GitHubTarget;
59
extensionHost: ExtensionHost;
60
isSupportedClient: boolean;
61
}
62
63
interface IFlowTriggerOptions {
64
/**
65
* The scopes to request for the OAuth flow.
66
*/
67
scopes: string;
68
/**
69
* The base URI for the flow. This is used to determine which GitHub instance to authenticate against.
70
*/
71
baseUri: Uri;
72
/**
73
* The specific auth provider to use for the flow.
74
*/
75
signInProvider?: GitHubSocialSignInProvider;
76
/**
77
* Extra parameters to include in the OAuth flow.
78
*/
79
extraAuthorizeParameters?: Record<string, string>;
80
/**
81
* The Uri that the OAuth flow will redirect to. (i.e. vscode.dev/redirect)
82
*/
83
redirectUri: Uri;
84
/**
85
* The Uri to redirect to after redirecting to the redirect Uri. (i.e. vscode://....)
86
*/
87
callbackUri: Uri;
88
/**
89
* The enterprise URI for the flow, if applicable.
90
*/
91
enterpriseUri?: Uri;
92
/**
93
* The existing login which will be used to pre-fill the login prompt.
94
*/
95
existingLogin?: string;
96
/**
97
* The nonce for this particular flow. This is used to prevent replay attacks.
98
*/
99
nonce: string;
100
/**
101
* The instance of the Uri Handler for this extension
102
*/
103
uriHandler: UriEventHandler;
104
/**
105
* The logger to use for this flow.
106
*/
107
logger: Log;
108
}
109
110
interface IFlow {
111
label: string;
112
options: IFlowOptions;
113
trigger(options: IFlowTriggerOptions): Promise<string>;
114
}
115
116
/**
117
* Generates a cryptographically secure random string for PKCE code verifier.
118
* @param length The length of the string to generate
119
* @returns A random hex string
120
*/
121
function generateRandomString(length: number): string {
122
const array = new Uint8Array(length);
123
crypto.getRandomValues(array);
124
return Array.from(array)
125
.map(b => b.toString(16).padStart(2, '0'))
126
.join('')
127
.substring(0, length);
128
}
129
130
/**
131
* Generates a PKCE code challenge from a code verifier using SHA-256.
132
* @param codeVerifier The code verifier string
133
* @returns A base64url-encoded SHA-256 hash of the code verifier
134
*/
135
async function generateCodeChallenge(codeVerifier: string): Promise<string> {
136
const encoder = new TextEncoder();
137
const data = encoder.encode(codeVerifier);
138
const digest = await crypto.subtle.digest('SHA-256', data);
139
140
// Base64url encode the digest
141
const base64String = btoa(String.fromCharCode(...new Uint8Array(digest)));
142
return base64String
143
.replace(/\+/g, '-')
144
.replace(/\//g, '_')
145
.replace(/=+$/, '');
146
}
147
148
async function exchangeCodeForToken(
149
logger: Log,
150
endpointUri: Uri,
151
redirectUri: Uri,
152
code: string,
153
codeVerifier: string,
154
enterpriseUri?: Uri
155
): Promise<string> {
156
logger.info('Exchanging code for token...');
157
158
const clientSecret = Config.gitHubClientSecret;
159
if (!clientSecret) {
160
throw new Error('No client secret configured for GitHub authentication.');
161
}
162
163
const body = new URLSearchParams([
164
['code', code],
165
['client_id', Config.gitHubClientId],
166
['redirect_uri', redirectUri.toString(true)],
167
['client_secret', clientSecret],
168
['code_verifier', codeVerifier]
169
]);
170
if (enterpriseUri) {
171
body.append('github_enterprise', enterpriseUri.toString(true));
172
}
173
const result = await fetching(endpointUri.toString(true), {
174
logger,
175
expectJSON: true,
176
method: 'POST',
177
headers: {
178
Accept: 'application/json',
179
'Content-Type': 'application/x-www-form-urlencoded',
180
},
181
body: body.toString()
182
});
183
184
if (result.ok) {
185
const json = await result.json();
186
logger.info('Token exchange success!');
187
return json.access_token;
188
} else {
189
const text = await result.text();
190
const error = new Error(text);
191
error.name = 'GitHubTokenExchangeError';
192
throw error;
193
}
194
}
195
196
class UrlHandlerFlow implements IFlow {
197
label = l10n.t('url handler');
198
options: IFlowOptions = {
199
supportsGitHubDotCom: true,
200
// Supporting GHES would be challenging because different versions
201
// used a different client ID. We could try to detect the version
202
// and use the right one, but that's a lot of work when we have
203
// other flows that work well.
204
supportsGitHubEnterpriseServer: false,
205
supportsHostedGitHubEnterprise: true,
206
supportsRemoteExtensionHost: true,
207
supportsWebWorkerExtensionHost: true,
208
// exchanging a code for a token requires a client secret
209
supportsNoClientSecret: false,
210
supportsSupportedClients: true,
211
supportsUnsupportedClients: false
212
};
213
214
async trigger({
215
scopes,
216
baseUri,
217
redirectUri,
218
callbackUri,
219
enterpriseUri,
220
nonce,
221
signInProvider,
222
extraAuthorizeParameters,
223
uriHandler,
224
existingLogin,
225
logger,
226
}: IFlowTriggerOptions): Promise<string> {
227
logger.info(`Trying without local server... (${scopes})`);
228
return await window.withProgress<string>({
229
location: ProgressLocation.Notification,
230
title: l10n.t({
231
message: 'Signing in to {0}...',
232
args: [baseUri.authority],
233
comment: ['The {0} will be a url, e.g. github.com']
234
}),
235
cancellable: true
236
}, async (_, token) => {
237
// Generate PKCE parameters
238
const codeVerifier = generateRandomString(64);
239
const codeChallenge = await generateCodeChallenge(codeVerifier);
240
241
const promise = uriHandler.waitForCode(logger, scopes, nonce, token);
242
243
const searchParams = new URLSearchParams([
244
['client_id', Config.gitHubClientId],
245
['redirect_uri', redirectUri.toString(true)],
246
['scope', scopes],
247
['state', encodeURIComponent(callbackUri.toString(true))],
248
['code_challenge', codeChallenge],
249
['code_challenge_method', 'S256']
250
]);
251
if (existingLogin) {
252
searchParams.append('login', existingLogin);
253
} else {
254
searchParams.append('prompt', 'select_account');
255
}
256
if (signInProvider) {
257
searchParams.append('provider', signInProvider);
258
}
259
if (extraAuthorizeParameters) {
260
for (const [key, value] of Object.entries(extraAuthorizeParameters)) {
261
searchParams.append(key, value);
262
}
263
}
264
265
// The extra toString, parse is apparently needed for env.openExternal
266
// to open the correct URL.
267
const uri = Uri.parse(baseUri.with({
268
path: '/login/oauth/authorize',
269
query: searchParams.toString()
270
}).toString(true));
271
await env.openExternal(uri);
272
273
const code = await promise;
274
275
const proxyEndpoints: { [providerId: string]: string } | undefined = await commands.executeCommand('workbench.getCodeExchangeProxyEndpoints');
276
const endpointUrl = proxyEndpoints?.github
277
? Uri.parse(`${proxyEndpoints.github}login/oauth/access_token`)
278
: baseUri.with({ path: '/login/oauth/access_token' });
279
280
const accessToken = await exchangeCodeForToken(logger, endpointUrl, redirectUri, code, codeVerifier, enterpriseUri);
281
return accessToken;
282
});
283
}
284
}
285
286
class LocalServerFlow implements IFlow {
287
label = l10n.t('local server');
288
options: IFlowOptions = {
289
supportsGitHubDotCom: true,
290
// Supporting GHES would be challenging because different versions
291
// used a different client ID. We could try to detect the version
292
// and use the right one, but that's a lot of work when we have
293
// other flows that work well.
294
supportsGitHubEnterpriseServer: false,
295
supportsHostedGitHubEnterprise: true,
296
// Opening a port on the remote side can't be open in the browser on
297
// the client side so this flow won't work in remote extension hosts
298
supportsRemoteExtensionHost: false,
299
// Web worker can't open a port to listen for the redirect
300
supportsWebWorkerExtensionHost: false,
301
// exchanging a code for a token requires a client secret
302
supportsNoClientSecret: false,
303
supportsSupportedClients: true,
304
supportsUnsupportedClients: true
305
};
306
async trigger({
307
scopes,
308
baseUri,
309
redirectUri,
310
callbackUri,
311
enterpriseUri,
312
signInProvider,
313
extraAuthorizeParameters,
314
existingLogin,
315
logger
316
}: IFlowTriggerOptions): Promise<string> {
317
logger.info(`Trying with local server... (${scopes})`);
318
return await window.withProgress<string>({
319
location: ProgressLocation.Notification,
320
title: l10n.t({
321
message: 'Signing in to {0}...',
322
args: [baseUri.authority],
323
comment: ['The {0} will be a url, e.g. github.com']
324
}),
325
cancellable: true
326
}, async (_, token) => {
327
// Generate PKCE parameters
328
const codeVerifier = generateRandomString(64);
329
const codeChallenge = await generateCodeChallenge(codeVerifier);
330
331
const searchParams = new URLSearchParams([
332
['client_id', Config.gitHubClientId],
333
['redirect_uri', redirectUri.toString(true)],
334
['scope', scopes],
335
['code_challenge', codeChallenge],
336
['code_challenge_method', 'S256']
337
]);
338
if (existingLogin) {
339
searchParams.append('login', existingLogin);
340
} else {
341
searchParams.append('prompt', 'select_account');
342
}
343
if (signInProvider) {
344
searchParams.append('provider', signInProvider);
345
}
346
if (extraAuthorizeParameters) {
347
for (const [key, value] of Object.entries(extraAuthorizeParameters)) {
348
searchParams.append(key, value);
349
}
350
}
351
352
const loginUrl = baseUri.with({
353
path: '/login/oauth/authorize',
354
query: searchParams.toString()
355
});
356
const server = new LoopbackAuthServer(path.join(__dirname, '../media'), loginUrl.toString(true), callbackUri.toString(true));
357
const port = await server.start();
358
359
let codeToExchange;
360
try {
361
env.openExternal(Uri.parse(`http://127.0.0.1:${port}/signin?nonce=${encodeURIComponent(server.nonce)}`));
362
const { code } = await Promise.race([
363
server.waitForOAuthResponse(),
364
new Promise<any>((_, reject) => setTimeout(() => reject(TIMED_OUT_ERROR), 300_000)), // 5min timeout
365
promiseFromEvent<any, any>(token.onCancellationRequested, (_, __, reject) => { reject(USER_CANCELLATION_ERROR); }).promise
366
]);
367
codeToExchange = code;
368
} finally {
369
setTimeout(() => {
370
void server.stop();
371
}, 5000);
372
}
373
374
const accessToken = await exchangeCodeForToken(
375
logger,
376
baseUri.with({ path: '/login/oauth/access_token' }),
377
redirectUri,
378
codeToExchange,
379
codeVerifier,
380
enterpriseUri);
381
return accessToken;
382
});
383
}
384
}
385
386
class DeviceCodeFlow implements IFlow {
387
label = l10n.t('device code');
388
options: IFlowOptions = {
389
supportsGitHubDotCom: true,
390
supportsGitHubEnterpriseServer: true,
391
supportsHostedGitHubEnterprise: true,
392
supportsRemoteExtensionHost: true,
393
// CORS prevents this from working in web workers
394
supportsWebWorkerExtensionHost: false,
395
supportsNoClientSecret: true,
396
supportsSupportedClients: true,
397
supportsUnsupportedClients: true
398
};
399
async trigger({ scopes, baseUri, signInProvider, extraAuthorizeParameters, logger }: IFlowTriggerOptions) {
400
logger.info(`Trying device code flow... (${scopes})`);
401
402
// Get initial device code
403
const uri = baseUri.with({
404
path: '/login/device/code',
405
query: `client_id=${Config.gitHubClientId}&scope=${scopes}`
406
});
407
const result = await fetching(uri.toString(true), {
408
logger,
409
expectJSON: true,
410
method: 'POST',
411
headers: {
412
Accept: 'application/json'
413
}
414
});
415
if (!result.ok) {
416
throw new Error(`Failed to get one-time code: ${await result.text()}`);
417
}
418
419
const json = await result.json() as IGitHubDeviceCodeResponse;
420
421
const button = l10n.t('Copy & Continue to {0}', signInProvider ? GitHubSocialSignInProviderLabels[signInProvider] : l10n.t('GitHub'));
422
const modalResult = await window.showInformationMessage(
423
l10n.t({ message: 'Your Code: {0}', args: [json.user_code], comment: ['The {0} will be a code, e.g. 123-456'] }),
424
{
425
modal: true,
426
detail: l10n.t('To finish authenticating, navigate to GitHub and paste in the above one-time code.')
427
}, button);
428
429
if (modalResult !== button) {
430
throw new Error(USER_CANCELLATION_ERROR);
431
}
432
433
await env.clipboard.writeText(json.user_code);
434
435
let open = Uri.parse(json.verification_uri);
436
const query = new URLSearchParams(open.query);
437
if (signInProvider) {
438
query.set('provider', signInProvider);
439
}
440
if (extraAuthorizeParameters) {
441
for (const [key, value] of Object.entries(extraAuthorizeParameters)) {
442
query.set(key, value);
443
}
444
}
445
if (signInProvider || extraAuthorizeParameters) {
446
open = open.with({ query: query.toString() });
447
}
448
const uriToOpen = await env.asExternalUri(open);
449
await env.openExternal(uriToOpen);
450
451
return await this.waitForDeviceCodeAccessToken(logger, baseUri, json);
452
}
453
454
private async waitForDeviceCodeAccessToken(
455
logger: Log,
456
baseUri: Uri,
457
json: IGitHubDeviceCodeResponse,
458
): Promise<string> {
459
return await window.withProgress<string>({
460
location: ProgressLocation.Notification,
461
cancellable: true,
462
title: l10n.t({
463
message: 'Open [{0}]({0}) in a new tab and paste your one-time code: {1}',
464
args: [json.verification_uri, json.user_code],
465
comment: [
466
'The [{0}]({0}) will be a url and the {1} will be a code, e.g. 123-456',
467
'{Locked="[{0}]({0})"}'
468
]
469
})
470
}, async (_, token) => {
471
const refreshTokenUri = baseUri.with({
472
path: '/login/oauth/access_token',
473
query: `client_id=${Config.gitHubClientId}&device_code=${json.device_code}&grant_type=urn:ietf:params:oauth:grant-type:device_code`
474
});
475
476
// Try for 2 minutes
477
const attempts = 120 / json.interval;
478
for (let i = 0; i < attempts; i++) {
479
await new Promise(resolve => setTimeout(resolve, json.interval * 1000));
480
if (token.isCancellationRequested) {
481
throw new Error(USER_CANCELLATION_ERROR);
482
}
483
let accessTokenResult;
484
try {
485
accessTokenResult = await fetching(refreshTokenUri.toString(true), {
486
logger,
487
expectJSON: true,
488
method: 'POST',
489
headers: {
490
Accept: 'application/json'
491
}
492
});
493
} catch {
494
continue;
495
}
496
497
if (!accessTokenResult.ok) {
498
continue;
499
}
500
501
const accessTokenJson = await accessTokenResult.json();
502
503
if (accessTokenJson.error === 'authorization_pending') {
504
continue;
505
}
506
507
if (accessTokenJson.error) {
508
throw new Error(accessTokenJson.error_description);
509
}
510
511
return accessTokenJson.access_token;
512
}
513
514
throw new Error(TIMED_OUT_ERROR);
515
});
516
}
517
}
518
519
class PatFlow implements IFlow {
520
label = l10n.t('personal access token');
521
options: IFlowOptions = {
522
supportsGitHubDotCom: true,
523
supportsGitHubEnterpriseServer: true,
524
supportsHostedGitHubEnterprise: true,
525
supportsRemoteExtensionHost: true,
526
supportsWebWorkerExtensionHost: true,
527
supportsNoClientSecret: true,
528
// PATs can't be used with Settings Sync so we don't enable this flow
529
// for supported clients
530
supportsSupportedClients: false,
531
supportsUnsupportedClients: true
532
};
533
534
async trigger({ scopes, baseUri, logger, enterpriseUri }: IFlowTriggerOptions) {
535
logger.info(`Trying to retrieve PAT... (${scopes})`);
536
537
const button = l10n.t('Continue to GitHub');
538
const modalResult = await window.showInformationMessage(
539
l10n.t('Continue to GitHub to create a Personal Access Token (PAT)'),
540
{
541
modal: true,
542
detail: l10n.t('To finish authenticating, navigate to GitHub to create a PAT then paste the PAT into the input box.')
543
}, button);
544
545
if (modalResult !== button) {
546
throw new Error(USER_CANCELLATION_ERROR);
547
}
548
549
const description = `${env.appName} (${scopes})`;
550
const uriToOpen = await env.asExternalUri(baseUri.with({ path: '/settings/tokens/new', query: `description=${description}&scopes=${scopes.split(' ').join(',')}` }));
551
await env.openExternal(uriToOpen);
552
const token = await window.showInputBox({ placeHolder: `ghp_1a2b3c4...`, prompt: `GitHub Personal Access Token - ${scopes}`, ignoreFocusOut: true });
553
if (!token) { throw new Error(USER_CANCELLATION_ERROR); }
554
555
const appUri = !enterpriseUri || isHostedGitHubEnterprise(enterpriseUri)
556
? Uri.parse(`${baseUri.scheme}://api.${baseUri.authority}`)
557
: Uri.parse(`${baseUri.scheme}://${baseUri.authority}/api/v3`);
558
559
const tokenScopes = await this.getScopes(token, appUri, logger); // Example: ['repo', 'user']
560
const scopesList = scopes.split(' '); // Example: 'read:user repo user:email'
561
if (!scopesList.every(scope => {
562
const included = tokenScopes.includes(scope);
563
if (included || !scope.includes(':')) {
564
return included;
565
}
566
567
return scope.split(':').some(splitScopes => {
568
return tokenScopes.includes(splitScopes);
569
});
570
})) {
571
throw new Error(`The provided token does not match the requested scopes: ${scopes}`);
572
}
573
574
return token;
575
}
576
577
private async getScopes(token: string, serverUri: Uri, logger: Log): Promise<string[]> {
578
try {
579
logger.info('Getting token scopes...');
580
const result = await fetching(serverUri.toString(), {
581
logger,
582
expectJSON: false,
583
headers: {
584
Authorization: `token ${token}`,
585
'User-Agent': `${env.appName} (${env.appHost})`
586
}
587
});
588
589
if (result.ok) {
590
const scopes = result.headers.get('X-OAuth-Scopes');
591
return scopes ? scopes.split(',').map(scope => scope.trim()) : [];
592
} else {
593
logger.error(`Getting scopes failed: ${result.statusText}`);
594
throw new Error(result.statusText);
595
}
596
} catch (ex) {
597
logger.error(ex.message);
598
throw new Error(NETWORK_ERROR);
599
}
600
}
601
}
602
603
const allFlows: IFlow[] = [
604
new LocalServerFlow(),
605
new UrlHandlerFlow(),
606
new DeviceCodeFlow(),
607
new PatFlow()
608
];
609
610
export function getFlows(query: IFlowQuery) {
611
return allFlows.filter(flow => {
612
let useFlow: boolean = true;
613
switch (query.target) {
614
case GitHubTarget.DotCom:
615
useFlow &&= flow.options.supportsGitHubDotCom;
616
break;
617
case GitHubTarget.Enterprise:
618
useFlow &&= flow.options.supportsGitHubEnterpriseServer;
619
break;
620
case GitHubTarget.HostedEnterprise:
621
useFlow &&= flow.options.supportsHostedGitHubEnterprise;
622
break;
623
}
624
625
switch (query.extensionHost) {
626
case ExtensionHost.Remote:
627
useFlow &&= flow.options.supportsRemoteExtensionHost;
628
break;
629
case ExtensionHost.WebWorker:
630
useFlow &&= flow.options.supportsWebWorkerExtensionHost;
631
break;
632
}
633
634
if (!Config.gitHubClientSecret) {
635
useFlow &&= flow.options.supportsNoClientSecret;
636
}
637
638
if (query.isSupportedClient) {
639
// TODO: revisit how we support PAT in GHES but not DotCom... but this works for now since
640
// there isn't another flow that has supportsSupportedClients = false
641
useFlow &&= (flow.options.supportsSupportedClients || query.target !== GitHubTarget.DotCom);
642
} else {
643
useFlow &&= flow.options.supportsUnsupportedClients;
644
}
645
return useFlow;
646
});
647
}
648
649
/**
650
* Social authentication providers for GitHub
651
*/
652
export const enum GitHubSocialSignInProvider {
653
Google = 'google',
654
Apple = 'apple',
655
}
656
657
const GitHubSocialSignInProviderLabels = {
658
[GitHubSocialSignInProvider.Google]: l10n.t('Google'),
659
[GitHubSocialSignInProvider.Apple]: l10n.t('Apple'),
660
};
661
662
export function isSocialSignInProvider(provider: unknown): provider is GitHubSocialSignInProvider {
663
return provider === GitHubSocialSignInProvider.Google || provider === GitHubSocialSignInProvider.Apple;
664
}
665
666