Skip to content

Commit

Permalink
add fallback auth server for github auth and better fallback logic
Browse files Browse the repository at this point in the history
  • Loading branch information
TylerLeonhardt committed Apr 15, 2022
1 parent 9c15f41 commit ed6d360
Show file tree
Hide file tree
Showing 9 changed files with 579 additions and 131 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
}
}
});
100 changes: 100 additions & 0 deletions extensions/github-authentication/media/auth.css

Large diffs are not rendered by default.

Binary file not shown.
Binary file added extensions/github-authentication/media/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
37 changes: 37 additions & 0 deletions extensions/github-authentication/media/index.html
Original file line number Diff line number Diff line change
@@ -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>
198 changes: 198 additions & 0 deletions extensions/github-authentication/src/authServer.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
12 changes: 12 additions & 0 deletions extensions/github-authentication/src/env/browser/authServer.ts
Original file line number Diff line number Diff line change
@@ -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');
}
6 changes: 2 additions & 4 deletions extensions/github-authentication/src/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading

0 comments on commit ed6d360

Please sign in to comment.