AADHelper.ts 19.3 KB
Newer Older
1 2 3 4 5 6 7 8
/*---------------------------------------------------------------------------------------------
 *  Copyright (c) Microsoft Corporation. All rights reserved.
 *  Licensed under the MIT License. See License.txt in the project root for license information.
 *--------------------------------------------------------------------------------------------*/

import * as crypto from 'crypto';
import * as https from 'https';
import * as querystring from 'querystring';
R
Rachel Macfarlane 已提交
9
import * as vscode from 'vscode';
10
import * as uuid from 'uuid';
11
import { createServer, startServer } from './authServer';
R
Rachel Macfarlane 已提交
12
import { keychain } from './keychain';
13
import Logger from './logger';
R
Rachel Macfarlane 已提交
14
import { toBase64UrlEncoding } from './utils';
15 16 17 18

const redirectUrl = 'https://vscode-redirect.azurewebsites.net/';
const loginEndpointUrl = 'https://login.microsoftonline.com/';
const clientId = 'aebc6443-996d-45c2-90f0-388ff96faa56';
19
const tenant = 'organizations';
20 21

interface IToken {
22 23 24
	accessToken?: string; // When unable to refresh due to network problems, the access token becomes undefined

	expiresIn?: string; // How long access token is valid, in seconds
25
	expiresAt?: number; // UNIX epoch time at which token will expire
26
	refreshToken: string;
27

28 29 30 31
	account: {
		displayName: string;
		id: string;
	};
32 33
	scope: string;
	sessionId: string; // The account id + the scope
34 35
}

R
Rachel Macfarlane 已提交
36
interface ITokenClaims {
37
	tid: string;
R
Rachel Macfarlane 已提交
38 39 40 41
	email?: string;
	unique_name?: string;
	oid?: string;
	altsecid?: string;
42
	ipd?: string;
43 44 45 46 47 48 49
	scp: string;
}

interface IStoredSession {
	id: string;
	refreshToken: string;
	scope: string; // Scopes are alphabetized and joined with a space
50 51 52 53
	account: {
		displayName: string,
		id: string
	}
R
Rachel Macfarlane 已提交
54 55
}

56 57 58 59 60 61 62 63
function parseQuery(uri: vscode.Uri) {
	return uri.query.split('&').reduce((prev: any, current) => {
		const queryString = current.split('=');
		prev[queryString[0]] = queryString[1];
		return prev;
	}, {});
}

64
export const onDidChangeSessions = new vscode.EventEmitter<vscode.AuthenticationSessionsChangeEvent>();
65

66 67
export const REFRESH_NETWORK_FAILURE = 'Network failure';

68 69 70 71 72 73
class UriEventHandler extends vscode.EventEmitter<vscode.Uri> implements vscode.UriHandler {
	public handleUri(uri: vscode.Uri) {
		this.fire(uri);
	}
}

74
export class AzureActiveDirectoryService {
75 76
	private _tokens: IToken[] = [];
	private _refreshTimeouts: Map<string, NodeJS.Timeout> = new Map<string, NodeJS.Timeout>();
77 78 79 80 81 82
	private _uriHandler: UriEventHandler;

	constructor() {
		this._uriHandler = new UriEventHandler();
		vscode.window.registerUriHandler(this._uriHandler);
	}
83 84

	public async initialize(): Promise<void> {
85 86 87
		// TODO remove, temporary migration
		await keychain.migrateToken();

88 89
		const storedData = await keychain.getToken();
		if (storedData) {
90
			try {
91
				const sessions = this.parseStoredData(storedData);
92
				const refreshes = sessions.map(async session => {
93
					try {
94
						await this.refreshToken(session.refreshToken, session.scope, session.id);
95
					} catch (e) {
96 97 98 99 100 101
						if (e.message === REFRESH_NETWORK_FAILURE) {
							const didSucceedOnRetry = await this.handleRefreshNetworkError(session.id, session.refreshToken, session.scope);
							if (!didSucceedOnRetry) {
								this._tokens.push({
									accessToken: undefined,
									refreshToken: session.refreshToken,
102 103 104 105
									account: {
										displayName: session.account.displayName,
										id: session.account.id
									},
106 107 108
									scope: session.scope,
									sessionId: session.id
								});
109
								this.pollForReconnect(session.id, session.refreshToken, session.scope);
110 111 112 113
							}
						} else {
							await this.logout(session.id);
						}
114 115 116 117
					}
				});

				await Promise.all(refreshes);
118
			} catch (e) {
119
				Logger.info('Failed to initialize stored data');
120
				await this.clearSessions();
121
			}
122
		}
123 124 125 126

		this.pollForChange();
	}

127 128 129 130 131 132 133 134 135
	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,
136
				scope: token.scope,
137
				account: token.account
138 139 140 141 142 143
			};
		});

		await keychain.setToken(JSON.stringify(serializedData));
	}

144 145
	private pollForChange() {
		setTimeout(async () => {
146 147
			const addedIds: string[] = [];
			let removedIds: string[] = [];
148 149 150 151 152 153 154 155
			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 {
156
								await this.refreshToken(session.refreshToken, session.scope, session.id);
157
								addedIds.push(session.id);
158
							} catch (e) {
159 160 161 162 163
								if (e.message === REFRESH_NETWORK_FAILURE) {
									// Ignore, will automatically retry on next poll.
								} else {
									await this.logout(session.id);
								}
164 165 166 167 168 169 170 171
							}
						}
					});

					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);
172
							removedIds.push(token.sessionId);
173 174 175 176 177 178 179
						}
					}));

					await Promise.all(promises);
				} catch (e) {
					Logger.error(e.message);
					// if data is improperly formatted, remove all of it and send change event
180
					removedIds = this._tokens.map(token => token.sessionId);
181 182 183 184
					this.clearSessions();
				}
			} else {
				if (this._tokens.length) {
R
Rachel Macfarlane 已提交
185
					// Log out all, remove all local data
186
					removedIds = this._tokens.map(token => token.sessionId);
R
Rachel Macfarlane 已提交
187 188 189 190 191 192 193 194 195
					Logger.info('No stored keychain data, clearing local data');

					this._tokens = [];

					this._refreshTimeouts.forEach(timeout => {
						clearTimeout(timeout);
					});

					this._refreshTimeouts.clear();
196
				}
197 198
			}

199 200
			if (addedIds.length || removedIds.length) {
				onDidChangeSessions.fire({ added: addedIds, removed: removedIds, changed: [] });
201 202 203 204
			}

			this.pollForChange();
		}, 1000 * 30);
205 206
	}

207
	private convertToSession(token: IToken): vscode.AuthenticationSession {
208
		return {
209
			id: token.sessionId,
210
			getAccessToken: () => this.resolveAccessToken(token),
211
			account: token.account,
212
			scopes: token.scope.split(' ')
213 214 215
		};
	}

216 217
	private async resolveAccessToken(token: IToken): Promise<string> {
		if (token.accessToken && (!token.expiresAt || token.expiresAt > Date.now())) {
218 219 220
			token.expiresAt
				? Logger.info(`Token available from cache, expires in ${token.expiresAt - Date.now()} milliseconds`)
				: Logger.info('Token available from cache');
221 222 223 224 225
			return Promise.resolve(token.accessToken);
		}

		try {
			Logger.info('Token expired or unavailable, trying refresh');
226
			const refreshedToken = await this.refreshToken(token.refreshToken, token.scope, token.sessionId);
227
			if (refreshedToken.accessToken) {
228
				return refreshedToken.accessToken;
229 230 231 232 233 234 235 236
			} else {
				throw new Error();
			}
		} catch (e) {
			throw new Error('Unavailable due to network problems');
		}
	}

237
	private getTokenClaims(accessToken: string): ITokenClaims {
238
		try {
R
Rachel Macfarlane 已提交
239
			return JSON.parse(Buffer.from(accessToken.split('.')[1], 'base64').toString());
240
		} catch (e) {
R
Rachel Macfarlane 已提交
241
			Logger.error(e.message);
242
			throw new Error('Unable to read token claims');
243 244 245
		}
	}

246
	get sessions(): vscode.AuthenticationSession[] {
247
		return this._tokens.map(token => this.convertToSession(token));
248 249
	}

250
	public async login(scope: string): Promise<void> {
251
		Logger.info('Logging in...');
252 253 254 255 256 257

		if (vscode.env.uiKind === vscode.UIKind.Web) {
			await this.loginWithoutLocalServer(scope);
			return;
		}

258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281
		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'));
282
			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}`;
283 284 285 286 287 288 289 290 291 292 293

			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;
				}
294 295
				token = await this.exchangeCodeForToken(codeRes.code, codeVerifier, scope);
				this.setToken(token, scope);
296
				Logger.info('Login successful');
297 298 299 300 301
				res.writeHead(302, { Location: '/' });
				res.end();
			} catch (err) {
				res.writeHead(302, { Location: `/?error=${encodeURIComponent(err && err.message || 'Unknown error')}` });
				res.end();
302
				throw new Error(err.message);
303
			}
304 305 306 307 308 309 310
		} catch (e) {
			Logger.error(e.message);

			// If the error was about starting the server, try directly hitting the login endpoint instead
			if (e.message === 'Error listening to server' || e.message === 'Closed' || e.message === 'Timeout waiting for port') {
				await this.loginWithoutLocalServer(scope);
			}
311
			throw new Error(e.message);
312 313 314 315 316 317 318
		} finally {
			setTimeout(() => {
				server.close();
			}, 5000);
		}
	}

319 320
	private getCallbackEnvironment(callbackUri: vscode.Uri): string {
		switch (callbackUri.authority) {
321 322
			case 'online.visualstudio.com':
				return 'vso,';
323
			case 'online-ppe.core.vsengsaas.visualstudio.com':
324
				return 'vsoppe,';
325
			case 'online.dev.core.vsengsaas.visualstudio.com':
326
				return 'vsodev,';
327
			default:
328
				return '';
329 330 331 332 333 334 335 336
		}
	}

	private async loginWithoutLocalServer(scope: string): Promise<IToken> {
		const callbackUri = await vscode.env.asExternalUri(vscode.Uri.parse(`${vscode.env.uriScheme}://vscode.vscode-account`));
		const nonce = crypto.randomBytes(16).toString('base64');
		const port = (callbackUri.authority.match(/:([0-9]*)$/) || [])[1] || (callbackUri.scheme === 'https' ? 443 : 80);
		const callbackEnvironment = this.getCallbackEnvironment(callbackUri);
337
		const state = `${callbackEnvironment}${port},${encodeURIComponent(nonce)},${encodeURIComponent(callbackUri.query)}`;
338 339 340 341 342 343 344 345 346
		const signInUrl = `${loginEndpointUrl}${tenant}/oauth2/v2.0/authorize`;
		let uri = vscode.Uri.parse(signInUrl);
		const codeVerifier = toBase64UrlEncoding(crypto.randomBytes(32).toString('base64'));
		const codeChallenge = toBase64UrlEncoding(crypto.createHash('sha256').update(codeVerifier).digest('base64'));
		uri = uri.with({
			query: `response_type=code&client_id=${encodeURIComponent(clientId)}&response_mode=query&redirect_uri=${redirectUrl}&state=${state}&scope=${scope}&prompt=select_account&code_challenge_method=S256&code_challenge=${codeChallenge}`
		});
		vscode.env.openExternal(uri);

347
		const timeoutPromise = new Promise((_: (value: IToken) => void, reject) => {
348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364
			const wait = setTimeout(() => {
				clearTimeout(wait);
				reject('Login timed out.');
			}, 1000 * 60 * 5);
		});

		return Promise.race([this.handleCodeResponse(state, codeVerifier, scope), timeoutPromise]);
	}

	private async handleCodeResponse(state: string, codeVerifier: string, scope: string) {
		let uriEventListener: vscode.Disposable;
		return new Promise((resolve: (value: IToken) => void, reject) => {
			uriEventListener = this._uriHandler.event(async (uri: vscode.Uri) => {
				try {
					const query = parseQuery(uri);
					const code = query.code;

365 366
					// Workaround double encoding issues of state in web
					if (query.state !== state && decodeURIComponent(query.state) !== state) {
367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386
						throw new Error('State does not match.');
					}

					const token = await this.exchangeCodeForToken(code, codeVerifier, scope);
					this.setToken(token, scope);

					resolve(token);
				} catch (err) {
					reject(err);
				}
			});
		}).then(result => {
			uriEventListener.dispose();
			return result;
		}).catch(err => {
			uriEventListener.dispose();
			throw err;
		});
	}

387
	private async setToken(token: IToken, scope: string): Promise<void> {
388 389 390
		const existingTokenIndex = this._tokens.findIndex(t => t.sessionId === token.sessionId);
		if (existingTokenIndex > -1) {
			this._tokens.splice(existingTokenIndex, 1, token);
391 392 393
		} else {
			this._tokens.push(token);
		}
394

395
		this.clearSessionTimeout(token.sessionId);
396

397 398 399
		if (token.expiresIn) {
			this._refreshTimeouts.set(token.sessionId, setTimeout(async () => {
				try {
400
					await this.refreshToken(token.refreshToken, scope, token.sessionId);
401
					onDidChangeSessions.fire({ added: [], removed: [], changed: [token.sessionId] });
402 403
				} catch (e) {
					if (e.message === REFRESH_NETWORK_FAILURE) {
404 405 406 407
						const didSucceedOnRetry = await this.handleRefreshNetworkError(token.sessionId, token.refreshToken, scope);
						if (!didSucceedOnRetry) {
							this.pollForReconnect(token.sessionId, token.refreshToken, token.scope);
						}
408 409
					} else {
						await this.logout(token.sessionId);
410
						onDidChangeSessions.fire({ added: [], removed: [token.sessionId], changed: [] });
411 412 413 414
					}
				}
			}, 1000 * (parseInt(token.expiresIn) - 30)));
		}
415

416
		this.storeTokenData();
417 418
	}

419
	private getTokenFromResponse(buffer: Buffer[], scope: string, existingId?: string): IToken {
420 421 422 423
		const json = JSON.parse(Buffer.concat(buffer).toString());
		const claims = this.getTokenClaims(json.access_token);
		return {
			expiresIn: json.expires_in,
424
			expiresAt: json.expires_in ? Date.now() + json.expires_in * 1000 : undefined,
425 426 427
			accessToken: json.access_token,
			refreshToken: json.refresh_token,
			scope,
428
			sessionId: existingId || `${claims.tid}/${(claims.oid || (claims.altsecid || '' + claims.ipd || ''))}/${uuid()}`,
429 430 431 432
			account: {
				displayName: claims.email || claims.unique_name || 'user@example.com',
				id: `${claims.tid}/${(claims.oid || (claims.altsecid || '' + claims.ipd || ''))}`
			}
433 434 435 436
		};
	}

	private async exchangeCodeForToken(code: string, codeVerifier: string, scope: string): Promise<IToken> {
437
		return new Promise((resolve: (value: IToken) => void, reject) => {
438
			Logger.info('Exchanging login code for token');
439 440 441 442 443
			try {
				const postData = querystring.stringify({
					grant_type: 'authorization_code',
					code: code,
					client_id: clientId,
444
					scope: scope,
445 446 447 448
					code_verifier: codeVerifier,
					redirect_uri: redirectUrl
				});

449
				const tokenUrl = vscode.Uri.parse(`${loginEndpointUrl}${tenant}/oauth2/v2.0/token`);
450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465

				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) {
466
							Logger.info('Exchanging login code for token success');
467
							resolve(this.getTokenFromResponse(buffer, scope));
468
						} else {
469
							Logger.error('Exchanging login code for token failed');
470 471 472 473 474 475 476 477 478 479 480 481 482
							reject(new Error('Unable to login.'));
						}
					});
				});

				post.write(postData);

				post.end();
				post.on('error', err => {
					reject(err);
				});

			} catch (e) {
483
				Logger.error(e.message);
484 485 486 487 488
				reject(e);
			}
		});
	}

489
	private async refreshToken(refreshToken: string, scope: string, sessionId: string): Promise<IToken> {
490
		return new Promise((resolve: (value: IToken) => void, reject) => {
491
			Logger.info('Refreshing token...');
492 493 494 495
			const postData = querystring.stringify({
				refresh_token: refreshToken,
				client_id: clientId,
				grant_type: 'refresh_token',
496
				scope: scope
497 498 499 500
			});

			const post = https.request({
				host: 'login.microsoftonline.com',
501
				path: `/${tenant}/oauth2/v2.0/token`,
502 503 504 505 506 507 508 509 510 511
				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);
				});
512
				result.on('end', async () => {
513
					if (result.statusCode === 200) {
514
						const token = this.getTokenFromResponse(buffer, scope, sessionId);
515
						this.setToken(token, scope);
516
						Logger.info('Token refresh success');
517 518
						resolve(token);
					} else {
519
						Logger.error('Refreshing token failed');
520
						reject(new Error('Refreshing token failed.'));
521 522 523 524 525 526 527 528
					}
				});
			});

			post.write(postData);

			post.end();
			post.on('error', err => {
529
				Logger.error(err.message);
530
				reject(new Error(REFRESH_NETWORK_FAILURE));
531 532 533 534
			});
		});
	}

535 536 537 538 539 540 541 542 543
	private clearSessionTimeout(sessionId: string): void {
		const timeout = this._refreshTimeouts.get(sessionId);
		if (timeout) {
			clearTimeout(timeout);
			this._refreshTimeouts.delete(sessionId);
		}
	}

	private removeInMemorySessionData(sessionId: string) {
544 545 546 547 548
		const tokenIndex = this._tokens.findIndex(token => token.sessionId === sessionId);
		if (tokenIndex > -1) {
			this._tokens.splice(tokenIndex, 1);
		}

549 550 551
		this.clearSessionTimeout(sessionId);
	}

552 553 554 555 556
	private pollForReconnect(sessionId: string, refreshToken: string, scope: string): void {
		this.clearSessionTimeout(sessionId);

		this._refreshTimeouts.set(sessionId, setTimeout(async () => {
			try {
557
				await this.refreshToken(refreshToken, scope, sessionId);
558 559 560 561 562 563
			} catch (e) {
				this.pollForReconnect(sessionId, refreshToken, scope);
			}
		}, 1000 * 60 * 30));
	}

564 565 566 567 568
	private handleRefreshNetworkError(sessionId: string, refreshToken: string, scope: string, attempts: number = 1): Promise<boolean> {
		return new Promise((resolve, _) => {
			if (attempts === 3) {
				Logger.error('Token refresh failed after 3 attempts');
				return resolve(false);
569 570
			}

571 572 573 574
			if (attempts === 1) {
				const token = this._tokens.find(token => token.sessionId === sessionId);
				if (token) {
					token.accessToken = undefined;
575
					onDidChangeSessions.fire({ added: [], removed: [], changed: [token.sessionId] });
576 577
				}
			}
578

579
			const delayBeforeRetry = 5 * attempts * attempts;
580

581
			this.clearSessionTimeout(sessionId);
582

583 584
			this._refreshTimeouts.set(sessionId, setTimeout(async () => {
				try {
585
					await this.refreshToken(refreshToken, scope, sessionId);
586 587 588 589 590 591
					return resolve(true);
				} catch (e) {
					return resolve(await this.handleRefreshNetworkError(sessionId, refreshToken, scope, attempts + 1));
				}
			}, 1000 * delayBeforeRetry));
		});
592 593 594 595 596 597
	}

	public async logout(sessionId: string) {
		Logger.info(`Logging out of session '${sessionId}'`);
		this.removeInMemorySessionData(sessionId);

598 599 600 601 602
		if (this._tokens.length === 0) {
			await keychain.deleteToken();
		} else {
			this.storeTokenData();
		}
603
	}
604 605 606 607 608 609 610 611 612 613 614 615

	public async clearSessions() {
		Logger.info('Logging out of all sessions');
		this._tokens = [];
		await keychain.deleteToken();

		this._refreshTimeouts.forEach(timeout => {
			clearTimeout(timeout);
		});

		this._refreshTimeouts.clear();
	}
616
}