Merge from vscode 3c6f6af7347d38e87bc6406024e8dcf9e9bce229 (#8962)

* Merge from vscode 3c6f6af7347d38e87bc6406024e8dcf9e9bce229

* skip failing tests

* update mac build image
This commit is contained in:
Anthony Dresser
2020-01-27 15:28:17 -08:00
committed by Karl Burtram
parent 0eaee18dc4
commit fefe1454de
481 changed files with 12764 additions and 7836 deletions

View File

@@ -4,52 +4,131 @@
*--------------------------------------------------------------------------------------------*/
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 * as vscode from 'vscode';
import { createServer, startServer } from './authServer';
import { keychain } from './keychain';
import Logger from './logger';
import { toBase64UrlEncoding } from './utils';
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';
const tenant = 'organizations';
interface IToken {
expiresIn: string; // How long access token is valid, in seconds
accessToken: string;
refreshToken: string;
displayName: string;
scope: string;
sessionId: string; // The account id + the scope
}
interface ITokenClaims {
tid: string;
email?: string;
unique_name?: string;
oid?: string;
altsecid?: string;
scp: string;
}
interface IStoredSession {
id: string;
refreshToken: string;
scope: string; // Scopes are alphabetized and joined with a space
}
export const onDidChangeSessions = new vscode.EventEmitter<void>();
export class AzureActiveDirectoryService {
private _token: IToken | undefined;
private _refreshTimeout: NodeJS.Timeout | undefined;
private _tokens: IToken[] = [];
private _refreshTimeouts: Map<string, NodeJS.Timeout> = new Map<string, NodeJS.Timeout>();
public async initialize(): Promise<void> {
const existingRefreshToken = await keychain.getToken();
if (existingRefreshToken) {
await this.refreshToken(existingRefreshToken);
const storedData = await keychain.getToken();
if (storedData) {
try {
const sessions = this.parseStoredData(storedData);
const refreshes = sessions.map(async session => {
try {
await this.refreshToken(session.refreshToken, session.scope);
} catch (e) {
await this.logout(session.id);
}
});
await Promise.all(refreshes);
} catch (e) {
await this.clearSessions();
}
}
this.pollForChange();
}
private parseStoredData(data: string): IStoredSession[] {
return JSON.parse(data);
}
private async storeTokenData(): Promise<void> {
const serializedData: IStoredSession[] = this._tokens.map(token => {
return {
id: token.sessionId,
refreshToken: token.refreshToken,
scope: token.scope
};
});
await keychain.setToken(JSON.stringify(serializedData));
}
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();
let didChange = false;
const storedData = await keychain.getToken();
if (storedData) {
try {
const sessions = this.parseStoredData(storedData);
let promises = sessions.map(async session => {
const matchesExisting = this._tokens.some(token => token.scope === session.scope && token.sessionId === session.id);
if (!matchesExisting) {
try {
await this.refreshToken(session.refreshToken, session.scope);
didChange = true;
} catch (e) {
await this.logout(session.id);
}
}
});
promises = promises.concat(this._tokens.map(async token => {
const matchesExisting = sessions.some(session => token.scope === session.scope && token.sessionId === session.id);
if (!matchesExisting) {
await this.logout(token.sessionId);
didChange = true;
}
}));
await Promise.all(promises);
} catch (e) {
Logger.error(e.message);
// if data is improperly formatted, remove all of it and send change event
this.clearSessions();
didChange = true;
}
} else {
if (this._tokens.length) {
// Log out all
await this.clearSessions();
didChange = true;
}
}
// Another window has logged out
if (!refreshToken && this._token) {
await this.logout();
if (didChange) {
onDidChangeSessions.fire();
}
@@ -57,31 +136,30 @@ export class AzureActiveDirectoryService {
}, 1000 * 30);
}
private tokenToAccount(token: IToken): vscode.Session {
private convertToSession(token: IToken): vscode.Session {
return {
id: '',
id: token.sessionId,
accessToken: token.accessToken,
displayName: this.getDisplayNameFromToken(token.accessToken)
displayName: token.displayName,
scopes: token.scope.split(' ')
};
}
private getDisplayNameFromToken(accessToken: string): string {
let displayName = 'user@example.com';
private getTokenClaims(accessToken: string): ITokenClaims {
try {
// TODO fixme
displayName = JSON.parse(atob(accessToken.split('.')[1]));
return JSON.parse(Buffer.from(accessToken.split('.')[1], 'base64').toString());
} catch (e) {
// Fall back to example display name
Logger.error(e.message);
throw new Error('Unable to read token claims');
}
return displayName;
}
get sessions(): vscode.Session[] {
return this._token ? [this.tokenToAccount(this._token)] : [];
return this._tokens.map(token => this.convertToSession(token));
}
public async login(): Promise<void> {
public async login(scope: string): Promise<void> {
Logger.info('Logging in...');
const nonce = crypto.randomBytes(16).toString('base64');
const { server, redirectPromise, codePromise } = createServer(nonce);
@@ -118,11 +196,13 @@ export class AzureActiveDirectoryService {
if ('err' in codeRes) {
throw codeRes.err;
}
token = await this.exchangeCodeForToken(codeRes.code, codeVerifier);
this.setToken(token);
token = await this.exchangeCodeForToken(codeRes.code, codeVerifier, scope);
this.setToken(token, scope);
Logger.info('Login successful');
res.writeHead(302, { Location: '/' });
res.end();
} catch (err) {
Logger.error(err.message);
res.writeHead(302, { Location: `/?error=${encodeURIComponent(err && err.message || 'Unknown error')}` });
res.end();
}
@@ -133,28 +213,48 @@ export class AzureActiveDirectoryService {
}
}
private async setToken(token: IToken): Promise<void> {
this._token = token;
if (this._refreshTimeout) {
clearTimeout(this._refreshTimeout);
private async setToken(token: IToken, scope: string): Promise<void> {
const existingToken = this._tokens.findIndex(t => t.sessionId === token.sessionId);
if (existingToken) {
this._tokens.splice(existingToken, 1, token);
} else {
this._tokens.push(token);
}
this._refreshTimeout = setTimeout(async () => {
const existingTimeout = this._refreshTimeouts.get(token.sessionId);
if (existingTimeout) {
clearTimeout(existingTimeout);
}
this._refreshTimeouts.set(token.sessionId, setTimeout(async () => {
try {
await this.refreshToken(token.refreshToken);
await this.refreshToken(token.refreshToken, scope);
} catch (e) {
await this.logout();
await this.logout(token.sessionId);
} finally {
onDidChangeSessions.fire();
}
}, 1000 * (parseInt(token.expiresIn) - 10));
}, 1000 * (parseInt(token.expiresIn) - 10)));
await keychain.setToken(token.refreshToken);
this.storeTokenData();
}
private async exchangeCodeForToken(code: string, codeVerifier: string): Promise<IToken> {
private getTokenFromResponse(buffer: Buffer[], scope: string): IToken {
const json = JSON.parse(Buffer.concat(buffer).toString());
const claims = this.getTokenClaims(json.access_token);
return {
expiresIn: json.expires_in,
accessToken: json.access_token,
refreshToken: json.refresh_token,
scope,
sessionId: claims.tid + (claims.oid || claims.altsecid) + scope,
displayName: claims.email || claims.unique_name || 'user@example.com'
};
}
private async exchangeCodeForToken(code: string, codeVerifier: string, scope: string): Promise<IToken> {
return new Promise((resolve: (value: IToken) => void, reject) => {
Logger.info('Exchanging login code for token');
try {
const postData = querystring.stringify({
grant_type: 'authorization_code',
@@ -182,12 +282,7 @@ export class AzureActiveDirectoryService {
});
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
});
resolve(this.getTokenFromResponse(buffer, scope));
} else {
reject(new Error('Unable to login.'));
}
@@ -202,13 +297,15 @@ export class AzureActiveDirectoryService {
});
} catch (e) {
Logger.error(e.message);
reject(e);
}
});
}
private async refreshToken(refreshToken: string): Promise<IToken> {
private async refreshToken(refreshToken: string, scope: string): Promise<IToken> {
return new Promise((resolve: (value: IToken) => void, reject) => {
Logger.info('Refreshing token...');
const postData = querystring.stringify({
refresh_token: refreshToken,
client_id: clientId,
@@ -231,16 +328,12 @@ export class AzureActiveDirectoryService {
});
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);
const token = this.getTokenFromResponse(buffer, scope);
this.setToken(token, scope);
Logger.info('Token refresh success');
resolve(token);
} else {
await this.logout();
Logger.error('Refreshing token failed');
reject(new Error('Refreshing token failed.'));
}
});
@@ -250,16 +343,41 @@ export class AzureActiveDirectoryService {
post.end();
post.on('error', err => {
Logger.error(err.message);
reject(err);
});
});
}
public async logout() {
delete this._token;
await keychain.deleteToken();
if (this._refreshTimeout) {
clearTimeout(this._refreshTimeout);
public async logout(sessionId: string) {
Logger.info(`Logging out of session '${sessionId}'`);
const tokenIndex = this._tokens.findIndex(token => token.sessionId === sessionId);
if (tokenIndex > -1) {
this._tokens.splice(tokenIndex, 1);
}
if (this._tokens.length === 0) {
await keychain.deleteToken();
} else {
this.storeTokenData();
}
const timeout = this._refreshTimeouts.get(sessionId);
if (timeout) {
clearTimeout(timeout);
this._refreshTimeouts.delete(sessionId);
}
}
public async clearSessions() {
Logger.info('Logging out of all sessions');
this._tokens = [];
await keychain.deleteToken();
this._refreshTimeouts.forEach(timeout => {
clearTimeout(timeout);
});
this._refreshTimeouts.clear();
}
}

View File

@@ -14,12 +14,12 @@ export async function activate(context: vscode.ExtensionContext) {
vscode.authentication.registerAuthenticationProvider({
id: 'MSA',
displayName: 'Microsoft Account', // TODO localize
displayName: 'Microsoft',
onDidChangeSessions: onDidChangeSessions.event,
getSessions: () => Promise.resolve(loginService.sessions),
login: async () => {
login: async (scopes: string[]) => {
try {
await loginService.login();
await loginService.login(scopes.sort().join(' '));
return loginService.sessions[0]!;
} catch (e) {
vscode.window.showErrorMessage(`Logging in failed: ${e}`);
@@ -27,7 +27,7 @@ export async function activate(context: vscode.ExtensionContext) {
}
},
logout: async (id: string) => {
return loginService.logout();
return loginService.logout(id);
}
});

View File

@@ -0,0 +1,55 @@
/*---------------------------------------------------------------------------------------------
* 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';
type LogLevel = 'Trace' | 'Info' | 'Error';
class Log {
private output: vscode.OutputChannel;
constructor() {
this.output = vscode.window.createOutputChannel('Account');
}
private data2String(data: any): string {
if (data instanceof Error) {
return data.stack || data.message;
}
if (data.success === false && data.message) {
return data.message;
}
return data.toString();
}
public info(message: string, data?: any): void {
this.logLevel('Info', message, data);
}
public error(message: string, data?: any): void {
this.logLevel('Error', message, data);
}
public logLevel(level: LogLevel, message: string, data?: any): void {
this.output.appendLine(`[${level} - ${this.now()}] ${message}`);
if (data) {
this.output.appendLine(this.data2String(data));
}
}
private now(): string {
const now = new Date();
return padLeft(now.getUTCHours() + '', 2, '0')
+ ':' + padLeft(now.getMinutes() + '', 2, '0')
+ ':' + padLeft(now.getUTCSeconds() + '', 2, '0') + '.' + now.getMilliseconds();
}
}
function padLeft(s: string, n: number, pad = ' ') {
return pad.repeat(Math.max(0, n - s.length)) + s;
}
const Logger = new Log();
export default Logger;

View File

@@ -20,6 +20,7 @@ declare module 'vscode' {
id: string;
accessToken: string;
displayName: string;
scopes: string[]
}
export interface AuthenticationProvider {
@@ -35,7 +36,7 @@ declare module 'vscode' {
/**
* Prompts a user to login.
*/
login(): Promise<Session>;
login(scopes: string[]): Promise<Session>;
logout(sessionId: string): Promise<void>;
}
@@ -48,13 +49,7 @@ declare module 'vscode' {
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>;
export const providers: ReadonlyArray<AuthenticationProvider>;
}
// #region Ben - extension auth flow (desktop+web)