Path: blob/main/extensions/github-authentication/src/node/authServer.ts
5223 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/4import * as http from 'http';5import { URL } from 'url';6import * as fs from 'fs';7import * as path from 'path';8import { randomBytes } from 'crypto';9import { env } from 'vscode';1011function sendFile(res: http.ServerResponse, filepath: string) {12const isSvg = filepath.endsWith('.svg');13fs.readFile(filepath, (err, body) => {14if (err) {15console.error(err);16res.writeHead(404);17res.end();18} else {19if (isSvg) {20// SVGs need to be served with the correct content type21res.setHeader('Content-Type', 'image/svg+xml');22}23res.setHeader('content-length', body.length);24res.writeHead(200);25res.end(body);26}27});28}2930interface IOAuthResult {31code: string;32state: string;33}3435interface ILoopbackServer {36/**37* If undefined, the server is not started yet.38*/39port: number | undefined;4041/**42* The nonce used43*/44nonce: string;4546/**47* The state parameter used in the OAuth flow.48*/49state: string | undefined;5051/**52* Starts the server.53* @returns The port to listen on.54* @throws If the server fails to start.55* @throws If the server is already started.56*/57start(): Promise<number>;58/**59* Stops the server.60* @throws If the server is not started.61* @throws If the server fails to stop.62*/63stop(): Promise<void>;64/**65* Returns a promise that resolves to the result of the OAuth flow.66*/67waitForOAuthResponse(): Promise<IOAuthResult>;68}6970export class LoopbackAuthServer implements ILoopbackServer {71private readonly _server: http.Server;72private readonly _resultPromise: Promise<IOAuthResult>;73private _startingRedirect: URL;7475public nonce = randomBytes(16).toString('base64');76public port: number | undefined;7778public set state(state: string | undefined) {79if (state) {80this._startingRedirect.searchParams.set('state', state);81} else {82this._startingRedirect.searchParams.delete('state');83}84}85public get state(): string | undefined {86return this._startingRedirect.searchParams.get('state') ?? undefined;87}8889constructor(serveRoot: string, startingRedirect: string, callbackUri: string, isPortable: boolean) {90if (!serveRoot) {91throw new Error('serveRoot must be defined');92}93if (!startingRedirect) {94throw new Error('startingRedirect must be defined');95}96this._startingRedirect = new URL(startingRedirect);97let deferred: { resolve: (result: IOAuthResult) => void; reject: (reason: any) => void };98this._resultPromise = new Promise<IOAuthResult>((resolve, reject) => deferred = { resolve, reject });99100const appNameQueryParam = `&app_name=${encodeURIComponent(env.appName)}`;101this._server = http.createServer((req, res) => {102const reqUrl = new URL(req.url!, `http://${req.headers.host}`);103switch (reqUrl.pathname) {104case '/signin': {105const receivedNonce = (reqUrl.searchParams.get('nonce') ?? '').replace(/ /g, '+');106if (receivedNonce !== this.nonce) {107res.writeHead(302, { location: `/?error=${encodeURIComponent('Nonce does not match.')}${appNameQueryParam}` });108res.end();109}110res.writeHead(302, { location: this._startingRedirect.toString() });111res.end();112break;113}114case '/callback': {115const code = reqUrl.searchParams.get('code') ?? undefined;116const state = reqUrl.searchParams.get('state') ?? undefined;117const nonce = (reqUrl.searchParams.get('nonce') ?? '').replace(/ /g, '+');118if (!code || !state || !nonce) {119res.writeHead(400);120res.end();121return;122}123if (this.state !== state) {124res.writeHead(302, { location: `/?error=${encodeURIComponent('State does not match.')}${appNameQueryParam}` });125res.end();126throw new Error('State does not match.');127}128if (this.nonce !== nonce) {129res.writeHead(302, { location: `/?error=${encodeURIComponent('Nonce does not match.')}${appNameQueryParam}` });130res.end();131throw new Error('Nonce does not match.');132}133deferred.resolve({ code, state });134if (isPortable) {135res.writeHead(302, { location: `/?app_name=${encodeURIComponent(env.appName)}` });136} else {137res.writeHead(302, { location: `/?redirect_uri=${encodeURIComponent(callbackUri)}${appNameQueryParam}` });138}139res.end();140break;141}142// Serve the static files143case '/':144sendFile(res, path.join(serveRoot, 'index.html'));145break;146default:147// substring to get rid of leading '/'148sendFile(res, path.join(serveRoot, reqUrl.pathname.substring(1)));149break;150}151});152}153154public start(): Promise<number> {155return new Promise<number>((resolve, reject) => {156if (this._server.listening) {157throw new Error('Server is already started');158}159const portTimeout = setTimeout(() => {160reject(new Error('Timeout waiting for port'));161}, 5000);162this._server.on('listening', () => {163const address = this._server.address();164if (typeof address === 'string') {165this.port = parseInt(address);166} else if (address instanceof Object) {167this.port = address.port;168} else {169throw new Error('Unable to determine port');170}171172clearTimeout(portTimeout);173174// set state which will be used to redirect back to vscode175this.state = `http://127.0.0.1:${this.port}/callback?nonce=${encodeURIComponent(this.nonce)}`;176177resolve(this.port);178});179this._server.on('error', err => {180reject(new Error(`Error listening to server: ${err}`));181});182this._server.on('close', () => {183reject(new Error('Closed'));184});185this._server.listen(0, '127.0.0.1');186});187}188189public stop(): Promise<void> {190return new Promise<void>((resolve, reject) => {191if (!this._server.listening) {192throw new Error('Server is not started');193}194this._server.close((err) => {195if (err) {196reject(err);197} else {198resolve();199}200});201});202}203204public waitForOAuthResponse(): Promise<IOAuthResult> {205return this._resultPromise;206}207}208209210