Use local server for auth so that a completion page can be shown.

This commit is contained in:
Rachel Macfarlane
2019-11-22 09:42:04 -08:00
parent 17ce94c11e
commit 6b78be0286
7 changed files with 392 additions and 106 deletions
+100
View File
@@ -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
}
+35
View File
@@ -0,0 +1,35 @@
<!-- Copyright (C) Microsoft Corporation. All rights reserved. -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<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>
+1 -1
View File
@@ -26,6 +26,6 @@ export interface IAuthTokenService {
getToken(): Promise<string | undefined>;
refreshToken(): Promise<void>;
login(callbackUri?: URI): Promise<void>;
login(): Promise<void>;
logout(): Promise<void>;
}
+1 -4
View File
@@ -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');
@@ -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<T> {
resolve: (result: T | Promise<T>) => void;
reject: (reason: any) => void;
}
export function createTerminateServer(server: http.Server) {
const sockets: Record<number, net.Socket> = {};
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<void>(resolve => server.close(resolve));
for (const id in sockets) {
sockets[id].destroy();
}
return result;
};
}
export async function startServer(server: http.Server): Promise<string> {
let portTimer: NodeJS.Timer;
function cancelPortTimer() {
clearTimeout(portTimer);
}
const port = new Promise<string>((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<string> {
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<RedirectResult>;
const redirectPromise = new Promise<RedirectResult>((resolve, reject) => deferredRedirect = { resolve, reject });
type CodeResult = { code: string; res: http.ServerResponse; } | { err: any; res: http.ServerResponse; };
let deferredCode: Deferred<CodeResult>;
const codePromise = new Promise<CodeResult>((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
};
}
@@ -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<void> {
public async login(): Promise<void> {
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);
const codeVerifier = toBase64UrlEncoding(crypto.randomBytes(32).toString('base64'));
const codeChallenge = toBase64UrlEncoding(crypto.createHash('sha256').update(codeVerifier).digest('base64'));
try {
const port = await startServer(server);
shell.openExternal(`http://localhost:${port}/signin?nonce=${encodeURIComponent(nonce)}`);
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 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;
}
await shell.openExternal(uri.toString(true));
const host = redirectReq.req.headers.host || '';
const updatedPortStr = (/^[^:]+:(\d+)$/.exec(Array.isArray(host) ? host[0] : host) || [])[1];
const updatedPort = updatedPortStr ? parseInt(updatedPortStr, 10) : port;
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);
});
const state = `${updatedPort},${encodeURIComponent(nonce)}`;
const signInUrl = `${activeDirectoryEndpointUrl}${tenantId}/oauth2/authorize`;
const codeVerifier = toBase64UrlEncoding(crypto.randomBytes(32).toString('base64'));
const codeChallenge = toBase64UrlEncoding(crypto.createHash('sha256').update(codeVerifier).digest('base64'));
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<string | undefined> {
@@ -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<IToken> {
let uriEventListener: IDisposable;
private exchangeCodeForToken(code: string, codeVerifier: string): Promise<IToken> {
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);
}
});
}
@@ -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<AuthTokenStatus>('onDidChangeStatus')(status => this.updateStatus(status)));
this.channel.call<AuthTokenStatus>('_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<string> {
@@ -51,8 +38,7 @@ export class AuthTokenService extends Disposable implements IAuthTokenService {
}
login(): Promise<void> {
const callbackUri = this.urlService.create({ authority: 'vscode.login ' });
return this.channel.call('login', callbackUri);
return this.channel.call('login');
}
refreshToken(): Promise<void> {