Path: blob/main/extensions/microsoft-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';910function sendFile(res: http.ServerResponse, filepath: string) {11fs.readFile(filepath, (err, body) => {12if (err) {13console.error(err);14res.writeHead(404);15res.end();16} else {17res.writeHead(200, {18'content-length': body.length,19});20res.end(body);21}22});23}2425interface IOAuthResult {26code: string;27state: string;28}2930interface ILoopbackServer {31/**32* If undefined, the server is not started yet.33*/34port: number | undefined;3536/**37* The nonce used38*/39nonce: string;4041/**42* The state parameter used in the OAuth flow.43*/44state: string | undefined;4546/**47* Starts the server.48* @returns The port to listen on.49* @throws If the server fails to start.50* @throws If the server is already started.51*/52start(): Promise<number>;53/**54* Stops the server.55* @throws If the server is not started.56* @throws If the server fails to stop.57*/58stop(): Promise<void>;59/**60* Returns a promise that resolves to the result of the OAuth flow.61*/62waitForOAuthResponse(): Promise<IOAuthResult>;63}6465export class LoopbackAuthServer implements ILoopbackServer {66private readonly _server: http.Server;67private readonly _resultPromise: Promise<IOAuthResult>;68private _startingRedirect: URL;6970public nonce = randomBytes(16).toString('base64');71public port: number | undefined;7273public set state(state: string | undefined) {74if (state) {75this._startingRedirect.searchParams.set('state', state);76} else {77this._startingRedirect.searchParams.delete('state');78}79}80public get state(): string | undefined {81return this._startingRedirect.searchParams.get('state') ?? undefined;82}8384constructor(serveRoot: string, startingRedirect: string) {85if (!serveRoot) {86throw new Error('serveRoot must be defined');87}88if (!startingRedirect) {89throw new Error('startingRedirect must be defined');90}91this._startingRedirect = new URL(startingRedirect);92let deferred: { resolve: (result: IOAuthResult) => void; reject: (reason: any) => void };93this._resultPromise = new Promise<IOAuthResult>((resolve, reject) => deferred = { resolve, reject });9495this._server = http.createServer((req, res) => {96const reqUrl = new URL(req.url!, `http://${req.headers.host}`);97switch (reqUrl.pathname) {98case '/signin': {99const receivedNonce = (reqUrl.searchParams.get('nonce') ?? '').replace(/ /g, '+');100if (receivedNonce !== this.nonce) {101res.writeHead(302, { location: `/?error=${encodeURIComponent('Nonce does not match.')}` });102res.end();103}104res.writeHead(302, { location: this._startingRedirect.toString() });105res.end();106break;107}108case '/callback': {109const code = reqUrl.searchParams.get('code') ?? undefined;110const state = reqUrl.searchParams.get('state') ?? undefined;111const nonce = (reqUrl.searchParams.get('nonce') ?? '').replace(/ /g, '+');112const error = reqUrl.searchParams.get('error') ?? undefined;113if (error) {114res.writeHead(302, { location: `/?error=${reqUrl.searchParams.get('error_description')}` });115res.end();116deferred.reject(new Error(error));117break;118}119if (!code || !state || !nonce) {120res.writeHead(400);121res.end();122break;123}124if (this.state !== state) {125res.writeHead(302, { location: `/?error=${encodeURIComponent('State does not match.')}` });126res.end();127deferred.reject(new Error('State does not match.'));128break;129}130if (this.nonce !== nonce) {131res.writeHead(302, { location: `/?error=${encodeURIComponent('Nonce does not match.')}` });132res.end();133deferred.reject(new Error('Nonce does not match.'));134break;135}136deferred.resolve({ code, state });137res.writeHead(302, { location: '/' });138res.end();139break;140}141// Serve the static files142case '/':143sendFile(res, path.join(serveRoot, 'index.html'));144break;145default:146// substring to get rid of leading '/'147sendFile(res, path.join(serveRoot, reqUrl.pathname.substring(1)));148break;149}150});151}152153public start(): Promise<number> {154return new Promise<number>((resolve, reject) => {155if (this._server.listening) {156throw new Error('Server is already started');157}158const portTimeout = setTimeout(() => {159reject(new Error('Timeout waiting for port'));160}, 5000);161this._server.on('listening', () => {162const address = this._server.address();163if (typeof address === 'string') {164this.port = parseInt(address);165} else if (address instanceof Object) {166this.port = address.port;167} else {168throw new Error('Unable to determine port');169}170171clearTimeout(portTimeout);172173// set state which will be used to redirect back to vscode174this.state = `http://127.0.0.1:${this.port}/callback?nonce=${encodeURIComponent(this.nonce)}`;175176resolve(this.port);177});178this._server.on('error', err => {179reject(new Error(`Error listening to server: ${err}`));180});181this._server.on('close', () => {182reject(new Error('Closed'));183});184this._server.listen(0, '127.0.0.1');185});186}187188public stop(): Promise<void> {189return new Promise<void>((resolve, reject) => {190if (!this._server.listening) {191throw new Error('Server is not started');192}193this._server.close((err) => {194if (err) {195reject(err);196} else {197resolve();198}199});200});201}202203public waitForOAuthResponse(): Promise<IOAuthResult> {204return this._resultPromise;205}206}207208209