add fallback auth server for github auth and better fallback logic

This commit is contained in:
Tyler Leonhardt 2022-04-15 15:48:29 -07:00
parent 9c15f4185a
commit ed6d360182
No known key found for this signature in database
GPG key ID: D9BFA0BA8AD9F6F7
9 changed files with 589 additions and 141 deletions

View file

@ -22,7 +22,8 @@ module.exports = withBrowserDefaults({
resolve: {
alias: {
'node-fetch': path.resolve(__dirname, 'node_modules/node-fetch/browser.js'),
'uuid': path.resolve(__dirname, 'node_modules/uuid/dist/esm-browser/index.js')
'uuid': path.resolve(__dirname, 'node_modules/uuid/dist/esm-browser/index.js'),
'./authServer': path.resolve(__dirname, 'src/env/browser/authServer'),
}
}
});

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View file

@ -0,0 +1,37 @@
<!-- Copyright (C) Microsoft Corporation. All rights reserved. -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Azure Account - Sign In</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" type="text/css" media="screen" href="auth.css" />
</head>
<body>
<a class="branding" href="https://code.visualstudio.com/">
Visual Studio Code
</a>
<div class="message-container">
<div class="message">
You are signed in now and can close this page.
</div>
<div class="error-message">
An error occurred while signing in:
<div class="error-text"></div>
</div>
</div>
<script>
var search = window.location.search;
var error = (/[?&^]error=([^&]+)/.exec(search) || [])[1];
if (error) {
document.querySelector('.error-text')
.textContent = decodeURIComponent(error);
document.querySelector('body')
.classList.add('error');
}
</script>
</body>
</html>

View file

@ -0,0 +1,198 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as http from 'http';
import { URL } from 'url';
import * as fs from 'fs';
import * as path from 'path';
import { randomBytes } from 'crypto';
function sendFile(res: http.ServerResponse, filepath: string) {
fs.readFile(filepath, (err, body) => {
if (err) {
console.error(err);
res.writeHead(404);
res.end();
} else {
res.writeHead(200, {
'content-length': body.length,
});
res.end(body);
}
});
}
interface IOAuthResult {
code: string;
state: string;
}
interface ILoopbackServer {
/**
* If undefined, the server is not started yet.
*/
port: number | undefined;
/**
* The nonce used
*/
nonce: string;
/**
* The state parameter used in the OAuth flow.
*/
state: string | undefined;
/**
* Starts the server.
* @returns The port to listen on.
* @throws If the server fails to start.
* @throws If the server is already started.
*/
start(): Promise<number>;
/**
* Stops the server.
* @throws If the server is not started.
* @throws If the server fails to stop.
*/
stop(): Promise<void>;
/**
* Returns a promise that resolves to the result of the OAuth flow.
*/
waitForOAuthResponse(): Promise<IOAuthResult>;
}
export class LoopbackAuthServer implements ILoopbackServer {
private readonly _server: http.Server;
private readonly _resultPromise: Promise<IOAuthResult>;
private _startingRedirect: URL;
public nonce = randomBytes(16).toString('base64');
public port: number | undefined;
public set state(state: string | undefined) {
if (state) {
this._startingRedirect.searchParams.set('state', state);
} else {
this._startingRedirect.searchParams.delete('state');
}
}
public get state(): string | undefined {
return this._startingRedirect.searchParams.get('state') ?? undefined;
}
constructor(serveRoot: string, startingRedirect: string) {
if (!serveRoot) {
throw new Error('serveRoot must be defined');
}
if (!startingRedirect) {
throw new Error('startingRedirect must be defined');
}
this._startingRedirect = new URL(startingRedirect);
let deferred: { resolve: (result: IOAuthResult) => void; reject: (reason: any) => void };
this._resultPromise = new Promise<IOAuthResult>((resolve, reject) => deferred = { resolve, reject });
this._server = http.createServer((req, res) => {
const reqUrl = new URL(req.url!, `http://${req.headers.host}`);
switch (reqUrl.pathname) {
case '/signin': {
const receivedNonce = (reqUrl.searchParams.get('nonce') ?? '').replace(/ /g, '+');
if (receivedNonce !== this.nonce) {
res.writeHead(302, { location: `/?error=${encodeURIComponent('Nonce does not match.')}` });
res.end();
}
res.writeHead(302, { location: this._startingRedirect.toString() });
res.end();
break;
}
case '/callback': {
const code = reqUrl.searchParams.get('code') ?? undefined;
const state = reqUrl.searchParams.get('state') ?? undefined;
const nonce = (reqUrl.searchParams.get('nonce') ?? '').replace(/ /g, '+');
if (!code || !state || !nonce) {
res.writeHead(400);
res.end();
return;
}
if (this.state !== state) {
res.writeHead(302, { location: `/?error=${encodeURIComponent('State does not match.')}` });
res.end();
throw new Error('State does not match.');
}
if (this.nonce !== nonce) {
res.writeHead(302, { location: `/?error=${encodeURIComponent('Nonce does not match.')}` });
res.end();
throw new Error('Nonce does not match.');
}
deferred.resolve({ code, state });
res.writeHead(302, { location: '/' });
res.end();
break;
}
// Serve the static files
case '/':
sendFile(res, path.join(serveRoot, 'index.html'));
break;
default:
// substring to get rid of leading '/'
sendFile(res, path.join(serveRoot, reqUrl.pathname.substring(1)));
break;
}
});
}
public start(): Promise<number> {
return new Promise<number>((resolve, reject) => {
if (this._server.listening) {
throw new Error('Server is already started');
}
const portTimeout = setTimeout(() => {
reject(new Error('Timeout waiting for port'));
}, 5000);
this._server.on('listening', () => {
const address = this._server.address();
if (typeof address === 'string') {
this.port = parseInt(address);
} else if (address instanceof Object) {
this.port = address.port;
} else {
throw new Error('Unable to determine port');
}
clearTimeout(portTimeout);
// set state which will be used to redirect back to vscode
this.state = `http://127.0.0.1:${this.port}/callback?nonce=${encodeURIComponent(this.nonce)}`;
resolve(this.port);
});
this._server.on('error', err => {
reject(new Error(`Error listening to server: ${err}`));
});
this._server.on('close', () => {
reject(new Error('Closed'));
});
this._server.listen(0, '127.0.0.1');
});
}
public stop(): Promise<void> {
return new Promise<void>((resolve, reject) => {
if (!this._server.listening) {
throw new Error('Server is not started');
}
this._server.close((err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
public waitForOAuthResponse(): Promise<IOAuthResult> {
return this._resultPromise;
}
}

View file

@ -0,0 +1,12 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
export function startServer(_: any): any {
throw new Error('Not implemented');
}
export function createServer(_: any): any {
throw new Error('Not implemented');
}

View file

@ -45,10 +45,8 @@ export class GitHubAuthenticationProvider implements vscode.AuthenticationProvid
if (this.type === AuthProviderType.github) {
this._githubServer = new GitHubServer(
// We only can use the Device Code flow when we are running with a remote extension host.
context.extension.extensionKind === vscode.ExtensionKind.Workspace
// This should only matter when we are running in code-oss. See the other change in this commit.
|| vscode.env.uiKind === vscode.UIKind.Desktop,
// We only can use the Device Code flow when we have a full node environment because of CORS.
context.extension.extensionKind === vscode.ExtensionKind.Workspace || vscode.env.uiKind === vscode.UIKind.Desktop,
this._logger,
this._telemetryReporter);
} else {

View file

@ -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}`);