AADHelper.ts 19.0 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
	accountName: string;
29 30
	scope: string;
	sessionId: string; // The account id + the scope
31 32
}

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

interface IStoredSession {
	id: string;
	refreshToken: string;
	scope: string; // Scopes are alphabetized and joined with a space
47
	accountName: string;
R
Rachel Macfarlane 已提交
48 49
}

50 51 52 53 54 55 56 57
function parseQuery(uri: vscode.Uri) {
	return uri.query.split('&').reduce((prev: any, current) => {
		const queryString = current.split('=');
		prev[queryString[0]] = queryString[1];
		return prev;
	}, {});
}

58
export const onDidChangeSessions = new vscode.EventEmitter<vscode.AuthenticationSessionsChangeEvent>();
59

60 61
export const REFRESH_NETWORK_FAILURE = 'Network failure';

62 63 64 65 66 67
class UriEventHandler extends vscode.EventEmitter<vscode.Uri> implements vscode.UriHandler {
	public handleUri(uri: vscode.Uri) {
		this.fire(uri);
	}
}

68
export class AzureActiveDirectoryService {
69 70
	private _tokens: IToken[] = [];
	private _refreshTimeouts: Map<string, NodeJS.Timeout> = new Map<string, NodeJS.Timeout>();
71 72 73 74 75 76
	private _uriHandler: UriEventHandler;

	constructor() {
		this._uriHandler = new UriEventHandler();
		vscode.window.registerUriHandler(this._uriHandler);
	}
77 78

	public async initialize(): Promise<void> {
79 80
		const storedData = await keychain.getToken();
		if (storedData) {
81
			try {
82
				const sessions = this.parseStoredData(storedData);
83
				const refreshes = sessions.map(async session => {
84
					try {
85
						await this.refreshToken(session.refreshToken, session.scope, session.id);
86
					} catch (e) {
87 88 89 90 91 92 93 94 95 96
						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,
									accountName: session.accountName,
									scope: session.scope,
									sessionId: session.id
								});
97
								this.pollForReconnect(session.id, session.refreshToken, session.scope);
98 99 100 101
							}
						} else {
							await this.logout(session.id);
						}
102 103 104 105
					}
				});

				await Promise.all(refreshes);
106
			} catch (e) {
107
				Logger.info('Failed to initialize stored data');
108
				await this.clearSessions();
109
			}
110
		}
111 112 113 114

		this.pollForChange();
	}

115 116 117 118 119 120 121 122 123
	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,
124 125
				scope: token.scope,
				accountName: token.accountName
126 127 128 129 130 131
			};
		});

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

132 133
	private pollForChange() {
		setTimeout(async () => {
134 135
			const addedIds: string[] = [];
			let removedIds: string[] = [];
136 137 138 139 140 141 142 143
			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 {
144
								await this.refreshToken(session.refreshToken, session.scope, session.id);
145
								addedIds.push(session.id);
146
							} catch (e) {
147 148 149 150 151
								if (e.message === REFRESH_NETWORK_FAILURE) {
									// Ignore, will automatically retry on next poll.
								} else {
									await this.logout(session.id);
								}
152 153 154 155 156 157 158 159
							}
						}
					});

					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);
160
							removedIds.push(token.sessionId);
161 162 163 164 165 166 167
						}
					}));

					await Promise.all(promises);
				} catch (e) {
					Logger.error(e.message);
					// if data is improperly formatted, remove all of it and send change event
168
					removedIds = this._tokens.map(token => token.sessionId);
169 170 171 172
					this.clearSessions();
				}
			} else {
				if (this._tokens.length) {
R
Rachel Macfarlane 已提交
173
					// Log out all, remove all local data
174
					removedIds = this._tokens.map(token => token.sessionId);
R
Rachel Macfarlane 已提交
175 176 177 178 179 180 181 182 183
					Logger.info('No stored keychain data, clearing local data');

					this._tokens = [];

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

					this._refreshTimeouts.clear();
184
				}
185 186
			}

187 188
			if (addedIds.length || removedIds.length) {
				onDidChangeSessions.fire({ added: addedIds, removed: removedIds, changed: [] });
189 190 191 192
			}

			this.pollForChange();
		}, 1000 * 30);
193 194
	}

195
	private convertToSession(token: IToken): vscode.AuthenticationSession {
196
		return {
197
			id: token.sessionId,
198
			getAccessToken: () => this.resolveAccessToken(token),
199
			accountName: token.accountName,
200
			scopes: token.scope.split(' ')
201 202 203
		};
	}

204 205
	private async resolveAccessToken(token: IToken): Promise<string> {
		if (token.accessToken && (!token.expiresAt || token.expiresAt > Date.now())) {
206 207 208
			token.expiresAt
				? Logger.info(`Token available from cache, expires in ${token.expiresAt - Date.now()} milliseconds`)
				: Logger.info('Token available from cache');
209 210 211 212 213
			return Promise.resolve(token.accessToken);
		}

		try {
			Logger.info('Token expired or unavailable, trying refresh');
214
			const refreshedToken = await this.refreshToken(token.refreshToken, token.scope, token.sessionId);
215
			if (refreshedToken.accessToken) {
216
				return refreshedToken.accessToken;
217 218 219 220 221 222 223 224 225 226
			} else {
				throw new Error();
			}
		} catch (e) {
			throw new Error('Unavailable due to network problems');
		}

		throw new Error('Unavailable due to network problems');
	}

227
	private getTokenClaims(accessToken: string): ITokenClaims {
228
		try {
R
Rachel Macfarlane 已提交
229
			return JSON.parse(Buffer.from(accessToken.split('.')[1], 'base64').toString());
230
		} catch (e) {
R
Rachel Macfarlane 已提交
231
			Logger.error(e.message);
232
			throw new Error('Unable to read token claims');
233 234 235
		}
	}

236
	get sessions(): vscode.AuthenticationSession[] {
237
		return this._tokens.map(token => this.convertToSession(token));
238 239
	}

240
	public async login(scope: string): Promise<void> {
241
		Logger.info('Logging in...');
242 243 244 245 246 247

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

248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271
		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'));
272
			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}`;
273 274 275 276 277 278 279 280 281 282 283

			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;
				}
284 285
				token = await this.exchangeCodeForToken(codeRes.code, codeVerifier, scope);
				this.setToken(token, scope);
286
				Logger.info('Login successful');
287 288 289 290 291
				res.writeHead(302, { Location: '/' });
				res.end();
			} catch (err) {
				res.writeHead(302, { Location: `/?error=${encodeURIComponent(err && err.message || 'Unknown error')}` });
				res.end();
292
				throw new Error(err.message);
293
			}
294 295 296 297 298 299 300
		} 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);
			}
301
			throw new Error(e.message);
302 303 304 305 306 307 308
		} finally {
			setTimeout(() => {
				server.close();
			}, 5000);
		}
	}

309 310
	private getCallbackEnvironment(callbackUri: vscode.Uri): string {
		switch (callbackUri.authority) {
311 312
			case 'online.visualstudio.com':
				return 'vso,';
313
			case 'online-ppe.core.vsengsaas.visualstudio.com':
314
				return 'vsoppe,';
315
			case 'online.dev.core.vsengsaas.visualstudio.com':
316
				return 'vsodev,';
317
			default:
318
				return '';
319 320 321 322 323 324 325 326
		}
	}

	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);
327
		const state = `${callbackEnvironment}${port},${encodeURIComponent(nonce)},${encodeURIComponent(callbackUri.query)}`;
328 329 330 331 332 333 334 335 336
		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);

337
		const timeoutPromise = new Promise((_: (value: IToken) => void, reject) => {
338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354
			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;

355 356
					// Workaround double encoding issues of state in web
					if (query.state !== state && decodeURIComponent(query.state) !== state) {
357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376
						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;
		});
	}

377
	private async setToken(token: IToken, scope: string): Promise<void> {
378 379 380
		const existingTokenIndex = this._tokens.findIndex(t => t.sessionId === token.sessionId);
		if (existingTokenIndex > -1) {
			this._tokens.splice(existingTokenIndex, 1, token);
381 382 383
		} else {
			this._tokens.push(token);
		}
384

385
		this.clearSessionTimeout(token.sessionId);
386

387 388 389
		if (token.expiresIn) {
			this._refreshTimeouts.set(token.sessionId, setTimeout(async () => {
				try {
390
					await this.refreshToken(token.refreshToken, scope, token.sessionId);
391
					onDidChangeSessions.fire({ added: [], removed: [], changed: [token.sessionId] });
392 393
				} catch (e) {
					if (e.message === REFRESH_NETWORK_FAILURE) {
394 395 396 397
						const didSucceedOnRetry = await this.handleRefreshNetworkError(token.sessionId, token.refreshToken, scope);
						if (!didSucceedOnRetry) {
							this.pollForReconnect(token.sessionId, token.refreshToken, token.scope);
						}
398 399
					} else {
						await this.logout(token.sessionId);
400
						onDidChangeSessions.fire({ added: [], removed: [token.sessionId], changed: [] });
401 402 403 404
					}
				}
			}, 1000 * (parseInt(token.expiresIn) - 30)));
		}
405

406
		this.storeTokenData();
407 408
	}

409
	private getTokenFromResponse(buffer: Buffer[], scope: string, existingId?: string): IToken {
410 411 412 413
		const json = JSON.parse(Buffer.concat(buffer).toString());
		const claims = this.getTokenClaims(json.access_token);
		return {
			expiresIn: json.expires_in,
414
			expiresAt: json.expires_in ? Date.now() + json.expires_in * 1000 : undefined,
415 416 417
			accessToken: json.access_token,
			refreshToken: json.refresh_token,
			scope,
418
			sessionId: existingId || `${claims.tid}/${(claims.oid || (claims.altsecid || '' + claims.ipd || ''))}/${uuid()}`,
419
			accountName: claims.email || claims.unique_name || 'user@example.com'
420 421 422 423
		};
	}

	private async exchangeCodeForToken(code: string, codeVerifier: string, scope: string): Promise<IToken> {
424
		return new Promise((resolve: (value: IToken) => void, reject) => {
425
			Logger.info('Exchanging login code for token');
426 427 428 429 430
			try {
				const postData = querystring.stringify({
					grant_type: 'authorization_code',
					code: code,
					client_id: clientId,
431
					scope: scope,
432 433 434 435
					code_verifier: codeVerifier,
					redirect_uri: redirectUrl
				});

436
				const tokenUrl = vscode.Uri.parse(`${loginEndpointUrl}${tenant}/oauth2/v2.0/token`);
437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452

				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) {
453
							Logger.info('Exchanging login code for token success');
454
							resolve(this.getTokenFromResponse(buffer, scope));
455
						} else {
456
							Logger.error('Exchanging login code for token failed');
457 458 459 460 461 462 463 464 465 466 467 468 469
							reject(new Error('Unable to login.'));
						}
					});
				});

				post.write(postData);

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

			} catch (e) {
470
				Logger.error(e.message);
471 472 473 474 475
				reject(e);
			}
		});
	}

476
	private async refreshToken(refreshToken: string, scope: string, sessionId: string): Promise<IToken> {
477
		return new Promise((resolve: (value: IToken) => void, reject) => {
478
			Logger.info('Refreshing token...');
479 480 481 482
			const postData = querystring.stringify({
				refresh_token: refreshToken,
				client_id: clientId,
				grant_type: 'refresh_token',
483
				scope: scope
484 485 486 487
			});

			const post = https.request({
				host: 'login.microsoftonline.com',
488
				path: `/${tenant}/oauth2/v2.0/token`,
489 490 491 492 493 494 495 496 497 498
				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);
				});
499
				result.on('end', async () => {
500
					if (result.statusCode === 200) {
501
						const token = this.getTokenFromResponse(buffer, scope, sessionId);
502
						this.setToken(token, scope);
503
						Logger.info('Token refresh success');
504 505
						resolve(token);
					} else {
506
						Logger.error('Refreshing token failed');
507
						reject(new Error('Refreshing token failed.'));
508 509 510 511 512 513 514 515
					}
				});
			});

			post.write(postData);

			post.end();
			post.on('error', err => {
516
				Logger.error(err.message);
517
				reject(new Error(REFRESH_NETWORK_FAILURE));
518 519 520 521
			});
		});
	}

522 523 524 525 526 527 528 529 530
	private clearSessionTimeout(sessionId: string): void {
		const timeout = this._refreshTimeouts.get(sessionId);
		if (timeout) {
			clearTimeout(timeout);
			this._refreshTimeouts.delete(sessionId);
		}
	}

	private removeInMemorySessionData(sessionId: string) {
531 532 533 534 535
		const tokenIndex = this._tokens.findIndex(token => token.sessionId === sessionId);
		if (tokenIndex > -1) {
			this._tokens.splice(tokenIndex, 1);
		}

536 537 538
		this.clearSessionTimeout(sessionId);
	}

539 540 541 542 543
	private pollForReconnect(sessionId: string, refreshToken: string, scope: string): void {
		this.clearSessionTimeout(sessionId);

		this._refreshTimeouts.set(sessionId, setTimeout(async () => {
			try {
544
				await this.refreshToken(refreshToken, scope, sessionId);
545 546 547 548 549 550
			} catch (e) {
				this.pollForReconnect(sessionId, refreshToken, scope);
			}
		}, 1000 * 60 * 30));
	}

551 552 553 554 555
	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);
556 557
			}

558 559 560 561
			if (attempts === 1) {
				const token = this._tokens.find(token => token.sessionId === sessionId);
				if (token) {
					token.accessToken = undefined;
562
					onDidChangeSessions.fire({ added: [], removed: [], changed: [token.sessionId] });
563 564
				}
			}
565

566
			const delayBeforeRetry = 5 * attempts * attempts;
567

568
			this.clearSessionTimeout(sessionId);
569

570 571
			this._refreshTimeouts.set(sessionId, setTimeout(async () => {
				try {
572
					await this.refreshToken(refreshToken, scope, sessionId);
573 574 575 576 577 578
					return resolve(true);
				} catch (e) {
					return resolve(await this.handleRefreshNetworkError(sessionId, refreshToken, scope, attempts + 1));
				}
			}, 1000 * delayBeforeRetry));
		});
579 580 581 582 583 584
	}

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

585 586 587 588 589
		if (this._tokens.length === 0) {
			await keychain.deleteToken();
		} else {
			this.storeTokenData();
		}
590
	}
591 592 593 594 595 596 597 598 599 600 601 602

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

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

		this._refreshTimeouts.clear();
	}
603
}