Path: blob/main/extensions/microsoft-authentication/src/node/authProvider.ts
5220 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*--------------------------------------------------------------------------------------------*/4import { AccountInfo, AuthenticationResult, AuthError, ClientAuthError, ClientAuthErrorCodes, ServerError } from '@azure/msal-node';5import { AuthenticationChallenge, AuthenticationConstraint, AuthenticationGetSessionOptions, AuthenticationProvider, AuthenticationProviderAuthenticationSessionsChangeEvent, AuthenticationProviderSessionOptions, AuthenticationSession, AuthenticationSessionAccountInformation, CancellationError, env, EventEmitter, ExtensionContext, ExtensionKind, l10n, LogOutputChannel, Uri, window } from 'vscode';6import { Environment } from '@azure/ms-rest-azure-env';7import { CachedPublicClientApplicationManager } from './publicClientCache';8import { UriEventHandler } from '../UriEventHandler';9import { ICachedPublicClientApplication, ICachedPublicClientApplicationManager } from '../common/publicClientCache';10import { MicrosoftAccountType, MicrosoftAuthenticationTelemetryReporter } from '../common/telemetryReporter';11import { ScopeData } from '../common/scopeData';12import { EventBufferer } from '../common/event';13import { BetterTokenStorage } from '../betterSecretStorage';14import { ExtensionHost, getMsalFlows } from './flows';15import { base64Decode } from './buffer';16import { Config } from '../common/config';17import { isSupportedClient } from '../common/env';1819const MSA_TID = '9188040d-6c67-4c5b-b112-36a304b66dad';20const MSA_PASSTHRU_TID = 'f8cdef31-a31e-4b4a-93e4-5f571e91255a';2122/**23* Interface for sessions stored from the old authentication flow.24* Used for migration purposes when upgrading to MSAL.25* TODO: Remove this after one or two releases.26*/27export interface IStoredSession {28id: string;29refreshToken: string;30scope: string; // Scopes are alphabetized and joined with a space31account: {32label: string;33id: string;34};35endpoint: string | undefined;36}3738export class MsalAuthProvider implements AuthenticationProvider {3940private readonly _disposables: { dispose(): void }[];41private readonly _eventBufferer = new EventBufferer();4243/**44* Event to signal a change in authentication sessions for this provider.45*/46private readonly _onDidChangeSessionsEmitter = new EventEmitter<AuthenticationProviderAuthenticationSessionsChangeEvent>();4748/**49* Event to signal a change in authentication sessions for this provider.50*51* NOTE: This event is handled differently in the Microsoft auth provider than "typical" auth providers. Normally,52* this event would fire when the provider's sessions change... which are tied to a specific list of scopes. However,53* since Microsoft identity doesn't care too much about scopes (you can mint a new token from an existing token),54* we just fire this event whenever the account list changes... so essentially there is one session per account.55*56* This is not quite how the API should be used... but this event really is just for signaling that the account list57* has changed.58*/59onDidChangeSessions = this._onDidChangeSessionsEmitter.event;6061private constructor(62private readonly _context: ExtensionContext,63private readonly _telemetryReporter: MicrosoftAuthenticationTelemetryReporter,64private readonly _logger: LogOutputChannel,65private readonly _uriHandler: UriEventHandler,66private readonly _publicClientManager: ICachedPublicClientApplicationManager,67private readonly _env: Environment = Environment.AzureCloud68) {69this._disposables = _context.subscriptions;70const accountChangeEvent = this._eventBufferer.wrapEvent(71this._publicClientManager.onDidAccountsChange,72(last, newEvent) => {73if (!last) {74return newEvent;75}76const mergedEvent = {77added: [...(last.added ?? []), ...(newEvent.added ?? [])],78deleted: [...(last.deleted ?? []), ...(newEvent.deleted ?? [])],79changed: [...(last.changed ?? []), ...(newEvent.changed ?? [])]80};8182const dedupedEvent = {83added: Array.from(new Map(mergedEvent.added.map(item => [item.username, item])).values()),84deleted: Array.from(new Map(mergedEvent.deleted.map(item => [item.username, item])).values()),85changed: Array.from(new Map(mergedEvent.changed.map(item => [item.username, item])).values())86};8788return dedupedEvent;89},90{ added: new Array<AccountInfo>(), deleted: new Array<AccountInfo>(), changed: new Array<AccountInfo>() }91)(e => this._handleAccountChange(e));92this._disposables.push(93this._onDidChangeSessionsEmitter,94accountChangeEvent95);96}9798static async create(99context: ExtensionContext,100telemetryReporter: MicrosoftAuthenticationTelemetryReporter,101logger: LogOutputChannel,102uriHandler: UriEventHandler,103env: Environment = Environment.AzureCloud104): Promise<MsalAuthProvider> {105const publicClientManager = await CachedPublicClientApplicationManager.create(context.secrets, logger, telemetryReporter, env);106context.subscriptions.push(publicClientManager);107const authProvider = new MsalAuthProvider(context, telemetryReporter, logger, uriHandler, publicClientManager, env);108await authProvider.initialize();109return authProvider;110}111112/**113* Migrate sessions from the old secret storage to MSAL.114* TODO: MSAL Migration. Remove this when we remove the old flow.115*/116private async _migrateSessions() {117const betterSecretStorage = new BetterTokenStorage<IStoredSession>('microsoft.login.keylist', this._context);118const sessions = await betterSecretStorage.getAll(item => {119item.endpoint ||= Environment.AzureCloud.activeDirectoryEndpointUrl;120return item.endpoint === this._env.activeDirectoryEndpointUrl;121});122this._context.globalState.update('msalMigration', true);123124const clientTenantMap = new Map<string, { clientId: string; tenant: string; refreshTokens: string[] }>();125126for (const session of sessions) {127const scopeData = new ScopeData(session.scope.split(' '));128const key = `${scopeData.clientId}:${scopeData.tenant}`;129if (!clientTenantMap.has(key)) {130clientTenantMap.set(key, { clientId: scopeData.clientId, tenant: scopeData.tenant, refreshTokens: [] });131}132clientTenantMap.get(key)!.refreshTokens.push(session.refreshToken);133}134135for (const { clientId, tenant, refreshTokens } of clientTenantMap.values()) {136await this._publicClientManager.getOrCreate(clientId, { refreshTokensToMigrate: refreshTokens, tenant });137}138}139140private async initialize(): Promise<void> {141if (!this._context.globalState.get('msalMigration', false)) {142await this._migrateSessions();143}144145// Send telemetry for existing accounts146for (const cachedPca of this._publicClientManager.getAll()) {147for (const account of cachedPca.accounts) {148const tid = account.tenantId;149const type = tid === MSA_TID || tid === MSA_PASSTHRU_TID ? MicrosoftAccountType.MSA : MicrosoftAccountType.AAD;150this._telemetryReporter.sendAccountEvent([], type);151}152}153}154155/**156* See {@link onDidChangeSessions} for more information on how this is used.157* @param param0 Event that contains the added and removed accounts158*/159private _handleAccountChange({ added, changed, deleted }: { added: AccountInfo[]; changed: AccountInfo[]; deleted: AccountInfo[] }) {160this._logger.debug(`[_handleAccountChange] added: ${added.length}, changed: ${changed.length}, deleted: ${deleted.length}`);161this._onDidChangeSessionsEmitter.fire({162added: added.map(this.sessionFromAccountInfo),163changed: changed.map(this.sessionFromAccountInfo),164removed: deleted.map(this.sessionFromAccountInfo)165});166}167168//#region AuthenticationProvider methods169170async getSessions(scopes: string[] | undefined, options: AuthenticationGetSessionOptions = {}): Promise<AuthenticationSession[]> {171const askingForAll = scopes === undefined;172const scopeData = new ScopeData(scopes, undefined, options?.authorizationServer);173// Do NOT use `scopes` beyond this place in the code. Use `scopeData` instead.174this._logger.info('[getSessions]', askingForAll ? '[all]' : `[${scopeData.scopeStr}]`, 'starting');175176// This branch only gets called by Core for sign out purposes and initial population of the account menu. Since we are177// living in a world where a "session" from Core's perspective is an account, we return 1 session per account.178// See the large comment on `onDidChangeSessions` for more information.179if (askingForAll) {180const allSessionsForAccounts = new Map<string, AuthenticationSession>();181for (const cachedPca of this._publicClientManager.getAll()) {182for (const account of cachedPca.accounts) {183if (allSessionsForAccounts.has(account.homeAccountId)) {184continue;185}186allSessionsForAccounts.set(account.homeAccountId, this.sessionFromAccountInfo(account));187}188}189const allSessions = Array.from(allSessionsForAccounts.values());190this._logger.info('[getSessions] [all]', `returned ${allSessions.length} session(s)`);191return allSessions;192}193194const cachedPca = await this._publicClientManager.getOrCreate(scopeData.clientId);195const sessions = await this.getAllSessionsForPca(cachedPca, scopeData, options?.account);196this._logger.info(`[getSessions] [${scopeData.scopeStr}] returned ${sessions.length} session(s)`);197return sessions;198199}200201async createSession(scopes: readonly string[], options: AuthenticationProviderSessionOptions): Promise<AuthenticationSession> {202const scopeData = new ScopeData(scopes, undefined, options.authorizationServer);203// Do NOT use `scopes` beyond this place in the code. Use `scopeData` instead.204205this._logger.info('[createSession]', `[${scopeData.scopeStr}]`, 'starting');206const cachedPca = await this._publicClientManager.getOrCreate(scopeData.clientId);207208// Used for showing a friendlier message to the user when the explicitly cancel a flow.209let userCancelled: boolean | undefined;210const yes = l10n.t('Yes');211const no = l10n.t('No');212const promptToContinue = async (mode: string) => {213if (userCancelled === undefined) {214// We haven't had a failure yet so wait to prompt215return;216}217const message = userCancelled218? l10n.t('Having trouble logging in? Would you like to try a different way? ({0})', mode)219: l10n.t('You have not yet finished authorizing this extension to use your Microsoft Account. Would you like to try a different way? ({0})', mode);220const result = await window.showWarningMessage(message, yes, no);221if (result !== yes) {222throw new CancellationError();223}224};225226const callbackUri = await env.asExternalUri(Uri.parse(`${env.uriScheme}://vscode.microsoft-authentication`));227const flows = getMsalFlows({228extensionHost: this._context.extension.extensionKind === ExtensionKind.UI ? ExtensionHost.Local : ExtensionHost.Remote,229supportedClient: isSupportedClient(callbackUri),230isBrokerSupported: cachedPca.isBrokerAvailable,231isPortableMode: env.isAppPortable232});233234const authority = new URL(scopeData.tenant, this._env.activeDirectoryEndpointUrl).toString();235let lastError: Error | undefined;236for (const flow of flows) {237if (flow !== flows[0]) {238try {239await promptToContinue(flow.label);240} finally {241this._telemetryReporter.sendLoginFailedEvent();242}243}244try {245const result = await flow.trigger({246cachedPca,247authority,248scopes: scopeData.scopesToSend,249loginHint: options.account?.label,250windowHandle: window.nativeHandle ? Buffer.from(window.nativeHandle) : undefined,251logger: this._logger,252uriHandler: this._uriHandler,253callbackUri254});255256const session = this.sessionFromAuthenticationResult(result, scopeData.originalScopes);257this._telemetryReporter.sendLoginEvent(session.scopes);258this._logger.info('[createSession]', `[${scopeData.scopeStr}]`, 'returned session');259return session;260} catch (e) {261lastError = e;262if (e instanceof ServerError || (e as ClientAuthError)?.errorCode === ClientAuthErrorCodes.userCanceled) {263this._telemetryReporter.sendLoginFailedEvent();264throw e;265}266// Continue to next flow267if (e instanceof CancellationError) {268userCancelled = true;269}270}271}272273this._telemetryReporter.sendLoginFailedEvent();274throw lastError ?? new Error('No auth flow succeeded');275}276277async removeSession(sessionId: string): Promise<void> {278this._logger.info('[removeSession]', sessionId, 'starting');279const promises = new Array<Promise<void>>();280for (const cachedPca of this._publicClientManager.getAll()) {281const accounts = cachedPca.accounts;282for (const account of accounts) {283if (account.homeAccountId === sessionId) {284this._telemetryReporter.sendLogoutEvent();285promises.push(cachedPca.removeAccount(account));286this._logger.info(`[removeSession] [${sessionId}] [${cachedPca.clientId}] removing session...`);287}288}289}290if (!promises.length) {291this._logger.info('[removeSession]', sessionId, 'session not found');292return;293}294const results = await Promise.allSettled(promises);295for (const result of results) {296if (result.status === 'rejected') {297this._telemetryReporter.sendLogoutFailedEvent();298this._logger.error('[removeSession]', sessionId, 'error removing session', result.reason);299}300}301302this._logger.info('[removeSession]', sessionId, `attempted to remove ${promises.length} sessions`);303}304305async getSessionsFromChallenges(constraint: AuthenticationConstraint, options: AuthenticationProviderSessionOptions): Promise<readonly AuthenticationSession[]> {306this._logger.info('[getSessionsFromChallenges]', 'starting with', constraint.challenges.length, 'challenges');307308// Use scopes from challenges if provided, otherwise use fallback scopes309const scopes = this.extractScopesFromChallenges(constraint.challenges) ?? constraint.fallbackScopes;310if (!scopes || scopes.length === 0) {311throw new Error('No scopes found in authentication challenges or fallback scopes');312}313const claims = this.extractClaimsFromChallenges(constraint.challenges);314if (!claims) {315throw new Error('No claims found in authentication challenges');316}317const scopeData = new ScopeData(scopes, claims, options?.authorizationServer);318this._logger.info('[getSessionsFromChallenges]', `[${scopeData.scopeStr}]`, 'with claims:', scopeData.claims);319320const cachedPca = await this._publicClientManager.getOrCreate(scopeData.clientId);321const sessions = await this.getAllSessionsForPca(cachedPca, scopeData, options?.account);322323this._logger.info('[getSessionsFromChallenges]', 'returning', sessions.length, 'sessions');324return sessions;325}326327async createSessionFromChallenges(constraint: AuthenticationConstraint, options: AuthenticationProviderSessionOptions): Promise<AuthenticationSession> {328this._logger.info('[createSessionFromChallenges]', 'starting with', constraint.challenges.length, 'challenges');329330// Use scopes from challenges if provided, otherwise use fallback scopes331const scopes = this.extractScopesFromChallenges(constraint.challenges) ?? constraint.fallbackScopes;332if (!scopes || scopes.length === 0) {333throw new Error('No scopes found in authentication challenges or fallback scopes');334}335const claims = this.extractClaimsFromChallenges(constraint.challenges);336337// Use scopes if available, otherwise fall back to default scopes338const effectiveScopes = scopes.length > 0 ? scopes : ['https://graph.microsoft.com/User.Read'];339340const scopeData = new ScopeData(effectiveScopes, claims, options.authorizationServer);341this._logger.info('[createSessionFromChallenges]', `[${scopeData.scopeStr}]`, 'starting with claims:', claims);342343const cachedPca = await this._publicClientManager.getOrCreate(scopeData.clientId);344345// Used for showing a friendlier message to the user when the explicitly cancel a flow.346let userCancelled: boolean | undefined;347const yes = l10n.t('Yes');348const no = l10n.t('No');349const promptToContinue = async (mode: string) => {350if (userCancelled === undefined) {351// We haven't had a failure yet so wait to prompt352return;353}354const message = userCancelled355? l10n.t('Having trouble logging in? Would you like to try a different way? ({0})', mode)356: l10n.t('You have not yet finished authorizing this extension to use your Microsoft Account. Would you like to try a different way? ({0})', mode);357const result = await window.showWarningMessage(message, yes, no);358if (result !== yes) {359throw new CancellationError();360}361};362363const callbackUri = await env.asExternalUri(Uri.parse(`${env.uriScheme}://vscode.microsoft-authentication`));364const flows = getMsalFlows({365extensionHost: this._context.extension.extensionKind === ExtensionKind.UI ? ExtensionHost.Local : ExtensionHost.Remote,366isBrokerSupported: cachedPca.isBrokerAvailable,367supportedClient: isSupportedClient(callbackUri),368isPortableMode: env.isAppPortable369});370371const authority = new URL(scopeData.tenant, this._env.activeDirectoryEndpointUrl).toString();372let lastError: Error | undefined;373for (const flow of flows) {374if (flow !== flows[0]) {375try {376await promptToContinue(flow.label);377} finally {378this._telemetryReporter.sendLoginFailedEvent();379}380}381try {382// Create the authentication request with claims if provided383const authRequest = {384cachedPca,385authority,386scopes: scopeData.scopesToSend,387loginHint: options.account?.label,388windowHandle: window.nativeHandle ? Buffer.from(window.nativeHandle) : undefined,389logger: this._logger,390uriHandler: this._uriHandler,391claims: scopeData.claims,392callbackUri393};394395const result = await flow.trigger(authRequest);396397const session = this.sessionFromAuthenticationResult(result, scopeData.originalScopes);398this._telemetryReporter.sendLoginEvent(session.scopes);399this._logger.info('[createSessionFromChallenges]', `[${scopeData.scopeStr}]`, 'returned session');400return session;401} catch (e) {402lastError = e as Error;403if (e instanceof ClientAuthError && e.errorCode === ClientAuthErrorCodes.userCanceled) {404this._logger.info('[createSessionFromChallenges]', `[${scopeData.scopeStr}]`, 'user cancelled');405userCancelled = true;406continue;407}408this._logger.error('[createSessionFromChallenges]', `[${scopeData.scopeStr}]`, 'error', e);409throw e;410}411}412413this._telemetryReporter.sendLoginFailedEvent();414throw lastError ?? new Error('No auth flow succeeded');415}416417private extractScopesFromChallenges(challenges: readonly AuthenticationChallenge[]): string[] | undefined {418for (const challenge of challenges) {419if (challenge.scheme.toLowerCase() === 'bearer' && challenge.params.scope) {420return challenge.params.scope.split(' ');421}422}423return undefined;424}425426private extractClaimsFromChallenges(challenges: readonly AuthenticationChallenge[]): string | undefined {427for (const challenge of challenges) {428if (challenge.scheme.toLowerCase() === 'bearer' && challenge.params.claims) {429try {430return base64Decode(challenge.params.claims);431} catch (e) {432this._logger.warn('[extractClaimsFromChallenges]', 'failed to decode claims... checking if it is already JSON', e);433try {434JSON.parse(challenge.params.claims);435return challenge.params.claims;436} catch (e) {437this._logger.error('[extractClaimsFromChallenges]', 'failed to parse claims as JSON... returning undefined', e);438}439}440}441}442return undefined;443}444445//#endregion446447private async getAllSessionsForPca(448cachedPca: ICachedPublicClientApplication,449scopeData: ScopeData,450accountFilter?: AuthenticationSessionAccountInformation451): Promise<AuthenticationSession[]> {452let filteredAccounts = accountFilter453? cachedPca.accounts.filter(a => a.homeAccountId === accountFilter.id)454: cachedPca.accounts;455456// Group accounts by homeAccountId457const accountGroups = new Map<string, AccountInfo[]>();458for (const account of filteredAccounts) {459const existing = accountGroups.get(account.homeAccountId) || [];460existing.push(account);461accountGroups.set(account.homeAccountId, existing);462}463464// Filter to one account per homeAccountId465filteredAccounts = Array.from(accountGroups.values()).map(accounts => {466if (accounts.length === 1) {467return accounts[0];468}469470// If we have a specific tenant to target, prefer that one471if (scopeData.tenantId) {472const matchingTenant = accounts.find(a => a.tenantId === scopeData.tenantId);473if (matchingTenant) {474return matchingTenant;475}476}477478// Otherwise prefer the home tenant479return accounts.find(a => a.tenantId === a.idTokenClaims?.tid) || accounts[0];480});481482const authority = new URL(scopeData.tenant, this._env.activeDirectoryEndpointUrl).toString();483const sessions: AuthenticationSession[] = [];484return this._eventBufferer.bufferEventsAsync(async () => {485for (const account of filteredAccounts) {486try {487let forceRefresh: true | undefined;488if (scopeData.tenantId) {489// If the tenants do not match, then we need to skip the cache490// to get a new token for the new tenant491if (account.tenantId !== scopeData.tenantId) {492forceRefresh = true;493}494} else {495// If we are requesting the home tenant and we don't yet have496// a token for the home tenant, we need to skip the cache497// to get a new token for the home tenant498if (account.tenantId !== account.idTokenClaims?.tid) {499forceRefresh = true;500}501}502// When claims are present, force refresh to ensure we get a token that satisfies the claims503let claims: string | undefined;504if (scopeData.claims) {505forceRefresh = true;506claims = scopeData.claims;507}508let redirectUri: string | undefined;509// If we have the broker available and are on macOS, we HAVE to include the redirect URI or MSAL will throw an error.510// HOWEVER, if we are _not_ using the broker, we MUST NOT include the redirect URI or MSAL will throw an error.511if (cachedPca.isBrokerAvailable && process.platform === 'darwin') {512redirectUri = Config.macOSBrokerRedirectUri;513}514this._logger.trace(`[getAllSessionsForPca] [${scopeData.scopeStr}] [${account.environment}] [${account.username}] acquiring token silently with${forceRefresh ? ' ' : 'out '}force refresh${claims ? ' and claims' : ''}...`);515const result = await cachedPca.acquireTokenSilent({516account,517authority,518scopes: scopeData.scopesToSend,519claims,520redirectUri,521forceRefresh522});523sessions.push(this.sessionFromAuthenticationResult(result, scopeData.originalScopes));524} catch (e) {525// If we can't get a token silently, the account is probably in a bad state so we should skip it526// MSAL will log this already, so we don't need to log it again527if (e instanceof AuthError) {528this._telemetryReporter.sendTelemetryClientAuthErrorEvent(e);529} else {530this._telemetryReporter.sendTelemetryErrorEvent(e);531}532this._logger.info(`[getAllSessionsForPca] [${scopeData.scopeStr}] [${account.username}] failed to acquire token silently, skipping account`, JSON.stringify(e));533continue;534}535}536return sessions;537});538}539540private sessionFromAuthenticationResult(result: AuthenticationResult, scopes: readonly string[]): AuthenticationSession & { idToken: string } {541return {542accessToken: result.accessToken,543idToken: result.idToken,544id: result.account?.homeAccountId ?? result.uniqueId,545account: {546id: result.account?.homeAccountId ?? result.uniqueId,547label: result.account?.username.toLowerCase() ?? 'Unknown',548},549scopes550};551}552553private sessionFromAccountInfo(account: AccountInfo): AuthenticationSession {554return {555accessToken: '1234',556id: account.homeAccountId,557scopes: [],558account: {559id: account.homeAccountId,560label: account.username.toLowerCase(),561},562idToken: account.idToken,563};564}565}566567568