AADHelper.ts 19.1 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 81
		// TODO remove, temporary migration
		await keychain.migrateToken();

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

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

		this.pollForChange();
	}

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

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

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

					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);
163
							removedIds.push(token.sessionId);
164 165 166 167 168 169 170
						}
					}));

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

					this._tokens = [];

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

					this._refreshTimeouts.clear();
187
				}
188 189
			}

190 191
			if (addedIds.length || removedIds.length) {
				onDidChangeSessions.fire({ added: addedIds, removed: removedIds, changed: [] });
192 193 194 195
			}

			this.pollForChange();
		}, 1000 * 30);
196 197
	}

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

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

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

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

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

239
	get sessions(): vscode.AuthenticationSession[] {
240
		return this._tokens.map(token => this.convertToSession(token));
241 242
	}

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

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

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

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

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

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

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

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

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

388
		this.clearSessionTimeout(token.sessionId);
389

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

409
		this.storeTokenData();
410 411
	}

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

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

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

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

				post.write(postData);

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

			} catch (e) {
473
				Logger.error(e.message);
474 475 476 477 478
				reject(e);
			}
		});
	}

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

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

			post.write(postData);

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

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

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

539 540 541
		this.clearSessionTimeout(sessionId);
	}

542 543 544 545 546
	private pollForReconnect(sessionId: string, refreshToken: string, scope: string): void {
		this.clearSessionTimeout(sessionId);

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

554 555 556 557 558
	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);
559 560
			}

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

569
			const delayBeforeRetry = 5 * attempts * attempts;
570

571
			this.clearSessionTimeout(sessionId);
572

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

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

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

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

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

		this._refreshTimeouts.clear();
	}
606
}