mirror of
https://github.com/microsoft/vscode.git
synced 2026-04-21 17:19:01 +01:00
add fallback auth server for github auth and better fallback logic
This commit is contained in:
@@ -12,6 +12,8 @@ import { ExperimentationTelemetry } from './experimentationService';
|
||||
import { AuthProviderType } from './github';
|
||||
import { Log } from './common/logger';
|
||||
import { isSupportedEnvironment } from './common/env';
|
||||
import { LoopbackAuthServer } from './authServer';
|
||||
import path = require('path');
|
||||
|
||||
const localize = nls.loadMessageBundle();
|
||||
const CLIENT_ID = '01ab8ac9400c4e429b23';
|
||||
@@ -110,15 +112,24 @@ async function getUserInfo(token: string, serverUri: vscode.Uri, logger: Log): P
|
||||
export class GitHubServer implements IGitHubServer {
|
||||
friendlyName = 'GitHub';
|
||||
type = AuthProviderType.github;
|
||||
private _onDidManuallyProvideToken = new vscode.EventEmitter<string | undefined>();
|
||||
|
||||
private _pendingNonces = new Map<string, string[]>();
|
||||
private _codeExchangePromises = new Map<string, { promise: Promise<string>; cancel: vscode.EventEmitter<void> }>();
|
||||
private _disposable: vscode.Disposable;
|
||||
private _uriHandler = new UriEventHandler(this._logger);
|
||||
private readonly getRedirectEndpoint: Thenable<string>;
|
||||
|
||||
constructor(private readonly _supportDeviceCodeFlow: boolean, private readonly _logger: Log, private readonly _telemetryReporter: ExperimentationTelemetry) {
|
||||
this._disposable = vscode.window.registerUriHandler(this._uriHandler);
|
||||
|
||||
this.getRedirectEndpoint = vscode.commands.executeCommand<{ [providerId: string]: string } | undefined>('workbench.getCodeExchangeProxyEndpoints').then((proxyEndpoints) => {
|
||||
// If we are running in insiders vscode.dev, then ensure we use the redirect route on that.
|
||||
let redirectUri = REDIRECT_URL_STABLE;
|
||||
if (proxyEndpoints?.github && new URL(proxyEndpoints.github).hostname === 'insiders.vscode.dev') {
|
||||
redirectUri = REDIRECT_URL_INSIDERS;
|
||||
}
|
||||
return redirectUri;
|
||||
});
|
||||
}
|
||||
|
||||
dispose() {
|
||||
@@ -134,84 +145,147 @@ export class GitHubServer implements IGitHubServer {
|
||||
public async login(scopes: string): Promise<string> {
|
||||
this._logger.info(`Logging in for the following scopes: ${scopes}`);
|
||||
|
||||
// Used for showing a friendlier message to the user when the explicitly cancel a flow.
|
||||
let userCancelled: boolean = false;
|
||||
const yes = localize('yes', "Yes");
|
||||
const no = localize('no', "No");
|
||||
const getMessage = () => userCancelled
|
||||
? localize('userCancelledMessage', "Having trouble logging in? Would you like to try a different way?")
|
||||
: localize('otherReasonMessage', "You have not yet finished authorizing this extension to use GitHub. Would you like to keep trying?");
|
||||
|
||||
const nonce = uuid();
|
||||
const callbackUri = await vscode.env.asExternalUri(vscode.Uri.parse(`${vscode.env.uriScheme}://vscode.github-authentication/did-authenticate?nonce=${encodeURIComponent(nonce)}`));
|
||||
|
||||
if (!isSupportedEnvironment(callbackUri)) {
|
||||
const token = this._supportDeviceCodeFlow
|
||||
? await this.doDeviceCodeFlow(scopes)
|
||||
: await vscode.window.showInputBox({ prompt: 'GitHub Personal Access Token', ignoreFocusOut: true });
|
||||
|
||||
if (!token) { throw new Error('No token provided'); }
|
||||
|
||||
const tokenScopes = await getScopes(token, this.getServerUri('/'), this._logger); // Example: ['repo', 'user']
|
||||
const scopesList = scopes.split(' '); // Example: 'read:user repo user:email'
|
||||
if (!scopesList.every(scope => {
|
||||
const included = tokenScopes.includes(scope);
|
||||
if (included || !scope.includes(':')) {
|
||||
return included;
|
||||
}
|
||||
|
||||
return scope.split(':').some(splitScopes => {
|
||||
return tokenScopes.includes(splitScopes);
|
||||
});
|
||||
})) {
|
||||
throw new Error(`The provided token does not match the requested scopes: ${scopes}`);
|
||||
const supported = isSupportedEnvironment(callbackUri);
|
||||
if (supported) {
|
||||
try {
|
||||
return await this.doLoginWithoutLocalServer(scopes, nonce, callbackUri);
|
||||
} catch (e) {
|
||||
this._logger.error(e);
|
||||
userCancelled = e.message ?? e === 'User Cancelled';
|
||||
}
|
||||
|
||||
return token;
|
||||
let choice = await vscode.window.showWarningMessage(getMessage(), yes, no);
|
||||
if (choice !== yes) {
|
||||
throw new Error('Cancelled');
|
||||
}
|
||||
}
|
||||
|
||||
const existingNonces = this._pendingNonces.get(scopes) || [];
|
||||
this._pendingNonces.set(scopes, [...existingNonces, nonce]);
|
||||
// Starting a local server isn't supported in web
|
||||
if (vscode.env.uiKind === vscode.UIKind.Desktop) {
|
||||
try {
|
||||
return await this.doLoginWithLocalServer(scopes);
|
||||
} catch (e) {
|
||||
this._logger.error(e);
|
||||
userCancelled = e.message ?? e === 'User Cancelled';
|
||||
}
|
||||
|
||||
const proxyEndpoints: { [providerId: string]: string } | undefined = await vscode.commands.executeCommand('workbench.getCodeExchangeProxyEndpoints');
|
||||
// If we are running in insiders vscode.dev, then ensure we use the redirect route on that.
|
||||
let redirectUri = REDIRECT_URL_STABLE;
|
||||
if (proxyEndpoints?.github && new URL(proxyEndpoints.github).hostname === 'insiders.vscode.dev') {
|
||||
redirectUri = REDIRECT_URL_INSIDERS;
|
||||
let choice = await vscode.window.showWarningMessage(getMessage(), yes, no);
|
||||
if (choice !== yes) {
|
||||
throw new Error('Cancelled');
|
||||
}
|
||||
}
|
||||
const searchParams = new URLSearchParams([
|
||||
['client_id', CLIENT_ID],
|
||||
['redirect_uri', redirectUri],
|
||||
['scope', scopes],
|
||||
['state', encodeURIComponent(callbackUri.toString(true))]
|
||||
]);
|
||||
const uri = vscode.Uri.parse(`${GITHUB_AUTHORIZE_URL}?${searchParams.toString()}`);
|
||||
|
||||
return vscode.window.withProgress({
|
||||
location: vscode.ProgressLocation.Window,
|
||||
title: localize('signingIn', " $(mark-github) Signing in to github.com..."),
|
||||
}, async () => {
|
||||
if (this._supportDeviceCodeFlow) {
|
||||
try {
|
||||
return await this.doLoginDeviceCodeFlow(scopes);
|
||||
} catch (e) {
|
||||
this._logger.error(e);
|
||||
userCancelled = e.message ?? e === 'User Cancelled';
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
return await this.doLoginWithPat(scopes);
|
||||
} catch (e) {
|
||||
this._logger.error(e);
|
||||
userCancelled = e.message ?? e === 'User Cancelled';
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(userCancelled ? 'Cancelled' : 'No auth flow succeeded.');
|
||||
}
|
||||
|
||||
private async doLoginWithoutLocalServer(scopes: string, nonce: string, callbackUri: vscode.Uri): Promise<string> {
|
||||
this._logger.info(`Trying without local server... (${scopes})`);
|
||||
return await vscode.window.withProgress<string>({
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: localize('signingIn', "Signing in to github.com..."),
|
||||
cancellable: true
|
||||
}, async (_, token) => {
|
||||
const existingNonces = this._pendingNonces.get(scopes) || [];
|
||||
this._pendingNonces.set(scopes, [...existingNonces, nonce]);
|
||||
const redirectUri = await this.getRedirectEndpoint;
|
||||
const searchParams = new URLSearchParams([
|
||||
['client_id', CLIENT_ID],
|
||||
['redirect_uri', redirectUri],
|
||||
['scope', scopes],
|
||||
['state', encodeURIComponent(callbackUri.toString(true))]
|
||||
]);
|
||||
const uri = vscode.Uri.parse(`${GITHUB_AUTHORIZE_URL}?${searchParams.toString()}`);
|
||||
await vscode.env.openExternal(uri);
|
||||
|
||||
// Register a single listener for the URI callback, in case the user starts the login process multiple times
|
||||
// before completing it.
|
||||
let codeExchangePromise = this._codeExchangePromises.get(scopes);
|
||||
if (!codeExchangePromise) {
|
||||
codeExchangePromise = promiseFromEvent(this._uriHandler.event, this.exchangeCodeForToken(scopes));
|
||||
codeExchangePromise = promiseFromEvent(this._uriHandler.event, this.handleUri(scopes));
|
||||
this._codeExchangePromises.set(scopes, codeExchangePromise);
|
||||
}
|
||||
|
||||
return Promise.race([
|
||||
codeExchangePromise.promise,
|
||||
promiseFromEvent<string | undefined, string>(this._onDidManuallyProvideToken.event, (token: string | undefined, resolve, reject): void => {
|
||||
if (!token) {
|
||||
reject('Cancelled');
|
||||
} else {
|
||||
resolve(token);
|
||||
}
|
||||
}).promise,
|
||||
new Promise<string>((_, reject) => setTimeout(() => reject('Cancelled'), 60000))
|
||||
]).finally(() => {
|
||||
try {
|
||||
return await Promise.race([
|
||||
codeExchangePromise.promise,
|
||||
new Promise<string>((_, reject) => setTimeout(() => reject('Cancelled'), 60000)),
|
||||
promiseFromEvent<any, any>(token.onCancellationRequested, (_, __, reject) => { reject('User Cancelled'); }).promise
|
||||
]);
|
||||
} finally {
|
||||
this._pendingNonces.delete(scopes);
|
||||
codeExchangePromise?.cancel.fire();
|
||||
this._codeExchangePromises.delete(scopes);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async doDeviceCodeFlow(scopes: string): Promise<string> {
|
||||
private async doLoginWithLocalServer(scopes: string): Promise<string> {
|
||||
this._logger.info(`Trying with local server... (${scopes})`);
|
||||
return await vscode.window.withProgress<string>({
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: localize('signingInAnotherWay', "Signing in to github.com..."),
|
||||
cancellable: true
|
||||
}, async (_, token) => {
|
||||
const redirectUri = await this.getRedirectEndpoint;
|
||||
const searchParams = new URLSearchParams([
|
||||
['client_id', CLIENT_ID],
|
||||
['redirect_uri', redirectUri],
|
||||
['scope', scopes],
|
||||
]);
|
||||
const loginUrl = `${GITHUB_AUTHORIZE_URL}?${searchParams.toString()}`;
|
||||
const server = new LoopbackAuthServer(path.join(__dirname, '../media'), loginUrl);
|
||||
const port = await server.start();
|
||||
|
||||
let codeToExchange;
|
||||
try {
|
||||
vscode.env.openExternal(vscode.Uri.parse(`http://127.0.0.1:${port}/signin?nonce=${encodeURIComponent(server.nonce)}`));
|
||||
const { code } = await Promise.race([
|
||||
server.waitForOAuthResponse(),
|
||||
new Promise<any>((_, reject) => setTimeout(() => reject('Cancelled'), 60000)),
|
||||
promiseFromEvent<any, any>(token.onCancellationRequested, (_, __, reject) => { reject('User Cancelled'); }).promise
|
||||
]);
|
||||
codeToExchange = code;
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
void server.stop();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
const accessToken = await this.exchangeCodeForToken(codeToExchange);
|
||||
return accessToken;
|
||||
});
|
||||
}
|
||||
|
||||
private async doLoginDeviceCodeFlow(scopes: string): Promise<string> {
|
||||
this._logger.info(`Trying device code flow... (${scopes})`);
|
||||
|
||||
// Get initial device code
|
||||
const uri = `https://github.com/login/device/code?client_id=${CLIENT_ID}&scope=${scopes}`;
|
||||
const result = await fetch(uri, {
|
||||
@@ -235,7 +309,7 @@ export class GitHubServer implements IGitHubServer {
|
||||
}, 'Copy & Continue to GitHub');
|
||||
|
||||
if (modalResult !== 'Copy & Continue to GitHub') {
|
||||
throw new Error('Cancelled');
|
||||
throw new Error('User Cancelled');
|
||||
}
|
||||
|
||||
await vscode.env.clipboard.writeText(json.user_code);
|
||||
@@ -243,6 +317,35 @@ export class GitHubServer implements IGitHubServer {
|
||||
const uriToOpen = await vscode.env.asExternalUri(vscode.Uri.parse(json.verification_uri));
|
||||
await vscode.env.openExternal(uriToOpen);
|
||||
|
||||
return await this.waitForDeviceCodeAccessToken(json);
|
||||
}
|
||||
|
||||
private async doLoginWithPat(scopes: string): Promise<string> {
|
||||
this._logger.info(`Trying to retrieve PAT... (${scopes})`);
|
||||
const token = await vscode.window.showInputBox({ prompt: 'GitHub Personal Access Token', ignoreFocusOut: true });
|
||||
if (!token) { throw new Error('User Cancelled'); }
|
||||
|
||||
const tokenScopes = await getScopes(token, this.getServerUri('/'), this._logger); // Example: ['repo', 'user']
|
||||
const scopesList = scopes.split(' '); // Example: 'read:user repo user:email'
|
||||
if (!scopesList.every(scope => {
|
||||
const included = tokenScopes.includes(scope);
|
||||
if (included || !scope.includes(':')) {
|
||||
return included;
|
||||
}
|
||||
|
||||
return scope.split(':').some(splitScopes => {
|
||||
return tokenScopes.includes(splitScopes);
|
||||
});
|
||||
})) {
|
||||
throw new Error(`The provided token does not match the requested scopes: ${scopes}`);
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
private async waitForDeviceCodeAccessToken(
|
||||
json: IGitHubDeviceCodeResponse,
|
||||
): Promise<string> {
|
||||
return await vscode.window.withProgress<string>({
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
cancellable: true,
|
||||
@@ -252,67 +355,63 @@ export class GitHubServer implements IGitHubServer {
|
||||
json.verification_uri,
|
||||
json.user_code)
|
||||
}, async (_, token) => {
|
||||
return await this.waitForDeviceCodeAccessToken(json, token);
|
||||
const refreshTokenUri = `https://github.com/login/oauth/access_token?client_id=${CLIENT_ID}&device_code=${json.device_code}&grant_type=urn:ietf:params:oauth:grant-type:device_code`;
|
||||
|
||||
// Try for 2 minutes
|
||||
const attempts = 120 / json.interval;
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, json.interval * 1000));
|
||||
if (token.isCancellationRequested) {
|
||||
throw new Error('User Cancelled');
|
||||
}
|
||||
let accessTokenResult;
|
||||
try {
|
||||
accessTokenResult = await fetch(refreshTokenUri, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!accessTokenResult.ok) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const accessTokenJson = await accessTokenResult.json();
|
||||
|
||||
if (accessTokenJson.error === 'authorization_pending') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (accessTokenJson.error) {
|
||||
throw new Error(accessTokenJson.error_description);
|
||||
}
|
||||
|
||||
return accessTokenJson.access_token;
|
||||
}
|
||||
|
||||
throw new Error('Cancelled');
|
||||
});
|
||||
}
|
||||
|
||||
private async waitForDeviceCodeAccessToken(
|
||||
json: IGitHubDeviceCodeResponse,
|
||||
token: vscode.CancellationToken
|
||||
): Promise<string> {
|
||||
|
||||
const refreshTokenUri = `https://github.com/login/oauth/access_token?client_id=${CLIENT_ID}&device_code=${json.device_code}&grant_type=urn:ietf:params:oauth:grant-type:device_code`;
|
||||
|
||||
// Try for 2 minutes
|
||||
const attempts = 120 / json.interval;
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, json.interval * 1000));
|
||||
if (token.isCancellationRequested) {
|
||||
throw new Error('Cancelled');
|
||||
}
|
||||
let accessTokenResult;
|
||||
try {
|
||||
accessTokenResult = await fetch(refreshTokenUri, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!accessTokenResult.ok) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const accessTokenJson = await accessTokenResult.json();
|
||||
|
||||
if (accessTokenJson.error === 'authorization_pending') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (accessTokenJson.error) {
|
||||
throw new Error(accessTokenJson.error_description);
|
||||
}
|
||||
|
||||
return accessTokenJson.access_token;
|
||||
}
|
||||
|
||||
throw new Error('Cancelled');
|
||||
}
|
||||
|
||||
private exchangeCodeForToken: (scopes: string) => PromiseAdapter<vscode.Uri, string> =
|
||||
(scopes) => async (uri, resolve, reject) => {
|
||||
private handleUri: (scopes: string) => PromiseAdapter<vscode.Uri, string> =
|
||||
(scopes) => (uri, resolve, reject) => {
|
||||
const query = new URLSearchParams(uri.query);
|
||||
const code = query.get('code');
|
||||
|
||||
const acceptedNonces = this._pendingNonces.get(scopes) || [];
|
||||
const nonce = query.get('nonce');
|
||||
if (!nonce) {
|
||||
this._logger.error('No nonce in response.');
|
||||
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
|
||||
@@ -323,36 +422,39 @@ export class GitHubServer implements IGitHubServer {
|
||||
return;
|
||||
}
|
||||
|
||||
this._logger.info('Exchanging code for token...');
|
||||
|
||||
const proxyEndpoints: { [providerId: string]: string } | undefined = await vscode.commands.executeCommand('workbench.getCodeExchangeProxyEndpoints');
|
||||
const endpointUrl = proxyEndpoints?.github ? `${proxyEndpoints.github}login/oauth/access_token` : GITHUB_TOKEN_URL;
|
||||
|
||||
try {
|
||||
const body = `code=${code}`;
|
||||
const result = await fetch(endpointUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Content-Length': body.toString()
|
||||
|
||||
},
|
||||
body
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
const json = await result.json();
|
||||
this._logger.info('Token exchange success!');
|
||||
resolve(json.access_token);
|
||||
} else {
|
||||
reject(result.statusText);
|
||||
}
|
||||
} catch (ex) {
|
||||
reject(ex);
|
||||
}
|
||||
resolve(this.exchangeCodeForToken(code));
|
||||
};
|
||||
|
||||
private async exchangeCodeForToken(code: string): Promise<string> {
|
||||
this._logger.info('Exchanging code for token...');
|
||||
|
||||
const proxyEndpoints: { [providerId: string]: string } | undefined = await vscode.commands.executeCommand('workbench.getCodeExchangeProxyEndpoints');
|
||||
const endpointUrl = proxyEndpoints?.github ? `${proxyEndpoints.github}login/oauth/access_token` : GITHUB_TOKEN_URL;
|
||||
|
||||
const body = `code=${code}`;
|
||||
const result = await fetch(endpointUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Content-Length': body.toString()
|
||||
|
||||
},
|
||||
body
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
const json = await result.json();
|
||||
this._logger.info('Token exchange success!');
|
||||
return json.access_token;
|
||||
} else {
|
||||
const text = await result.text();
|
||||
const error = new Error(text);
|
||||
error.name = 'GitHubTokenExchangeError';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private getServerUri(path: string = '') {
|
||||
const apiUri = vscode.Uri.parse('https://api.github.com');
|
||||
return vscode.Uri.parse(`${apiUri.scheme}://${apiUri.authority}${path}`);
|
||||
|
||||
Reference in New Issue
Block a user