From 6b78be028629ac52ba980ace1d60f077d9493c9b Mon Sep 17 00:00:00 2001 From: Rachel Macfarlane Date: Fri, 22 Nov 2019 09:42:04 -0800 Subject: [PATCH] Use local server for auth so that a completion page can be shown. --- src/vs/platform/auth/common/auth.css | 100 ++++++++++ src/vs/platform/auth/common/auth.html | 35 ++++ src/vs/platform/auth/common/auth.ts | 2 +- src/vs/platform/auth/common/authTokenIpc.ts | 5 +- .../auth/electron-browser/authServer.ts | 162 ++++++++++++++++ .../auth/electron-browser/authTokenService.ts | 178 +++++++++--------- .../electron-browser/authTokenService.ts | 16 +- 7 files changed, 392 insertions(+), 106 deletions(-) create mode 100644 src/vs/platform/auth/common/auth.css create mode 100644 src/vs/platform/auth/common/auth.html create mode 100644 src/vs/platform/auth/electron-browser/authServer.ts diff --git a/src/vs/platform/auth/common/auth.css b/src/vs/platform/auth/common/auth.css new file mode 100644 index 0000000000000..e87a6372763f5 --- /dev/null +++ b/src/vs/platform/auth/common/auth.css @@ -0,0 +1,100 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +html { + height: 100%; +} + +body { + box-sizing: border-box; + min-height: 100%; + margin: 0; + padding: 15px 30px; + display: flex; + flex-direction: column; + color: white; + font-family: "Segoe UI","Helvetica Neue","Helvetica",Arial,sans-serif; + background-color: #373277; +} + +.branding { + background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PGRlZnM+PHN0eWxlPi5pY29uLWNhbnZhcy10cmFuc3BhcmVudHtmaWxsOiNmNmY2ZjY7b3BhY2l0eTowO30uaWNvbi13aGl0ZXtmaWxsOiNmZmY7fTwvc3R5bGU+PC9kZWZzPjx0aXRsZT5CcmFuZFZpc3VhbFN0dWRpb0NvZGUyMDE3UlRXXzI0eF93aGl0ZV8yNHg8L3RpdGxlPjxwYXRoIGNsYXNzPSJpY29uLWNhbnZhcy10cmFuc3BhcmVudCIgZD0iTTI0LDBWMjRIMFYwWiIvPjxwYXRoIGNsYXNzPSJpY29uLXdoaXRlIiBkPSJNMjQsMi41VjIxLjVMMTgsMjQsMCwxOC41di0uNTYxbDE4LDEuNTQ1VjBaTTEsMTMuMTExLDQuMzg1LDEwLDEsNi44ODlsMS40MTgtLjgyN0w1Ljg1Myw4LjY1LDEyLDNsMywxLjQ1NlYxNS41NDRMMTIsMTcsNS44NTMsMTEuMzUsMi40MTksMTMuOTM5Wk03LjY0NCwxMCwxMiwxMy4yODNWNi43MTdaIi8+PC9zdmc+"); + background-size: 24px; + background-repeat: no-repeat; + background-position: left 50%; + padding-left: 36px; + font-size: 20px; + letter-spacing: -0.04rem; + font-weight: 400; + color: white; + text-decoration: none; +} + +.message-container { + flex-grow: 1; + display: flex; + align-items: center; + justify-content: center; + margin: 0 30px; +} + +.message { + font-weight: 300; + font-size: 1.3rem; +} + +body.error .message { + display: none; +} + +body.error .error-message { + display: block; +} + +.error-message { + display: none; + font-weight: 300; + font-size: 1.3rem; +} + +.error-text { + color: red; + font-size: 1rem; +} + +@font-face { + font-family: 'Segoe UI'; + src: url("https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/light/latest.eot"),url("https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/light/latest.eot?#iefix") format("embedded-opentype"); + src: local("Segoe UI Light"),url("https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/light/latest.woff2") format("woff2"),url("https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/light/latest.woff") format("woff"),url("https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/light/latest.ttf") format("truetype"),url("https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/light/latest.svg#web") format("svg"); + font-weight: 200 +} + +@font-face { + font-family: 'Segoe UI'; + src: url("https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/semilight/latest.eot"),url("https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/semilight/latest.eot?#iefix") format("embedded-opentype"); + src: local("Segoe UI Semilight"),url("https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/semilight/latest.woff2") format("woff2"),url("https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/semilight/latest.woff") format("woff"),url("https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/semilight/latest.ttf") format("truetype"),url("https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/semilight/latest.svg#web") format("svg"); + font-weight: 300 +} + +@font-face { + font-family: 'Segoe UI'; + src: url("https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/normal/latest.eot"),url("https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/normal/latest.eot?#iefix") format("embedded-opentype"); + src: local("Segoe UI"),url("https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/normal/latest.woff2") format("woff"),url("https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/normal/latest.woff") format("woff"),url("https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/normal/latest.ttf") format("truetype"),url("https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/normal/latest.svg#web") format("svg"); + font-weight: 400 +} + +@font-face { + font-family: 'Segoe UI'; + src: url("https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/semibold/latest.eot"),url("https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/semibold/latest.eot?#iefix") format("embedded-opentype"); + src: local("Segoe UI Semibold"),url("https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/semibold/latest.woff2") format("woff"),url("https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/semibold/latest.woff") format("woff"),url("https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/semibold/latest.ttf") format("truetype"),url("https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/semibold/latest.svg#web") format("svg"); + font-weight: 600 +} + +@font-face { + font-family: 'Segoe UI'; + src: url("https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/bold/latest.eot"),url("https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/bold/latest.eot?#iefix") format("embedded-opentype"); + src: local("Segoe UI Bold"),url("https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/bold/latest.woff2") format("woff"),url("https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/bold/latest.woff") format("woff"),url("https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/bold/latest.ttf") format("truetype"),url("https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/bold/latest.svg#web") format("svg"); + font-weight: 700 +} diff --git a/src/vs/platform/auth/common/auth.html b/src/vs/platform/auth/common/auth.html new file mode 100644 index 0000000000000..8fe3e50e7b7cc --- /dev/null +++ b/src/vs/platform/auth/common/auth.html @@ -0,0 +1,35 @@ + + + + + + + Azure Account - Sign In + + + + + + Visual Studio Code + +
+
+ You are signed in now and can close this page. +
+
+ An error occurred while signing in: +
+
+
+ + + diff --git a/src/vs/platform/auth/common/auth.ts b/src/vs/platform/auth/common/auth.ts index 039949e9035e0..81af0bbf0a4a7 100644 --- a/src/vs/platform/auth/common/auth.ts +++ b/src/vs/platform/auth/common/auth.ts @@ -26,6 +26,6 @@ export interface IAuthTokenService { getToken(): Promise; refreshToken(): Promise; - login(callbackUri?: URI): Promise; + login(): Promise; logout(): Promise; } diff --git a/src/vs/platform/auth/common/authTokenIpc.ts b/src/vs/platform/auth/common/authTokenIpc.ts index 99a2111750c42..eff088c111473 100644 --- a/src/vs/platform/auth/common/authTokenIpc.ts +++ b/src/vs/platform/auth/common/authTokenIpc.ts @@ -22,11 +22,8 @@ export class AuthTokenChannel implements IServerChannel { switch (command) { case '_getInitialStatus': return Promise.resolve(this.service.status); case 'getToken': return this.service.getToken(); - case 'exchangeCodeForToken': - this.service._onDidGetCallback.fire(args); - return Promise.resolve(); case 'refreshToken': return this.service.refreshToken(); - case 'login': return this.service.login(args); + case 'login': return this.service.login(); case 'logout': return this.service.logout(); } throw new Error('Invalid call'); diff --git a/src/vs/platform/auth/electron-browser/authServer.ts b/src/vs/platform/auth/electron-browser/authServer.ts new file mode 100644 index 0000000000000..b2f227114fd97 --- /dev/null +++ b/src/vs/platform/auth/electron-browser/authServer.ts @@ -0,0 +1,162 @@ +/*--------------------------------------------------------------------------------------------- + * 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 * as url from 'url'; +import * as fs from 'fs'; +import * as net from 'net'; +import { getPathFromAmdModule } from 'vs/base/common/amd'; + +interface Deferred { + resolve: (result: T | Promise) => void; + reject: (reason: any) => void; +} + +export function createTerminateServer(server: http.Server) { + const sockets: Record = {}; + let socketCount = 0; + server.on('connection', socket => { + const id = socketCount++; + sockets[id] = socket; + socket.on('close', () => { + delete sockets[id]; + }); + }); + return async () => { + const result = new Promise(resolve => server.close(resolve)); + for (const id in sockets) { + sockets[id].destroy(); + } + return result; + }; +} + +export async function startServer(server: http.Server): Promise { + let portTimer: NodeJS.Timer; + + function cancelPortTimer() { + clearTimeout(portTimer); + } + + const port = new Promise((resolve, reject) => { + portTimer = setTimeout(() => { + reject(new Error('Timeout waiting for port')); + }, 5000); + + server.on('listening', () => { + const address = server.address(); + if (typeof address === 'string') { + resolve(address); + } else { + resolve(address.port.toString()); + } + }); + + server.on('error', err => { + reject(err); + }); + + server.on('close', () => { + reject(new Error('Closed')); + }); + + server.listen(0); + }); + + port.then(cancelPortTimer, cancelPortTimer); + return port; +} + +function sendFile(res: http.ServerResponse, filepath: string, contentType: string) { + fs.readFile(filepath, (err, body) => { + if (err) { + console.error(err); + } else { + res.writeHead(200, { + 'Content-Length': body.length, + 'Content-Type': contentType + }); + res.end(body); + } + }); +} + +async function callback(nonce: string, reqUrl: url.Url): Promise { + const query = reqUrl.query; + if (!query || typeof query === 'string') { + throw new Error('No query received.'); + } + + let error = query.error_description || query.error; + + if (!error) { + const state = (query.state as string) || ''; + const receivedNonce = (state.split(',')[1] || '').replace(/ /g, '+'); + if (receivedNonce !== nonce) { + error = 'Nonce does not match.'; + } + } + + const code = query.code as string; + if (!error && code) { + return code; + } + + throw new Error((error as string) || 'No code received.'); +} + +export function createServer(nonce: string) { + type RedirectResult = { req: http.IncomingMessage; res: http.ServerResponse; } | { err: any; res: http.ServerResponse; }; + let deferredRedirect: Deferred; + const redirectPromise = new Promise((resolve, reject) => deferredRedirect = { resolve, reject }); + + type CodeResult = { code: string; res: http.ServerResponse; } | { err: any; res: http.ServerResponse; }; + let deferredCode: Deferred; + const codePromise = new Promise((resolve, reject) => deferredCode = { resolve, reject }); + + const codeTimer = setTimeout(() => { + deferredCode.reject(new Error('Timeout waiting for code')); + }, 5 * 60 * 1000); + + function cancelCodeTimer() { + clearTimeout(codeTimer); + } + + const server = http.createServer(function (req, res) { + const reqUrl = url.parse(req.url!, /* parseQueryString */ true); + switch (reqUrl.pathname) { + case '/signin': + const receivedNonce = ((reqUrl.query.nonce as string) || '').replace(/ /g, '+'); + if (receivedNonce === nonce) { + deferredRedirect.resolve({ req, res }); + } else { + const err = new Error('Nonce does not match.'); + deferredRedirect.resolve({ err, res }); + } + break; + case '/': + sendFile(res, getPathFromAmdModule(require, '../common/auth.html'), 'text/html; charset=utf-8'); + break; + case '/auth.css': + sendFile(res, getPathFromAmdModule(require, '../common/auth.css'), 'text/css; charset=utf-8'); + break; + case '/callback': + deferredCode.resolve(callback(nonce, reqUrl) + .then(code => ({ code, res }), err => ({ err, res }))); + break; + default: + res.writeHead(404); + res.end(); + break; + } + }); + + codePromise.then(cancelCodeTimer, cancelCodeTimer); + return { + server, + redirectPromise, + codePromise + }; +} diff --git a/src/vs/platform/auth/electron-browser/authTokenService.ts b/src/vs/platform/auth/electron-browser/authTokenService.ts index 14586bab7b00c..301a6ae505c7a 100644 --- a/src/vs/platform/auth/electron-browser/authTokenService.ts +++ b/src/vs/platform/auth/electron-browser/authTokenService.ts @@ -8,10 +8,11 @@ import * as https from 'https'; import { Event, Emitter } from 'vs/base/common/event'; import { IAuthTokenService, AuthTokenStatus } from 'vs/platform/auth/common/auth'; import { ICredentialsService } from 'vs/platform/credentials/common/credentials'; -import { Disposable, IDisposable } from 'vs/base/common/lifecycle'; +import { Disposable } from 'vs/base/common/lifecycle'; import { URI } from 'vs/base/common/uri'; import { generateUuid } from 'vs/base/common/uuid'; import { shell } from 'electron'; +import { createServer, startServer } from 'vs/platform/auth/electron-browser/authServer'; const SERVICE_NAME = 'VS Code'; const ACCOUNT = 'MyAccount'; @@ -23,14 +24,6 @@ const activeDirectoryResourceId = 'https://management.core.windows.net/'; const clientId = 'aebc6443-996d-45c2-90f0-388ff96faa56'; const tenantId = 'common'; -function parseQuery(uri: URI) { - return uri.query.split('&').reduce((prev: any, current) => { - const queryString = current.split('='); - prev[queryString[0]] = queryString[1]; - return prev; - }, {}); -} - function toQuery(obj: any): string { return Object.keys(obj).map(key => `${key}=${obj[key]}`).join('&'); } @@ -72,34 +65,63 @@ export class AuthTokenService extends Disposable implements IAuthTokenService { }); } - public async login(callbackUri: URI): Promise { + public async login(): Promise { this.setStatus(AuthTokenStatus.SigningIn); + const nonce = generateUuid(); - const port = (callbackUri.authority.match(/:([0-9]*)$/) || [])[1] || (callbackUri.scheme === 'https' || callbackUri.scheme === 'http' ? 443 : 80); - const state = `${callbackUri.scheme},${port},${encodeURIComponent(nonce)},${encodeURIComponent(callbackUri.query)}`; - const signInUrl = `${activeDirectoryEndpointUrl}${tenantId}/oauth2/authorize`; + const { server, redirectPromise, codePromise } = createServer(nonce); + + try { + const port = await startServer(server); + shell.openExternal(`http://localhost:${port}/signin?nonce=${encodeURIComponent(nonce)}`); + + const redirectReq = await redirectPromise; + if ('err' in redirectReq) { + const { err, res } = redirectReq; + res.writeHead(302, { Location: `/?error=${encodeURIComponent(err && err.message || 'Unkown error')}` }); + res.end(); + throw err; + } - const codeVerifier = toBase64UrlEncoding(crypto.randomBytes(32).toString('base64')); - const codeChallenge = toBase64UrlEncoding(crypto.createHash('sha256').update(codeVerifier).digest('base64')); + const host = redirectReq.req.headers.host || ''; + const updatedPortStr = (/^[^:]+:(\d+)$/.exec(Array.isArray(host) ? host[0] : host) || [])[1]; + const updatedPort = updatedPortStr ? parseInt(updatedPortStr, 10) : port; - let uri = URI.parse(signInUrl); - uri = uri.with({ - query: `response_type=code&client_id=${encodeURIComponent(clientId)}&redirect_uri=${redirectUrlAAD}&state=${encodeURIComponent(state)}&resource=${activeDirectoryResourceId}&prompt=select_account&code_challenge_method=S256&code_challenge=${codeChallenge}` - }); + const state = `${updatedPort},${encodeURIComponent(nonce)}`; + const signInUrl = `${activeDirectoryEndpointUrl}${tenantId}/oauth2/authorize`; - await shell.openExternal(uri.toString(true)); + const codeVerifier = toBase64UrlEncoding(crypto.randomBytes(32).toString('base64')); + const codeChallenge = toBase64UrlEncoding(crypto.createHash('sha256').update(codeVerifier).digest('base64')); - const timeoutPromise = new Promise((resolve: (value: IToken) => void, reject) => { - const wait = setTimeout(() => { - this.setStatus(AuthTokenStatus.SignedOut); - clearTimeout(wait); - reject('Login timed out.'); - }, 1000 * 60 * 5); - }); + let uri = URI.parse(signInUrl); + uri = uri.with({ + query: `response_type=code&client_id=${encodeURIComponent(clientId)}&redirect_uri=${redirectUrlAAD}&state=${encodeURIComponent(state)}&resource=${activeDirectoryResourceId}&prompt=select_account&code_challenge_method=S256&code_challenge=${codeChallenge}` + }); + + await redirectReq.res.writeHead(302, { Location: uri.toString(true) }); + redirectReq.res.end(); + + const codeRes = await codePromise; + const res = codeRes.res; + + try { + if ('err' in codeRes) { + throw codeRes.err; + } + const token = await this.exchangeCodeForToken(codeRes.code, codeVerifier); + this.setToken(token); + res.writeHead(302, { Location: '/' }); + res.end(); + } catch (err) { + res.writeHead(302, { Location: `/?error=${encodeURIComponent(err && err.message || 'Unkown error')}` }); + res.end(); + } + } finally { + setTimeout(() => { + server.close(); + }, 5000); + } - return Promise.race([this.exchangeCodeForToken(clientId, tenantId, codeVerifier, state), timeoutPromise]).then(token => { - this.setToken(token); - }); } public getToken(): Promise { @@ -120,71 +142,55 @@ export class AuthTokenService extends Disposable implements IAuthTokenService { this.setStatus(AuthTokenStatus.SignedIn); } - private async exchangeCodeForToken(clientId: string, tenantId: string, codeVerifier: string, state: string): Promise { - let uriEventListener: IDisposable; + private exchangeCodeForToken(code: string, codeVerifier: string): Promise { return new Promise((resolve: (value: IToken) => void, reject) => { - uriEventListener = this.onDidGetCallback(async (uri: URI) => { - try { - const query = parseQuery(uri); - const code = query.code; + try { + const postData = toQuery({ + grant_type: 'authorization_code', + code: code, + client_id: clientId, + code_verifier: codeVerifier, + redirect_uri: redirectUrlAAD + }); - if (query.state !== state) { - return; + const post = https.request({ + host: 'login.microsoftonline.com', + path: `/${tenantId}/oauth2/token`, + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Length': postData.length } - - const postData = toQuery({ - grant_type: 'authorization_code', - code: code, - client_id: clientId, - code_verifier: codeVerifier, - redirect_uri: redirectUrlAAD + }, result => { + const buffer: Buffer[] = []; + result.on('data', (chunk: Buffer) => { + buffer.push(chunk); }); - - const post = https.request({ - host: 'login.microsoftonline.com', - path: `/${tenantId}/oauth2/token`, - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Content-Length': postData.length + result.on('end', () => { + if (result.statusCode === 200) { + const json = JSON.parse(Buffer.concat(buffer).toString()); + resolve({ + expiresIn: json.access_token, + expiresOn: json.expires_on, + accessToken: json.access_token, + refreshToken: json.refresh_token + }); + } else { + reject(new Error('Bad!')); } - }, result => { - const buffer: Buffer[] = []; - result.on('data', (chunk: Buffer) => { - buffer.push(chunk); - }); - result.on('end', () => { - if (result.statusCode === 200) { - const json = JSON.parse(Buffer.concat(buffer).toString()); - resolve({ - expiresIn: json.access_token, - expiresOn: json.expires_on, - accessToken: json.access_token, - refreshToken: json.refresh_token - }); - } else { - reject(new Error('Bad!')); - } - }); }); + }); - post.write(postData); + post.write(postData); - post.end(); - post.on('error', err => { - reject(err); - }); + post.end(); + post.on('error', err => { + reject(err); + }); - } catch (e) { - reject(e); - } - }); - }).then(result => { - uriEventListener.dispose(); - return result; - }).catch(err => { - uriEventListener.dispose(); - throw err; + } catch (e) { + reject(e); + } }); } diff --git a/src/vs/workbench/services/authToken/electron-browser/authTokenService.ts b/src/vs/workbench/services/authToken/electron-browser/authTokenService.ts index 099395471aa06..ad7abd588267c 100644 --- a/src/vs/workbench/services/authToken/electron-browser/authTokenService.ts +++ b/src/vs/workbench/services/authToken/electron-browser/authTokenService.ts @@ -9,7 +9,6 @@ import { Emitter, Event } from 'vs/base/common/event'; import { IChannel } from 'vs/base/parts/ipc/common/ipc'; import { registerSingleton } from 'vs/platform/instantiation/common/extensions'; import { IAuthTokenService, AuthTokenStatus } from 'vs/platform/auth/common/auth'; -import { IURLService } from 'vs/platform/url/common/url'; import { URI } from 'vs/base/common/uri'; export class AuthTokenService extends Disposable implements IAuthTokenService { @@ -27,23 +26,11 @@ export class AuthTokenService extends Disposable implements IAuthTokenService { constructor( @ISharedProcessService sharedProcessService: ISharedProcessService, - @IURLService private readonly urlService: IURLService ) { super(); this.channel = sharedProcessService.getChannel('authToken'); this._register(this.channel.listen('onDidChangeStatus')(status => this.updateStatus(status))); this.channel.call('_getInitialStatus').then(status => this.updateStatus(status)); - - this.urlService.registerHandler(this); - } - - handleURL(uri: URI) { - if (uri.authority === 'vscode.login') { - this.channel.call('exchangeCodeForToken', uri); - return Promise.resolve(true); - } else { - return Promise.resolve(false); - } } getToken(): Promise { @@ -51,8 +38,7 @@ export class AuthTokenService extends Disposable implements IAuthTokenService { } login(): Promise { - const callbackUri = this.urlService.create({ authority: 'vscode.login ' }); - return this.channel.call('login', callbackUri); + return this.channel.call('login'); } refreshToken(): Promise {