Path: blob/main/extensions/github-authentication/src/node/authServer.ts
3320 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) {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 });134res.writeHead(302, { location: `/?redirect_uri=${encodeURIComponent(callbackUri)}${appNameQueryParam}` });135res.end();136break;137}138// Serve the static files139case '/':140sendFile(res, path.join(serveRoot, 'index.html'));141break;142default:143// substring to get rid of leading '/'144sendFile(res, path.join(serveRoot, reqUrl.pathname.substring(1)));145break;146}147});148}149150public start(): Promise<number> {151return new Promise<number>((resolve, reject) => {152if (this._server.listening) {153throw new Error('Server is already started');154}155const portTimeout = setTimeout(() => {156reject(new Error('Timeout waiting for port'));157}, 5000);158this._server.on('listening', () => {159const address = this._server.address();160if (typeof address === 'string') {161this.port = parseInt(address);162} else if (address instanceof Object) {163this.port = address.port;164} else {165throw new Error('Unable to determine port');166}167168clearTimeout(portTimeout);169170// set state which will be used to redirect back to vscode171this.state = `http://127.0.0.1:${this.port}/callback?nonce=${encodeURIComponent(this.nonce)}`;172173resolve(this.port);174});175this._server.on('error', err => {176reject(new Error(`Error listening to server: ${err}`));177});178this._server.on('close', () => {179reject(new Error('Closed'));180});181this._server.listen(0, '127.0.0.1');182});183}184185public stop(): Promise<void> {186return new Promise<void>((resolve, reject) => {187if (!this._server.listening) {188throw new Error('Server is not started');189}190this._server.close((err) => {191if (err) {192reject(err);193} else {194resolve();195}196});197});198}199200public waitForOAuthResponse(): Promise<IOAuthResult> {201return this._resultPromise;202}203}204205206