Path: blob/main/extensions/microsoft-authentication/src/AADHelper.ts
3314 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import * as vscode from 'vscode';6import * as path from 'path';7import { isSupportedEnvironment } from './common/uri';8import { IntervalTimer, raceCancellationAndTimeoutError, SequencerByKey } from './common/async';9import { generateCodeChallenge, generateCodeVerifier, randomUUID } from './cryptoUtils';10import { BetterTokenStorage, IDidChangeInOtherWindowEvent } from './betterSecretStorage';11import { LoopbackAuthServer } from './node/authServer';12import { base64Decode } from './node/buffer';13import fetch from './node/fetch';14import { UriEventHandler } from './UriEventHandler';15import TelemetryReporter from '@vscode/extension-telemetry';16import { Environment } from '@azure/ms-rest-azure-env';1718const redirectUrl = 'https://vscode.dev/redirect';19const defaultActiveDirectoryEndpointUrl = Environment.AzureCloud.activeDirectoryEndpointUrl;20const DEFAULT_CLIENT_ID = 'aebc6443-996d-45c2-90f0-388ff96faa56';21const DEFAULT_TENANT = 'organizations';22const MSA_TID = '9188040d-6c67-4c5b-b112-36a304b66dad';23const MSA_PASSTHRU_TID = 'f8cdef31-a31e-4b4a-93e4-5f571e91255a';2425const enum MicrosoftAccountType {26AAD = 'aad',27MSA = 'msa',28Unknown = 'unknown'29}3031interface IToken {32accessToken?: string; // When unable to refresh due to network problems, the access token becomes undefined33idToken?: string; // depending on the scopes can be either supplied or empty3435expiresIn?: number; // How long access token is valid, in seconds36expiresAt?: number; // UNIX epoch time at which token will expire37refreshToken: string;3839account: {40label: string;41id: string;42type: MicrosoftAccountType;43};44scope: string;45sessionId: string; // The account id + the scope46}4748export interface IStoredSession {49id: string;50refreshToken: string;51scope: string; // Scopes are alphabetized and joined with a space52account: {53label: string;54id: string;55};56endpoint: string | undefined;57}5859export interface ITokenResponse {60access_token: string;61expires_in: number;62ext_expires_in: number;63refresh_token: string;64scope: string;65token_type: string;66id_token?: string;67}6869export interface IMicrosoftTokens {70accessToken: string;71idToken?: string;72}7374interface IScopeData {75originalScopes?: string[];76scopes: string[];77scopeStr: string;78scopesToSend: string;79clientId: string;80tenant: string;81}8283export const REFRESH_NETWORK_FAILURE = 'Network failure';8485export class AzureActiveDirectoryService {86// For details on why this is set to 2/3... see https://github.com/microsoft/vscode/issues/133201#issuecomment-96666819787private static REFRESH_TIMEOUT_MODIFIER = 1000 * 2 / 3;88private static POLLING_CONSTANT = 1000 * 60 * 30;8990private _tokens: IToken[] = [];91private _refreshTimeouts: Map<string, NodeJS.Timeout> = new Map<string, NodeJS.Timeout>();92private _sessionChangeEmitter: vscode.EventEmitter<vscode.AuthenticationProviderAuthenticationSessionsChangeEvent> = new vscode.EventEmitter<vscode.AuthenticationProviderAuthenticationSessionsChangeEvent>();9394// Used to keep track of current requests when not using the local server approach.95private _pendingNonces = new Map<string, string[]>();96private _codeExchangePromises = new Map<string, Promise<vscode.AuthenticationSession>>();97private _codeVerfifiers = new Map<string, string>();9899// Used to keep track of tokens that we need to store but can't because we aren't the focused window.100private _pendingTokensToStore: Map<string, IToken> = new Map<string, IToken>();101102// Used to sequence requests to the same scope.103private _sequencer = new SequencerByKey<string>();104105constructor(106private readonly _logger: vscode.LogOutputChannel,107_context: vscode.ExtensionContext,108private readonly _uriHandler: UriEventHandler,109private readonly _tokenStorage: BetterTokenStorage<IStoredSession>,110private readonly _telemetryReporter: TelemetryReporter,111private readonly _env: Environment112) {113_context.subscriptions.push(this._tokenStorage.onDidChangeInOtherWindow((e) => this.checkForUpdates(e)));114_context.subscriptions.push(vscode.window.onDidChangeWindowState(async (e) => e.focused && await this.storePendingTokens()));115116// In the event that a window isn't focused for a long time, we should still try to store the tokens at some point.117const timer = new IntervalTimer();118timer.cancelAndSet(119() => !vscode.window.state.focused && this.storePendingTokens(),120// 5 hours + random extra 0-30 seconds so that each window doesn't try to store at the same time121(18000000) + Math.floor(Math.random() * 30000));122_context.subscriptions.push(timer);123}124125public async initialize(): Promise<void> {126this._logger.trace('Reading sessions from secret storage...');127const sessions = await this._tokenStorage.getAll(item => this.sessionMatchesEndpoint(item));128this._logger.trace(`Got ${sessions.length} stored sessions`);129130const refreshes = sessions.map(async session => {131this._logger.trace(`[${session.scope}] '${session.id}' Read stored session`);132const scopes = session.scope.split(' ');133const scopeData: IScopeData = {134scopes,135scopeStr: session.scope,136// filter our special scopes137scopesToSend: scopes.filter(s => !s.startsWith('VSCODE_')).join(' '),138clientId: this.getClientId(scopes),139tenant: this.getTenantId(scopes),140};141try {142await this.refreshToken(session.refreshToken, scopeData, session.id);143} catch (e) {144// If we aren't connected to the internet, then wait and try to refresh again later.145if (e.message === REFRESH_NETWORK_FAILURE) {146this._tokens.push({147accessToken: undefined,148refreshToken: session.refreshToken,149account: {150...session.account,151type: MicrosoftAccountType.Unknown152},153scope: session.scope,154sessionId: session.id155});156} else {157vscode.window.showErrorMessage(vscode.l10n.t('You have been signed out because reading stored authentication information failed.'));158this._logger.error(e);159await this.removeSessionByIToken({160accessToken: undefined,161refreshToken: session.refreshToken,162account: {163...session.account,164type: MicrosoftAccountType.Unknown165},166scope: session.scope,167sessionId: session.id168});169}170}171});172173const result = await Promise.allSettled(refreshes);174for (const res of result) {175if (res.status === 'rejected') {176this._logger.error(`Failed to initialize stored data: ${res.reason}`);177this.clearSessions();178break;179}180}181182for (const token of this._tokens) {183/* __GDPR__184"account" : {185"owner": "TylerLeonhardt",186"comment": "Used to determine the usage of the Microsoft Auth Provider.",187"scopes": { "classification": "PublicNonPersonalData", "purpose": "FeatureInsight", "comment": "Used to determine what scope combinations are being requested." },188"accountType": { "classification": "PublicNonPersonalData", "purpose": "FeatureInsight", "comment": "Used to determine what account types are being used." }189}190*/191this._telemetryReporter.sendTelemetryEvent('account', {192// Get rid of guids from telemetry.193scopes: JSON.stringify(token.scope.replace(/[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}/i, '{guid}').split(' ')),194accountType: token.account.type195});196}197}198199//#region session operations200201public get onDidChangeSessions(): vscode.Event<vscode.AuthenticationProviderAuthenticationSessionsChangeEvent> {202return this._sessionChangeEmitter.event;203}204205public getSessions(scopes: string[] | undefined, { account, authorizationServer }: vscode.AuthenticationProviderSessionOptions = {}): Promise<vscode.AuthenticationSession[]> {206if (!scopes) {207this._logger.info('Getting sessions for all scopes...');208const sessions = this._tokens209.filter(token => !account?.label || token.account.label === account.label)210.map(token => this.convertToSessionSync(token));211this._logger.info(`Got ${sessions.length} sessions for all scopes${account ? ` for account '${account.label}'` : ''}...`);212return Promise.resolve(sessions);213}214215let modifiedScopes = [...scopes];216if (!modifiedScopes.includes('openid')) {217modifiedScopes.push('openid');218}219if (!modifiedScopes.includes('email')) {220modifiedScopes.push('email');221}222if (!modifiedScopes.includes('profile')) {223modifiedScopes.push('profile');224}225if (!modifiedScopes.includes('offline_access')) {226modifiedScopes.push('offline_access');227}228if (authorizationServer) {229const tenant = authorizationServer.path.split('/')[1];230if (tenant) {231modifiedScopes.push(`VSCODE_TENANT:${tenant}`);232}233}234modifiedScopes = modifiedScopes.sort();235236const modifiedScopesStr = modifiedScopes.join(' ');237const clientId = this.getClientId(scopes);238const scopeData: IScopeData = {239clientId,240originalScopes: scopes,241scopes: modifiedScopes,242scopeStr: modifiedScopesStr,243// filter our special scopes244scopesToSend: modifiedScopes.filter(s => !s.startsWith('VSCODE_')).join(' '),245tenant: this.getTenantId(modifiedScopes),246};247248this._logger.trace(`[${scopeData.scopeStr}] Queued getting sessions` + account ? ` for ${account?.label}` : '');249return this._sequencer.queue(modifiedScopesStr, () => this.doGetSessions(scopeData, account));250}251252private async doGetSessions(scopeData: IScopeData, account?: vscode.AuthenticationSessionAccountInformation): Promise<vscode.AuthenticationSession[]> {253this._logger.info(`[${scopeData.scopeStr}] Getting sessions` + account ? ` for ${account?.label}` : '');254255const matchingTokens = this._tokens256.filter(token => token.scope === scopeData.scopeStr)257.filter(token => !account?.label || token.account.label === account.label);258// If we still don't have a matching token try to get a new token from an existing token by using259// the refreshToken. This is documented here:260// https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow#refresh-the-access-token261// "Refresh tokens are valid for all permissions that your client has already received consent for."262if (!matchingTokens.length) {263// Get a token with the correct client id and account.264let token: IToken | undefined;265for (const t of this._tokens) {266// No refresh token, so we can't make a new token from this session267if (!t.refreshToken) {268continue;269}270// Need to make sure the account matches if we were provided one271if (account?.label && t.account.label !== account.label) {272continue;273}274// If the client id is the default client id, then check for the absence of the VSCODE_CLIENT_ID scope275if (scopeData.clientId === DEFAULT_CLIENT_ID && !t.scope.includes('VSCODE_CLIENT_ID')) {276token = t;277break;278}279// If the client id is not the default client id, then check for the matching VSCODE_CLIENT_ID scope280if (scopeData.clientId !== DEFAULT_CLIENT_ID && t.scope.includes(`VSCODE_CLIENT_ID:${scopeData.clientId}`)) {281token = t;282break;283}284}285286if (token) {287this._logger.trace(`[${scopeData.scopeStr}] '${token.sessionId}' Found a matching token with a different scopes '${token.scope}'. Attempting to get a new session using the existing session.`);288try {289const itoken = await this.doRefreshToken(token.refreshToken, scopeData);290this._sessionChangeEmitter.fire({ added: [this.convertToSessionSync(itoken)], removed: [], changed: [] });291matchingTokens.push(itoken);292} catch (err) {293this._logger.error(`[${scopeData.scopeStr}] Attempted to get a new session using the existing session with scopes '${token.scope}' but it failed due to: ${err.message ?? err}`);294}295}296}297298this._logger.info(`[${scopeData.scopeStr}] Got ${matchingTokens.length} sessions`);299const results = await Promise.allSettled(matchingTokens.map(token => this.convertToSession(token, scopeData)));300return results301.filter(result => result.status === 'fulfilled')302.map(result => (result as PromiseFulfilledResult<vscode.AuthenticationSession>).value);303}304305public createSession(scopes: string[], { account, authorizationServer }: vscode.AuthenticationProviderSessionOptions = {}): Promise<vscode.AuthenticationSession> {306let modifiedScopes = [...scopes];307if (!modifiedScopes.includes('openid')) {308modifiedScopes.push('openid');309}310if (!modifiedScopes.includes('email')) {311modifiedScopes.push('email');312}313if (!modifiedScopes.includes('profile')) {314modifiedScopes.push('profile');315}316if (!modifiedScopes.includes('offline_access')) {317modifiedScopes.push('offline_access');318}319if (authorizationServer) {320const tenant = authorizationServer.path.split('/')[1];321if (tenant) {322modifiedScopes.push(`VSCODE_TENANT:${tenant}`);323}324}325modifiedScopes = modifiedScopes.sort();326const scopeData: IScopeData = {327originalScopes: scopes,328scopes: modifiedScopes,329scopeStr: modifiedScopes.join(' '),330// filter our special scopes331scopesToSend: modifiedScopes.filter(s => !s.startsWith('VSCODE_')).join(' '),332clientId: this.getClientId(scopes),333tenant: this.getTenantId(modifiedScopes),334};335336this._logger.trace(`[${scopeData.scopeStr}] Queued creating session`);337return this._sequencer.queue(scopeData.scopeStr, () => this.doCreateSession(scopeData, account));338}339340private async doCreateSession(scopeData: IScopeData, account?: vscode.AuthenticationSessionAccountInformation): Promise<vscode.AuthenticationSession> {341this._logger.info(`[${scopeData.scopeStr}] Creating session` + account ? ` for ${account?.label}` : '');342343const runsRemote = vscode.env.remoteName !== undefined;344const runsServerless = vscode.env.remoteName === undefined && vscode.env.uiKind === vscode.UIKind.Web;345346if (runsServerless && this._env.activeDirectoryEndpointUrl !== defaultActiveDirectoryEndpointUrl) {347throw new Error('Sign in to non-public clouds is not supported on the web.');348}349350return await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: vscode.l10n.t('Signing in to your account...'), cancellable: true }, async (_progress, token) => {351if (runsRemote || runsServerless) {352return await this.createSessionWithoutLocalServer(scopeData, account?.label, token);353}354355try {356return await this.createSessionWithLocalServer(scopeData, account?.label, token);357} catch (e) {358this._logger.error(`[${scopeData.scopeStr}] Error creating session: ${e}`);359360// If the error was about starting the server, try directly hitting the login endpoint instead361if (e.message === 'Error listening to server' || e.message === 'Closed' || e.message === 'Timeout waiting for port') {362return this.createSessionWithoutLocalServer(scopeData, account?.label, token);363}364365throw e;366}367});368}369370private async createSessionWithLocalServer(scopeData: IScopeData, loginHint: string | undefined, token: vscode.CancellationToken): Promise<vscode.AuthenticationSession> {371this._logger.trace(`[${scopeData.scopeStr}] Starting login flow with local server`);372const codeVerifier = generateCodeVerifier();373const codeChallenge = await generateCodeChallenge(codeVerifier);374const qs = new URLSearchParams({375response_type: 'code',376response_mode: 'query',377client_id: scopeData.clientId,378redirect_uri: redirectUrl,379scope: scopeData.scopesToSend,380code_challenge_method: 'S256',381code_challenge: codeChallenge,382});383if (loginHint) {384qs.set('login_hint', loginHint);385} else {386qs.set('prompt', 'select_account');387}388const loginUrl = new URL(`${scopeData.tenant}/oauth2/v2.0/authorize?${qs.toString()}`, this._env.activeDirectoryEndpointUrl).toString();389const server = new LoopbackAuthServer(path.join(__dirname, '../media'), loginUrl);390await server.start();391392let codeToExchange;393try {394vscode.env.openExternal(vscode.Uri.parse(`http://127.0.0.1:${server.port}/signin?nonce=${encodeURIComponent(server.nonce)}`));395const { code } = await raceCancellationAndTimeoutError(server.waitForOAuthResponse(), token, 1000 * 60 * 5); // 5 minutes396codeToExchange = code;397} finally {398setTimeout(() => {399void server.stop();400}, 5000);401}402403const session = await this.exchangeCodeForSession(codeToExchange, codeVerifier, scopeData);404this._logger.trace(`[${scopeData.scopeStr}] '${session.id}' Sending change event for added session`);405this._sessionChangeEmitter.fire({ added: [session], removed: [], changed: [] });406this._logger.info(`[${scopeData.scopeStr}] '${session.id}' session successfully created!`);407return session;408}409410private async createSessionWithoutLocalServer(scopeData: IScopeData, loginHint: string | undefined, token: vscode.CancellationToken): Promise<vscode.AuthenticationSession> {411this._logger.trace(`[${scopeData.scopeStr}] Starting login flow without local server`);412let callbackUri = await vscode.env.asExternalUri(vscode.Uri.parse(`${vscode.env.uriScheme}://vscode.microsoft-authentication`));413const nonce = generateCodeVerifier();414const callbackQuery = new URLSearchParams(callbackUri.query);415callbackQuery.set('nonce', encodeURIComponent(nonce));416callbackUri = callbackUri.with({417query: callbackQuery.toString()418});419const state = encodeURIComponent(callbackUri.toString(true));420const codeVerifier = generateCodeVerifier();421const codeChallenge = await generateCodeChallenge(codeVerifier);422const signInUrl = new URL(`${scopeData.tenant}/oauth2/v2.0/authorize`, this._env.activeDirectoryEndpointUrl);423const qs = new URLSearchParams({424response_type: 'code',425client_id: encodeURIComponent(scopeData.clientId),426response_mode: 'query',427redirect_uri: redirectUrl,428state,429scope: scopeData.scopesToSend,430code_challenge_method: 'S256',431code_challenge: codeChallenge,432});433if (loginHint) {434qs.append('login_hint', loginHint);435} else {436qs.append('prompt', 'select_account');437}438signInUrl.search = qs.toString();439const uri = vscode.Uri.parse(signInUrl.toString());440vscode.env.openExternal(uri);441442443const existingNonces = this._pendingNonces.get(scopeData.scopeStr) || [];444this._pendingNonces.set(scopeData.scopeStr, [...existingNonces, nonce]);445446// Register a single listener for the URI callback, in case the user starts the login process multiple times447// before completing it.448let existingPromise = this._codeExchangePromises.get(scopeData.scopeStr);449let inputBox: vscode.InputBox | undefined;450if (!existingPromise) {451if (isSupportedEnvironment(callbackUri)) {452existingPromise = this.handleCodeResponse(scopeData);453} else {454inputBox = vscode.window.createInputBox();455existingPromise = this.handleCodeInputBox(inputBox, codeVerifier, scopeData);456}457this._codeExchangePromises.set(scopeData.scopeStr, existingPromise);458}459460this._codeVerfifiers.set(nonce, codeVerifier);461462return await raceCancellationAndTimeoutError(existingPromise, token, 1000 * 60 * 5) // 5 minutes463.finally(() => {464this._pendingNonces.delete(scopeData.scopeStr);465this._codeExchangePromises.delete(scopeData.scopeStr);466this._codeVerfifiers.delete(nonce);467inputBox?.dispose();468});469}470471public async removeSessionById(sessionId: string, writeToDisk: boolean = true): Promise<vscode.AuthenticationSession | undefined> {472const tokenIndex = this._tokens.findIndex(token => token.sessionId === sessionId);473if (tokenIndex === -1) {474this._logger.warn(`'${sessionId}' Session not found to remove`);475return Promise.resolve(undefined);476}477478const token = this._tokens.splice(tokenIndex, 1)[0];479this._logger.trace(`[${token.scope}] '${sessionId}' Queued removing session`);480return this._sequencer.queue(token.scope, () => this.removeSessionByIToken(token, writeToDisk));481}482483public async clearSessions() {484this._logger.trace('Logging out of all sessions');485this._tokens = [];486await this._tokenStorage.deleteAll(item => this.sessionMatchesEndpoint(item));487488this._refreshTimeouts.forEach(timeout => {489clearTimeout(timeout);490});491492this._refreshTimeouts.clear();493this._logger.trace('All sessions logged out');494}495496private async removeSessionByIToken(token: IToken, writeToDisk: boolean = true): Promise<vscode.AuthenticationSession | undefined> {497this._logger.info(`[${token.scope}] '${token.sessionId}' Logging out of session`);498this.removeSessionTimeout(token.sessionId);499500if (writeToDisk) {501await this._tokenStorage.delete(token.sessionId);502}503504const tokenIndex = this._tokens.findIndex(t => t.sessionId === token.sessionId);505if (tokenIndex !== -1) {506this._tokens.splice(tokenIndex, 1);507}508509const session = this.convertToSessionSync(token);510this._logger.trace(`[${token.scope}] '${token.sessionId}' Sending change event for session that was removed`);511this._sessionChangeEmitter.fire({ added: [], removed: [session], changed: [] });512this._logger.info(`[${token.scope}] '${token.sessionId}' Logged out of session successfully!`);513return session;514}515516//#endregion517518//#region timeout519520private setSessionTimeout(sessionId: string, refreshToken: string, scopeData: IScopeData, timeout: number) {521this._logger.trace(`[${scopeData.scopeStr}] '${sessionId}' Setting refresh timeout for ${timeout} milliseconds`);522this.removeSessionTimeout(sessionId);523this._refreshTimeouts.set(sessionId, setTimeout(async () => {524try {525const refreshedToken = await this.refreshToken(refreshToken, scopeData, sessionId);526this._logger.trace(`[${scopeData.scopeStr}] '${sessionId}' Sending change event for session that was refreshed`);527this._sessionChangeEmitter.fire({ added: [], removed: [], changed: [this.convertToSessionSync(refreshedToken)] });528this._logger.trace(`[${scopeData.scopeStr}] '${sessionId}' refresh timeout complete`);529} catch (e) {530if (e.message !== REFRESH_NETWORK_FAILURE) {531vscode.window.showErrorMessage(vscode.l10n.t('You have been signed out because reading stored authentication information failed.'));532await this.removeSessionById(sessionId);533}534}535}, timeout));536}537538private removeSessionTimeout(sessionId: string): void {539const timeout = this._refreshTimeouts.get(sessionId);540if (timeout) {541clearTimeout(timeout);542this._refreshTimeouts.delete(sessionId);543}544}545546//#endregion547548//#region convert operations549550private convertToTokenSync(json: ITokenResponse, scopeData: IScopeData, existingId?: string): IToken {551let claims = undefined;552this._logger.trace(`[${scopeData.scopeStr}] '${existingId ?? 'new'}' Attempting to parse token response.`);553554try {555if (json.id_token) {556claims = JSON.parse(base64Decode(json.id_token.split('.')[1]));557} else {558this._logger.warn(`[${scopeData.scopeStr}] '${existingId ?? 'new'}' Attempting to parse access_token instead since no id_token was included in the response.`);559claims = JSON.parse(base64Decode(json.access_token.split('.')[1]));560}561} catch (e) {562throw e;563}564565const id = `${claims.tid}/${(claims.oid ?? (claims.altsecid ?? '' + claims.ipd))}`;566const sessionId = existingId || `${id}/${randomUUID()}`;567this._logger.trace(`[${scopeData.scopeStr}] '${sessionId}' Token response parsed successfully.`);568return {569expiresIn: json.expires_in,570expiresAt: json.expires_in ? Date.now() + json.expires_in * 1000 : undefined,571accessToken: json.access_token,572idToken: json.id_token,573refreshToken: json.refresh_token,574scope: scopeData.scopeStr,575sessionId,576account: {577label: claims.preferred_username ?? claims.email ?? claims.unique_name ?? '[email protected]',578id,579type: claims.tid === MSA_TID || claims.tid === MSA_PASSTHRU_TID ? MicrosoftAccountType.MSA : MicrosoftAccountType.AAD580}581};582}583584/**585* Return a session object without checking for expiry and potentially refreshing.586* @param token The token information.587*/588private convertToSessionSync(token: IToken): vscode.AuthenticationSession {589return {590id: token.sessionId,591accessToken: token.accessToken!,592idToken: token.idToken,593account: token.account,594scopes: token.scope.split(' ')595};596}597598private async convertToSession(token: IToken, scopeData: IScopeData): Promise<vscode.AuthenticationSession> {599if (token.accessToken && (!token.expiresAt || token.expiresAt > Date.now())) {600this._logger.trace(`[${scopeData.scopeStr}] '${token.sessionId}' Token available from cache${token.expiresAt ? `, expires in ${token.expiresAt - Date.now()} milliseconds` : ''}.`);601return {602id: token.sessionId,603accessToken: token.accessToken,604idToken: token.idToken,605account: token.account,606scopes: scopeData.originalScopes ?? scopeData.scopes607};608}609610try {611this._logger.trace(`[${scopeData.scopeStr}] '${token.sessionId}' Token expired or unavailable, trying refresh`);612const refreshedToken = await this.refreshToken(token.refreshToken, scopeData, token.sessionId);613if (refreshedToken.accessToken) {614return {615id: token.sessionId,616accessToken: refreshedToken.accessToken,617idToken: refreshedToken.idToken,618account: token.account,619// We always prefer the original scopes requested since that array is used as a key in the AuthService620scopes: scopeData.originalScopes ?? scopeData.scopes621};622} else {623throw new Error();624}625} catch (e) {626throw new Error('Unavailable due to network problems');627}628}629630//#endregion631632//#region refresh logic633634private refreshToken(refreshToken: string, scopeData: IScopeData, sessionId?: string): Promise<IToken> {635this._logger.trace(`[${scopeData.scopeStr}] '${sessionId ?? 'new'}' Queued refreshing token`);636return this._sequencer.queue(scopeData.scopeStr, () => this.doRefreshToken(refreshToken, scopeData, sessionId));637}638639private async doRefreshToken(refreshToken: string, scopeData: IScopeData, sessionId?: string): Promise<IToken> {640this._logger.trace(`[${scopeData.scopeStr}] '${sessionId ?? 'new'}' Refreshing token`);641const postData = new URLSearchParams({642refresh_token: refreshToken,643client_id: scopeData.clientId,644grant_type: 'refresh_token',645scope: scopeData.scopesToSend646}).toString();647648try {649const json = await this.fetchTokenResponse(postData, scopeData);650const token = this.convertToTokenSync(json, scopeData, sessionId);651if (token.expiresIn) {652this.setSessionTimeout(token.sessionId, token.refreshToken, scopeData, token.expiresIn * AzureActiveDirectoryService.REFRESH_TIMEOUT_MODIFIER);653}654this.setToken(token, scopeData);655this._logger.trace(`[${scopeData.scopeStr}] '${token.sessionId}' Token refresh success`);656return token;657} catch (e) {658if (e.message === REFRESH_NETWORK_FAILURE) {659// We were unable to refresh because of a network failure (i.e. the user lost internet access).660// so set up a timeout to try again later. We only do this if we have a session id to reference later.661if (sessionId) {662this.setSessionTimeout(sessionId, refreshToken, scopeData, AzureActiveDirectoryService.POLLING_CONSTANT);663}664throw e;665}666this._logger.error(`[${scopeData.scopeStr}] '${sessionId ?? 'new'}' Refreshing token failed: ${e.message}`);667throw e;668}669}670671//#endregion672673//#region scope parsers674675private getClientId(scopes: string[]) {676return scopes.reduce<string | undefined>((prev, current) => {677if (current.startsWith('VSCODE_CLIENT_ID:')) {678return current.split('VSCODE_CLIENT_ID:')[1];679}680return prev;681}, undefined) ?? DEFAULT_CLIENT_ID;682}683684private getTenantId(scopes: string[]) {685return scopes.reduce<string | undefined>((prev, current) => {686if (current.startsWith('VSCODE_TENANT:')) {687return current.split('VSCODE_TENANT:')[1];688}689return prev;690}, undefined) ?? DEFAULT_TENANT;691}692693//#endregion694695//#region oauth flow696697private async handleCodeResponse(scopeData: IScopeData): Promise<vscode.AuthenticationSession> {698let uriEventListener: vscode.Disposable;699return new Promise((resolve: (value: vscode.AuthenticationSession) => void, reject) => {700uriEventListener = this._uriHandler.event(async (uri: vscode.Uri) => {701try {702const query = new URLSearchParams(uri.query);703let code = query.get('code');704let nonce = query.get('nonce');705if (Array.isArray(code)) {706code = code[0];707}708if (!code) {709throw new Error('No code included in query');710}711if (Array.isArray(nonce)) {712nonce = nonce[0];713}714if (!nonce) {715throw new Error('No nonce included in query');716}717718const acceptedStates = this._pendingNonces.get(scopeData.scopeStr) || [];719// Workaround double encoding issues of state in web720if (!acceptedStates.includes(nonce) && !acceptedStates.includes(decodeURIComponent(nonce))) {721throw new Error('Nonce does not match.');722}723724const verifier = this._codeVerfifiers.get(nonce) ?? this._codeVerfifiers.get(decodeURIComponent(nonce));725if (!verifier) {726throw new Error('No available code verifier');727}728729const session = await this.exchangeCodeForSession(code, verifier, scopeData);730this._sessionChangeEmitter.fire({ added: [session], removed: [], changed: [] });731this._logger.info(`[${scopeData.scopeStr}] '${session.id}' session successfully created!`);732resolve(session);733} catch (err) {734reject(err);735}736});737}).then(result => {738uriEventListener.dispose();739return result;740}).catch(err => {741uriEventListener.dispose();742throw err;743});744}745746private async handleCodeInputBox(inputBox: vscode.InputBox, verifier: string, scopeData: IScopeData): Promise<vscode.AuthenticationSession> {747this._logger.trace(`[${scopeData.scopeStr}] Starting login flow with input box`);748inputBox.ignoreFocusOut = true;749inputBox.title = vscode.l10n.t('Microsoft Authentication');750inputBox.prompt = vscode.l10n.t('Provide the authorization code to complete the sign in flow.');751inputBox.placeholder = vscode.l10n.t('Paste authorization code here...');752return new Promise((resolve: (value: vscode.AuthenticationSession) => void, reject) => {753inputBox.show();754inputBox.onDidAccept(async () => {755const code = inputBox.value;756if (code) {757inputBox.dispose();758const session = await this.exchangeCodeForSession(code, verifier, scopeData);759this._logger.trace(`[${scopeData.scopeStr}] '${session.id}' sending session changed event because session was added.`);760this._sessionChangeEmitter.fire({ added: [session], removed: [], changed: [] });761this._logger.trace(`[${scopeData.scopeStr}] '${session.id}' session successfully created!`);762resolve(session);763}764});765inputBox.onDidHide(() => {766if (!inputBox.value) {767inputBox.dispose();768reject('Cancelled');769}770});771});772}773774private async exchangeCodeForSession(code: string, codeVerifier: string, scopeData: IScopeData): Promise<vscode.AuthenticationSession> {775this._logger.trace(`[${scopeData.scopeStr}] Exchanging login code for session`);776let token: IToken | undefined;777try {778const postData = new URLSearchParams({779grant_type: 'authorization_code',780code: code,781client_id: scopeData.clientId,782scope: scopeData.scopesToSend,783code_verifier: codeVerifier,784redirect_uri: redirectUrl785}).toString();786787const json = await this.fetchTokenResponse(postData, scopeData);788this._logger.trace(`[${scopeData.scopeStr}] Exchanging code for token succeeded!`);789token = this.convertToTokenSync(json, scopeData);790} catch (e) {791this._logger.error(`[${scopeData.scopeStr}] Error exchanging code for token: ${e}`);792throw e;793}794795if (token.expiresIn) {796this.setSessionTimeout(token.sessionId, token.refreshToken, scopeData, token.expiresIn * AzureActiveDirectoryService.REFRESH_TIMEOUT_MODIFIER);797}798this.setToken(token, scopeData);799this._logger.trace(`[${scopeData.scopeStr}] '${token.sessionId}' Exchanging login code for session succeeded!`);800return await this.convertToSession(token, scopeData);801}802803private async fetchTokenResponse(postData: string, scopeData: IScopeData): Promise<ITokenResponse> {804let endpointUrl: string;805if (this._env.activeDirectoryEndpointUrl !== defaultActiveDirectoryEndpointUrl) {806// If this is for sovereign clouds, don't try using the proxy endpoint, which supports only public cloud807endpointUrl = this._env.activeDirectoryEndpointUrl;808} else {809const proxyEndpoints: { [providerId: string]: string } | undefined = await vscode.commands.executeCommand('workbench.getCodeExchangeProxyEndpoints');810endpointUrl = proxyEndpoints?.microsoft || this._env.activeDirectoryEndpointUrl;811}812const endpoint = new URL(`${scopeData.tenant}/oauth2/v2.0/token`, endpointUrl);813814let attempts = 0;815while (attempts <= 3) {816attempts++;817let result;818let errorMessage: string | undefined;819try {820result = await fetch(endpoint.toString(), {821method: 'POST',822headers: {823'Content-Type': 'application/x-www-form-urlencoded'824},825body: postData826});827} catch (e) {828errorMessage = e.message ?? e;829}830831if (!result || result.status > 499) {832if (attempts > 3) {833this._logger.error(`[${scopeData.scopeStr}] Fetching token failed: ${result ? await result.text() : errorMessage}`);834break;835}836// Exponential backoff837await new Promise(resolve => setTimeout(resolve, 5 * attempts * attempts * 1000));838continue;839} else if (!result.ok) {840// For 4XX errors, the user may actually have an expired token or have changed841// their password recently which is throwing a 4XX. For this, we throw an error842// so that the user can be prompted to sign in again.843throw new Error(await result.text());844}845846return await result.json() as ITokenResponse;847}848849throw new Error(REFRESH_NETWORK_FAILURE);850}851852//#endregion853854//#region storage operations855856private setToken(token: IToken, scopeData: IScopeData): void {857this._logger.trace(`[${scopeData.scopeStr}] '${token.sessionId}' Setting token`);858859const existingTokenIndex = this._tokens.findIndex(t => t.sessionId === token.sessionId);860if (existingTokenIndex > -1) {861this._tokens.splice(existingTokenIndex, 1, token);862} else {863this._tokens.push(token);864}865866// Don't await because setting the token is only useful for any new windows that open.867void this.storeToken(token, scopeData);868}869870private async storeToken(token: IToken, scopeData: IScopeData): Promise<void> {871if (!vscode.window.state.focused) {872if (this._pendingTokensToStore.has(token.sessionId)) {873this._logger.trace(`[${scopeData.scopeStr}] '${token.sessionId}' Window is not focused, replacing token to be stored`);874} else {875this._logger.trace(`[${scopeData.scopeStr}] '${token.sessionId}' Window is not focused, pending storage of token`);876}877this._pendingTokensToStore.set(token.sessionId, token);878return;879}880881await this._tokenStorage.store(token.sessionId, {882id: token.sessionId,883refreshToken: token.refreshToken,884scope: token.scope,885account: token.account,886endpoint: this._env.activeDirectoryEndpointUrl,887});888this._logger.trace(`[${scopeData.scopeStr}] '${token.sessionId}' Stored token`);889}890891private async storePendingTokens(): Promise<void> {892if (this._pendingTokensToStore.size === 0) {893this._logger.trace('No pending tokens to store');894return;895}896897const tokens = [...this._pendingTokensToStore.values()];898this._pendingTokensToStore.clear();899900this._logger.trace(`Storing ${tokens.length} pending tokens...`);901await Promise.allSettled(tokens.map(async token => {902this._logger.trace(`[${token.scope}] '${token.sessionId}' Storing pending token`);903await this._tokenStorage.store(token.sessionId, {904id: token.sessionId,905refreshToken: token.refreshToken,906scope: token.scope,907account: token.account,908endpoint: this._env.activeDirectoryEndpointUrl,909});910this._logger.trace(`[${token.scope}] '${token.sessionId}' Stored pending token`);911}));912this._logger.trace('Done storing pending tokens');913}914915private async checkForUpdates(e: IDidChangeInOtherWindowEvent<IStoredSession>): Promise<void> {916for (const key of e.added) {917const session = await this._tokenStorage.get(key);918if (!session) {919this._logger.error('session not found that was apparently just added');920continue;921}922923if (!this.sessionMatchesEndpoint(session)) {924// If the session wasn't made for this login endpoint, ignore this update925continue;926}927928const matchesExisting = this._tokens.some(token => token.scope === session.scope && token.sessionId === session.id);929if (!matchesExisting && session.refreshToken) {930try {931const scopes = session.scope.split(' ');932const scopeData: IScopeData = {933scopes,934scopeStr: session.scope,935// filter our special scopes936scopesToSend: scopes.filter(s => !s.startsWith('VSCODE_')).join(' '),937clientId: this.getClientId(scopes),938tenant: this.getTenantId(scopes),939};940this._logger.trace(`[${scopeData.scopeStr}] '${session.id}' Session added in another window`);941const token = await this.refreshToken(session.refreshToken, scopeData, session.id);942this._logger.trace(`[${scopeData.scopeStr}] '${token.sessionId}' Sending change event for session that was added`);943this._sessionChangeEmitter.fire({ added: [this.convertToSessionSync(token)], removed: [], changed: [] });944this._logger.trace(`[${scopeData.scopeStr}] '${token.sessionId}' Session added in another window added here`);945continue;946} catch (e) {947// Network failures will automatically retry on next poll.948if (e.message !== REFRESH_NETWORK_FAILURE) {949vscode.window.showErrorMessage(vscode.l10n.t('You have been signed out because reading stored authentication information failed.'));950await this.removeSessionById(session.id);951}952continue;953}954}955}956957for (const { value } of e.removed) {958this._logger.trace(`[${value.scope}] '${value.id}' Session removed in another window`);959if (!this.sessionMatchesEndpoint(value)) {960// If the session wasn't made for this login endpoint, ignore this update961this._logger.trace(`[${value.scope}] '${value.id}' Session doesn't match endpoint. Skipping...`);962continue;963}964965await this.removeSessionById(value.id, false);966this._logger.trace(`[${value.scope}] '${value.id}' Session removed in another window removed here`);967}968969// NOTE: We don't need to handle changed sessions because all that really would give us is a new refresh token970// because access tokens are not stored in Secret Storage due to their short lifespan. This new refresh token971// is not useful in this window because we really only care about the lifetime of the _access_ token which we972// are already managing (see usages of `setSessionTimeout`).973// However, in order to minimize the amount of times we store tokens, if a token was stored via another window,974// we cancel any pending token storage operations.975for (const sessionId of e.updated) {976if (this._pendingTokensToStore.delete(sessionId)) {977this._logger.trace(`'${sessionId}' Cancelled pending token storage because token was updated in another window`);978}979}980}981982private sessionMatchesEndpoint(session: IStoredSession): boolean {983// For older sessions with no endpoint set, it can be assumed to be the default endpoint984session.endpoint ||= defaultActiveDirectoryEndpointUrl;985986return session.endpoint === this._env.activeDirectoryEndpointUrl;987}988989//#endregion990}991992993