Merge from vscode a234f13c45b40a0929777cb440ee011b7549eed2 (#8911)

* Merge from vscode a234f13c45b40a0929777cb440ee011b7549eed2

* update distro

* fix layering

* update distro

* fix tests
This commit is contained in:
Anthony Dresser
2020-01-22 13:42:37 -08:00
committed by GitHub
parent 977111eb21
commit bd7aac8ee0
895 changed files with 24651 additions and 14520 deletions

View File

@@ -0,0 +1,265 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as crypto from 'crypto';
import * as vscode from 'vscode';
import * as https from 'https';
import * as querystring from 'querystring';
import { keychain } from './keychain';
import { toBase64UrlEncoding } from './utils';
import { createServer, startServer } from './authServer';
const redirectUrl = 'https://vscode-redirect.azurewebsites.net/';
const loginEndpointUrl = 'https://login.microsoftonline.com/';
const clientId = 'aebc6443-996d-45c2-90f0-388ff96faa56';
const scope = 'https://management.core.windows.net/.default offline_access';
const tenant = 'common';
interface IToken {
expiresIn: string; // How long access token is valid, in seconds
accessToken: string;
refreshToken: string;
}
export const onDidChangeSessions = new vscode.EventEmitter<void>();
export class AzureActiveDirectoryService {
private _token: IToken | undefined;
private _refreshTimeout: NodeJS.Timeout | undefined;
public async initialize(): Promise<void> {
const existingRefreshToken = await keychain.getToken();
if (existingRefreshToken) {
await this.refreshToken(existingRefreshToken);
}
this.pollForChange();
}
private pollForChange() {
setTimeout(async () => {
const refreshToken = await keychain.getToken();
// Another window has logged in, generate access token for this instance.
if (refreshToken && !this._token) {
await this.refreshToken(refreshToken);
onDidChangeSessions.fire();
}
// Another window has logged out
if (!refreshToken && this._token) {
await this.logout();
onDidChangeSessions.fire();
}
this.pollForChange();
}, 1000 * 30);
}
private tokenToAccount(token: IToken): vscode.Session {
return {
id: '',
accessToken: token.accessToken,
displayName: this.getDisplayNameFromToken(token.accessToken)
};
}
private getDisplayNameFromToken(accessToken: string): string {
let displayName = 'user@example.com';
try {
// TODO fixme
displayName = JSON.parse(atob(accessToken.split('.')[1]));
} catch (e) {
// Fall back to example display name
}
return displayName;
}
get sessions(): vscode.Session[] {
return this._token ? [this.tokenToAccount(this._token)] : [];
}
public async login(): Promise<void> {
const nonce = crypto.randomBytes(16).toString('base64');
const { server, redirectPromise, codePromise } = createServer(nonce);
let token: IToken | undefined;
try {
const port = await startServer(server);
vscode.env.openExternal(vscode.Uri.parse(`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 || 'Unknown error')}` });
res.end();
throw err;
}
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 state = `${updatedPort},${encodeURIComponent(nonce)}`;
const codeVerifier = toBase64UrlEncoding(crypto.randomBytes(32).toString('base64'));
const codeChallenge = toBase64UrlEncoding(crypto.createHash('sha256').update(codeVerifier).digest('base64'));
const loginUrl = `${loginEndpointUrl}${tenant}/oauth2/v2.0/authorize?response_type=code&response_mode=query&client_id=${encodeURIComponent(clientId)}&redirect_uri=${encodeURIComponent(redirectUrl)}&state=${state}&scope=${encodeURIComponent(scope)}&prompt=select_account&code_challenge_method=S256&code_challenge=${codeChallenge}`;
await redirectReq.res.writeHead(302, { Location: loginUrl });
redirectReq.res.end();
const codeRes = await codePromise;
const res = codeRes.res;
try {
if ('err' in codeRes) {
throw codeRes.err;
}
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 || 'Unknown error')}` });
res.end();
}
} finally {
setTimeout(() => {
server.close();
}, 5000);
}
}
private async setToken(token: IToken): Promise<void> {
this._token = token;
if (this._refreshTimeout) {
clearTimeout(this._refreshTimeout);
}
this._refreshTimeout = setTimeout(async () => {
try {
await this.refreshToken(token.refreshToken);
} catch (e) {
await this.logout();
} finally {
onDidChangeSessions.fire();
}
}, 1000 * (parseInt(token.expiresIn) - 10));
await keychain.setToken(token.refreshToken);
}
private async exchangeCodeForToken(code: string, codeVerifier: string): Promise<IToken> {
return new Promise((resolve: (value: IToken) => void, reject) => {
try {
const postData = querystring.stringify({
grant_type: 'authorization_code',
code: code,
client_id: clientId,
scope: scope,
code_verifier: codeVerifier,
redirect_uri: redirectUrl
});
const tokenUrl = vscode.Uri.parse(`${loginEndpointUrl}${tenant}/oauth2/v2.0/token`);
const post = https.request({
host: tokenUrl.authority,
path: tokenUrl.path,
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': postData.length
}
}, 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.expires_in,
accessToken: json.access_token,
refreshToken: json.refresh_token
});
} else {
reject(new Error('Unable to login.'));
}
});
});
post.write(postData);
post.end();
post.on('error', err => {
reject(err);
});
} catch (e) {
reject(e);
}
});
}
private async refreshToken(refreshToken: string): Promise<IToken> {
return new Promise((resolve: (value: IToken) => void, reject) => {
const postData = querystring.stringify({
refresh_token: refreshToken,
client_id: clientId,
grant_type: 'refresh_token',
scope: scope
});
const post = https.request({
host: 'login.microsoftonline.com',
path: `/${tenant}/oauth2/v2.0/token`,
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': postData.length
}
}, result => {
const buffer: Buffer[] = [];
result.on('data', (chunk: Buffer) => {
buffer.push(chunk);
});
result.on('end', async () => {
if (result.statusCode === 200) {
const json = JSON.parse(Buffer.concat(buffer).toString());
const token = {
expiresIn: json.expires_in,
accessToken: json.access_token,
refreshToken: json.refresh_token
};
this.setToken(token);
resolve(token);
} else {
await this.logout();
reject(new Error('Refreshing token failed.'));
}
});
});
post.write(postData);
post.end();
post.on('error', err => {
reject(err);
});
});
}
public async logout() {
delete this._token;
await keychain.deleteToken();
if (this._refreshTimeout) {
clearTimeout(this._refreshTimeout);
}
}
}

View File

@@ -0,0 +1,197 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. 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 * as path from 'path';
interface Deferred<T> {
resolve: (result: T | Promise<T>) => void;
reject: (reason: any) => void;
}
const _typeof = {
number: 'number',
string: 'string',
undefined: 'undefined',
object: 'object',
function: 'function'
};
/**
* @returns whether the provided parameter is undefined.
*/
export function isUndefined(obj: any): obj is undefined {
return typeof (obj) === _typeof.undefined;
}
/**
* @returns whether the provided parameter is undefined or null.
*/
export function isUndefinedOrNull(obj: any): obj is undefined | null {
return isUndefined(obj) || obj === null;
}
/**
* Asserts that the argument passed in is neither undefined nor null.
*/
export function assertIsDefined<T>(arg: T | null | undefined): T {
if (isUndefinedOrNull(arg)) {
throw new Error('Assertion Failed: argument is undefined or null');
}
return arg;
}
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<Error | undefined>(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(assertIsDefined(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);
res.writeHead(404);
res.end();
} 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, path.join(__dirname, '../media/auth.html'), 'text/html; charset=utf-8');
break;
case '/auth.css':
sendFile(res, path.join(__dirname, '../media/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
};
}

View File

@@ -0,0 +1,38 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { AzureActiveDirectoryService, onDidChangeSessions } from './AADHelper';
export async function activate(context: vscode.ExtensionContext) {
const loginService = new AzureActiveDirectoryService();
await loginService.initialize();
vscode.authentication.registerAuthenticationProvider({
id: 'MSA',
displayName: 'Microsoft Account', // TODO localize
onDidChangeSessions: onDidChangeSessions.event,
getSessions: () => Promise.resolve(loginService.sessions),
login: async () => {
try {
await loginService.login();
return loginService.sessions[0]!;
} catch (e) {
vscode.window.showErrorMessage(`Logging in failed: ${e}`);
throw e;
}
},
logout: async (id: string) => {
return loginService.logout();
}
});
return;
}
// this method is called when your extension is deactivated
export function deactivate() { }

View File

@@ -0,0 +1,67 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// keytar depends on a native module shipped in vscode, so this is
// how we load it
import * as keytarType from 'keytar';
import { env } from 'vscode';
function getKeytar(): Keytar | undefined {
try {
return require('keytar');
} catch (err) {
console.log(err);
}
return undefined;
}
export type Keytar = {
getPassword: typeof keytarType['getPassword'];
setPassword: typeof keytarType['setPassword'];
deletePassword: typeof keytarType['deletePassword'];
};
const SERVICE_ID = `${env.uriScheme}-vscode.login`;
const ACCOUNT_ID = 'account';
export class Keychain {
private keytar: Keytar;
constructor() {
const keytar = getKeytar();
if (!keytar) {
throw new Error('System keychain unavailable');
}
this.keytar = keytar;
}
async setToken(token: string): Promise<void> {
try {
return await this.keytar.setPassword(SERVICE_ID, ACCOUNT_ID, token);
} catch (e) {
// Ignore
}
}
async getToken() {
try {
return await this.keytar.getPassword(SERVICE_ID, ACCOUNT_ID);
} catch (e) {
// Ignore
}
}
async deleteToken() {
try {
return await this.keytar.deletePassword(SERVICE_ID, ACCOUNT_ID);
} catch (e) {
// Ignore
}
}
}
export const keychain = new Keychain();

View File

@@ -0,0 +1,8 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
export function toBase64UrlEncoding(base64string: string) {
return base64string.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); // Need to use base64url encoding
}

View File

@@ -0,0 +1,66 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
/**
* This is the place for API experiments and proposals.
* These API are NOT stable and subject to change. They are only available in the Insiders
* distribution and CANNOT be used in published extensions.
*
* To test these API in local environment:
* - Use Insiders release of VS Code.
* - Add `"enableProposedApi": true` to your package.json.
* - Copy this file to your project.
*/
declare module 'vscode' {
export interface Session {
id: string;
accessToken: string;
displayName: string;
}
export interface AuthenticationProvider {
readonly id: string;
readonly displayName: string;
readonly onDidChangeSessions: Event<void>;
/**
* Returns an array of current sessions.
*/
getSessions(): Promise<ReadonlyArray<Session>>;
/**
* Prompts a user to login.
*/
login(): Promise<Session>;
logout(sessionId: string): Promise<void>;
}
export namespace authentication {
export function registerAuthenticationProvider(provider: AuthenticationProvider): Disposable;
/**
* Fires with the provider id that was registered or unregistered.
*/
export const onDidRegisterAuthenticationProvider: Event<string>;
export const onDidUnregisterAuthenticationProvider: Event<string>;
/**
* Fires with the provider id that changed sessions.
*/
export const onDidChangeSessions: Event<string>;
export function login(providerId: string): Promise<Session>;
export function logout(providerId: string, accountId: string): Promise<void>;
export function getSessions(providerId: string): Promise<ReadonlyArray<Session> | undefined>;
}
// #region Ben - extension auth flow (desktop+web)
export namespace env {
export function asExternalUri(target: Uri): Thenable<Uri>
}
}