Path: blob/main/extensions/microsoft-authentication/src/node/authProvider.ts
3320 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, ClientAuthError, ClientAuthErrorCodes, ServerError, SilentFlowRequest } from '@azure/msal-node';5import { AuthenticationChallenge, AuthenticationConstraint, AuthenticationGetSessionOptions, AuthenticationProvider, AuthenticationProviderAuthenticationSessionsChangeEvent, AuthenticationProviderSessionOptions, AuthenticationSession, AuthenticationSessionAccountInformation, CancellationError, 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 { IStoredSession } from '../AADHelper';15import { ExtensionHost, getMsalFlows } from './flows';16import { base64Decode } from './buffer';17import { Config } from '../common/config';18import { DEFAULT_REDIRECT_URI } from '../common/env';1920const MSA_TID = '9188040d-6c67-4c5b-b112-36a304b66dad';21const MSA_PASSTHRU_TID = 'f8cdef31-a31e-4b4a-93e4-5f571e91255a';2223export class MsalAuthProvider implements AuthenticationProvider {2425private readonly _disposables: { dispose(): void }[];26private readonly _eventBufferer = new EventBufferer();2728/**29* Event to signal a change in authentication sessions for this provider.30*/31private readonly _onDidChangeSessionsEmitter = new EventEmitter<AuthenticationProviderAuthenticationSessionsChangeEvent>();3233/**34* Event to signal a change in authentication sessions for this provider.35*36* NOTE: This event is handled differently in the Microsoft auth provider than "typical" auth providers. Normally,37* this event would fire when the provider's sessions change... which are tied to a specific list of scopes. However,38* since Microsoft identity doesn't care too much about scopes (you can mint a new token from an existing token),39* we just fire this event whenever the account list changes... so essentially there is one session per account.40*41* This is not quite how the API should be used... but this event really is just for signaling that the account list42* has changed.43*/44onDidChangeSessions = this._onDidChangeSessionsEmitter.event;4546private constructor(47private readonly _context: ExtensionContext,48private readonly _telemetryReporter: MicrosoftAuthenticationTelemetryReporter,49private readonly _logger: LogOutputChannel,50private readonly _uriHandler: UriEventHandler,51private readonly _publicClientManager: ICachedPublicClientApplicationManager,52private readonly _env: Environment = Environment.AzureCloud53) {54this._disposables = _context.subscriptions;55const accountChangeEvent = this._eventBufferer.wrapEvent(56this._publicClientManager.onDidAccountsChange,57(last, newEvent) => {58if (!last) {59return newEvent;60}61const mergedEvent = {62added: [...(last.added ?? []), ...(newEvent.added ?? [])],63deleted: [...(last.deleted ?? []), ...(newEvent.deleted ?? [])],64changed: [...(last.changed ?? []), ...(newEvent.changed ?? [])]65};6667const dedupedEvent = {68added: Array.from(new Map(mergedEvent.added.map(item => [item.username, item])).values()),69deleted: Array.from(new Map(mergedEvent.deleted.map(item => [item.username, item])).values()),70changed: Array.from(new Map(mergedEvent.changed.map(item => [item.username, item])).values())71};7273return dedupedEvent;74},75{ added: new Array<AccountInfo>(), deleted: new Array<AccountInfo>(), changed: new Array<AccountInfo>() }76)(e => this._handleAccountChange(e));77this._disposables.push(78this._onDidChangeSessionsEmitter,79accountChangeEvent80);81}8283static async create(84context: ExtensionContext,85telemetryReporter: MicrosoftAuthenticationTelemetryReporter,86logger: LogOutputChannel,87uriHandler: UriEventHandler,88env: Environment = Environment.AzureCloud89): Promise<MsalAuthProvider> {90const publicClientManager = await CachedPublicClientApplicationManager.create(context.secrets, logger, telemetryReporter, env);91context.subscriptions.push(publicClientManager);92const authProvider = new MsalAuthProvider(context, telemetryReporter, logger, uriHandler, publicClientManager, env);93await authProvider.initialize();94return authProvider;95}9697/**98* Migrate sessions from the old secret storage to MSAL.99* TODO: MSAL Migration. Remove this when we remove the old flow.100*/101private async _migrateSessions() {102const betterSecretStorage = new BetterTokenStorage<IStoredSession>('microsoft.login.keylist', this._context);103const sessions = await betterSecretStorage.getAll(item => {104item.endpoint ||= Environment.AzureCloud.activeDirectoryEndpointUrl;105return item.endpoint === this._env.activeDirectoryEndpointUrl;106});107this._context.globalState.update('msalMigration', true);108109const clientTenantMap = new Map<string, { clientId: string; tenant: string; refreshTokens: string[] }>();110111for (const session of sessions) {112const scopeData = new ScopeData(session.scope.split(' '));113const key = `${scopeData.clientId}:${scopeData.tenant}`;114if (!clientTenantMap.has(key)) {115clientTenantMap.set(key, { clientId: scopeData.clientId, tenant: scopeData.tenant, refreshTokens: [] });116}117clientTenantMap.get(key)!.refreshTokens.push(session.refreshToken);118}119120for (const { clientId, tenant, refreshTokens } of clientTenantMap.values()) {121await this._publicClientManager.getOrCreate(clientId, { refreshTokensToMigrate: refreshTokens, tenant });122}123}124125private async initialize(): Promise<void> {126if (!this._context.globalState.get('msalMigration', false)) {127await this._migrateSessions();128}129130// Send telemetry for existing accounts131for (const cachedPca of this._publicClientManager.getAll()) {132for (const account of cachedPca.accounts) {133const tid = account.tenantId;134const type = tid === MSA_TID || tid === MSA_PASSTHRU_TID ? MicrosoftAccountType.MSA : MicrosoftAccountType.AAD;135this._telemetryReporter.sendAccountEvent([], type);136}137}138}139140/**141* See {@link onDidChangeSessions} for more information on how this is used.142* @param param0 Event that contains the added and removed accounts143*/144private _handleAccountChange({ added, changed, deleted }: { added: AccountInfo[]; changed: AccountInfo[]; deleted: AccountInfo[] }) {145this._logger.debug(`[_handleAccountChange] added: ${added.length}, changed: ${changed.length}, deleted: ${deleted.length}`);146this._onDidChangeSessionsEmitter.fire({147added: added.map(this.sessionFromAccountInfo),148changed: changed.map(this.sessionFromAccountInfo),149removed: deleted.map(this.sessionFromAccountInfo)150});151}152153//#region AuthenticationProvider methods154155async getSessions(scopes: string[] | undefined, options: AuthenticationGetSessionOptions = {}): Promise<AuthenticationSession[]> {156const askingForAll = scopes === undefined;157const scopeData = new ScopeData(scopes, undefined, options?.authorizationServer);158// Do NOT use `scopes` beyond this place in the code. Use `scopeData` instead.159this._logger.info('[getSessions]', askingForAll ? '[all]' : `[${scopeData.scopeStr}]`, 'starting');160161// This branch only gets called by Core for sign out purposes and initial population of the account menu. Since we are162// living in a world where a "session" from Core's perspective is an account, we return 1 session per account.163// See the large comment on `onDidChangeSessions` for more information.164if (askingForAll) {165const allSessionsForAccounts = new Map<string, AuthenticationSession>();166for (const cachedPca of this._publicClientManager.getAll()) {167for (const account of cachedPca.accounts) {168if (allSessionsForAccounts.has(account.homeAccountId)) {169continue;170}171allSessionsForAccounts.set(account.homeAccountId, this.sessionFromAccountInfo(account));172}173}174const allSessions = Array.from(allSessionsForAccounts.values());175this._logger.info('[getSessions] [all]', `returned ${allSessions.length} session(s)`);176return allSessions;177}178179const cachedPca = await this._publicClientManager.getOrCreate(scopeData.clientId);180const sessions = await this.getAllSessionsForPca(cachedPca, scopeData, options?.account);181this._logger.info(`[getSessions] [${scopeData.scopeStr}] returned ${sessions.length} session(s)`);182return sessions;183184}185186async createSession(scopes: readonly string[], options: AuthenticationProviderSessionOptions): Promise<AuthenticationSession> {187const scopeData = new ScopeData(scopes, undefined, options.authorizationServer);188// Do NOT use `scopes` beyond this place in the code. Use `scopeData` instead.189190this._logger.info('[createSession]', `[${scopeData.scopeStr}]`, 'starting');191const cachedPca = await this._publicClientManager.getOrCreate(scopeData.clientId);192193// Used for showing a friendlier message to the user when the explicitly cancel a flow.194let userCancelled: boolean | undefined;195const yes = l10n.t('Yes');196const no = l10n.t('No');197const promptToContinue = async (mode: string) => {198if (userCancelled === undefined) {199// We haven't had a failure yet so wait to prompt200return;201}202const message = userCancelled203? l10n.t('Having trouble logging in? Would you like to try a different way? ({0})', mode)204: 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);205const result = await window.showWarningMessage(message, yes, no);206if (result !== yes) {207throw new CancellationError();208}209};210211const isNodeEnvironment = typeof process !== 'undefined' && typeof process?.versions?.node === 'string';212const flows = getMsalFlows({213extensionHost: isNodeEnvironment214? this._context.extension.extensionKind === ExtensionKind.UI ? ExtensionHost.Local : ExtensionHost.Remote215: ExtensionHost.WebWorker,216isBrokerSupported: cachedPca.isBrokerAvailable217});218219const authority = new URL(scopeData.tenant, this._env.activeDirectoryEndpointUrl).toString();220let lastError: Error | undefined;221for (const flow of flows) {222if (flow !== flows[0]) {223try {224await promptToContinue(flow.label);225} finally {226this._telemetryReporter.sendLoginFailedEvent();227}228}229try {230const result = await flow.trigger({231cachedPca,232authority,233scopes: scopeData.scopesToSend,234loginHint: options.account?.label,235windowHandle: window.nativeHandle ? Buffer.from(window.nativeHandle) : undefined,236logger: this._logger,237uriHandler: this._uriHandler238});239240const session = this.sessionFromAuthenticationResult(result, scopeData.originalScopes);241this._telemetryReporter.sendLoginEvent(session.scopes);242this._logger.info('[createSession]', `[${scopeData.scopeStr}]`, 'returned session');243return session;244} catch (e) {245lastError = e;246if (e instanceof ServerError || (e as ClientAuthError)?.errorCode === ClientAuthErrorCodes.userCanceled) {247this._telemetryReporter.sendLoginFailedEvent();248throw e;249}250// Continue to next flow251if (e instanceof CancellationError) {252userCancelled = true;253}254}255}256257this._telemetryReporter.sendLoginFailedEvent();258throw lastError ?? new Error('No auth flow succeeded');259}260261async removeSession(sessionId: string): Promise<void> {262this._logger.info('[removeSession]', sessionId, 'starting');263const promises = new Array<Promise<void>>();264for (const cachedPca of this._publicClientManager.getAll()) {265const accounts = cachedPca.accounts;266for (const account of accounts) {267if (account.homeAccountId === sessionId) {268this._telemetryReporter.sendLogoutEvent();269promises.push(cachedPca.removeAccount(account));270this._logger.info(`[removeSession] [${sessionId}] [${cachedPca.clientId}] removing session...`);271}272}273}274if (!promises.length) {275this._logger.info('[removeSession]', sessionId, 'session not found');276return;277}278const results = await Promise.allSettled(promises);279for (const result of results) {280if (result.status === 'rejected') {281this._telemetryReporter.sendLogoutFailedEvent();282this._logger.error('[removeSession]', sessionId, 'error removing session', result.reason);283}284}285286this._logger.info('[removeSession]', sessionId, `attempted to remove ${promises.length} sessions`);287}288289async getSessionsFromChallenges(constraint: AuthenticationConstraint, options: AuthenticationProviderSessionOptions): Promise<readonly AuthenticationSession[]> {290this._logger.info('[getSessionsFromChallenges]', 'starting with', constraint.challenges.length, 'challenges');291292// Use scopes from constraint if provided, otherwise extract from challenges293const scopes = constraint.scopes?.length ? [...constraint.scopes] : this.extractScopesFromChallenges(constraint.challenges);294const claims = this.extractClaimsFromChallenges(constraint.challenges);295if (!claims) {296throw new Error('No claims found in authentication challenges');297}298const scopeData = new ScopeData(scopes, claims, options?.authorizationServer);299this._logger.info('[getSessionsFromChallenges]', `[${scopeData.scopeStr}]`, 'with claims:', scopeData.claims);300301const cachedPca = await this._publicClientManager.getOrCreate(scopeData.clientId);302const sessions = await this.getAllSessionsForPca(cachedPca, scopeData, options?.account);303304this._logger.info('[getSessionsFromChallenges]', 'returning', sessions.length, 'sessions');305return sessions;306}307308async createSessionFromChallenges(constraint: AuthenticationConstraint, options: AuthenticationProviderSessionOptions): Promise<AuthenticationSession> {309this._logger.info('[createSessionFromChallenges]', 'starting with', constraint.challenges.length, 'challenges');310311// Use scopes from constraint if provided, otherwise extract from challenges312const scopes = constraint.scopes?.length ? [...constraint.scopes] : this.extractScopesFromChallenges(constraint.challenges);313const claims = this.extractClaimsFromChallenges(constraint.challenges);314315// Use scopes if available, otherwise fall back to default scopes316const effectiveScopes = scopes.length > 0 ? scopes : ['https://graph.microsoft.com/User.Read'];317318const scopeData = new ScopeData(effectiveScopes, claims, options.authorizationServer);319this._logger.info('[createSessionFromChallenges]', `[${scopeData.scopeStr}]`, 'starting with claims:', claims);320321const cachedPca = await this._publicClientManager.getOrCreate(scopeData.clientId);322323// Used for showing a friendlier message to the user when the explicitly cancel a flow.324let userCancelled: boolean | undefined;325const yes = l10n.t('Yes');326const no = l10n.t('No');327const promptToContinue = async (mode: string) => {328if (userCancelled === undefined) {329// We haven't had a failure yet so wait to prompt330return;331}332const message = userCancelled333? l10n.t('Having trouble logging in? Would you like to try a different way? ({0})', mode)334: 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);335const result = await window.showWarningMessage(message, yes, no);336if (result !== yes) {337throw new CancellationError();338}339};340341const isNodeEnvironment = typeof process !== 'undefined' && typeof process?.versions?.node === 'string';342const flows = getMsalFlows({343extensionHost: isNodeEnvironment344? this._context.extension.extensionKind === ExtensionKind.UI ? ExtensionHost.Local : ExtensionHost.Remote345: ExtensionHost.WebWorker,346isBrokerSupported: cachedPca.isBrokerAvailable347});348349const authority = new URL(scopeData.tenant, this._env.activeDirectoryEndpointUrl).toString();350let lastError: Error | undefined;351for (const flow of flows) {352if (flow !== flows[0]) {353try {354await promptToContinue(flow.label);355} finally {356this._telemetryReporter.sendLoginFailedEvent();357}358}359try {360// Create the authentication request with claims if provided361const authRequest = {362cachedPca,363authority,364scopes: scopeData.scopesToSend,365loginHint: options.account?.label,366windowHandle: window.nativeHandle ? Buffer.from(window.nativeHandle) : undefined,367logger: this._logger,368uriHandler: this._uriHandler,369claims: scopeData.claims370};371372const result = await flow.trigger(authRequest);373374const session = this.sessionFromAuthenticationResult(result, scopeData.originalScopes);375this._telemetryReporter.sendLoginEvent(session.scopes);376this._logger.info('[createSessionFromChallenges]', `[${scopeData.scopeStr}]`, 'returned session');377return session;378} catch (e) {379lastError = e as Error;380if (e instanceof ClientAuthError && e.errorCode === ClientAuthErrorCodes.userCanceled) {381this._logger.info('[createSessionFromChallenges]', `[${scopeData.scopeStr}]`, 'user cancelled');382userCancelled = true;383continue;384}385this._logger.error('[createSessionFromChallenges]', `[${scopeData.scopeStr}]`, 'error', e);386throw e;387}388}389390this._telemetryReporter.sendLoginFailedEvent();391throw lastError ?? new Error('No auth flow succeeded');392}393394private extractScopesFromChallenges(challenges: readonly AuthenticationChallenge[]): string[] {395const scopes: string[] = [];396for (const challenge of challenges) {397if (challenge.scheme.toLowerCase() === 'bearer' && challenge.params.scope) {398scopes.push(...challenge.params.scope.split(' '));399}400}401return scopes;402}403404private extractClaimsFromChallenges(challenges: readonly AuthenticationChallenge[]): string | undefined {405for (const challenge of challenges) {406if (challenge.scheme.toLowerCase() === 'bearer' && challenge.params.claims) {407try {408return base64Decode(challenge.params.claims);409} catch (e) {410this._logger.warn('[extractClaimsFromChallenges]', 'failed to decode claims... checking if it is already JSON', e);411try {412JSON.parse(challenge.params.claims);413return challenge.params.claims;414} catch (e) {415this._logger.error('[extractClaimsFromChallenges]', 'failed to parse claims as JSON... returning undefined', e);416}417}418}419}420return undefined;421}422423//#endregion424425private async getAllSessionsForPca(426cachedPca: ICachedPublicClientApplication,427scopeData: ScopeData,428accountFilter?: AuthenticationSessionAccountInformation429): Promise<AuthenticationSession[]> {430let filteredAccounts = accountFilter431? cachedPca.accounts.filter(a => a.homeAccountId === accountFilter.id)432: cachedPca.accounts;433434// Group accounts by homeAccountId435const accountGroups = new Map<string, AccountInfo[]>();436for (const account of filteredAccounts) {437const existing = accountGroups.get(account.homeAccountId) || [];438existing.push(account);439accountGroups.set(account.homeAccountId, existing);440}441442// Filter to one account per homeAccountId443filteredAccounts = Array.from(accountGroups.values()).map(accounts => {444if (accounts.length === 1) {445return accounts[0];446}447448// If we have a specific tenant to target, prefer that one449if (scopeData.tenantId) {450const matchingTenant = accounts.find(a => a.tenantId === scopeData.tenantId);451if (matchingTenant) {452return matchingTenant;453}454}455456// Otherwise prefer the home tenant457return accounts.find(a => a.tenantId === a.idTokenClaims?.tid) || accounts[0];458});459460const authority = new URL(scopeData.tenant, this._env.activeDirectoryEndpointUrl).toString();461const sessions: AuthenticationSession[] = [];462return this._eventBufferer.bufferEventsAsync(async () => {463for (const account of filteredAccounts) {464try {465let forceRefresh: true | undefined;466if (scopeData.tenantId) {467// If the tenants do not match, then we need to skip the cache468// to get a new token for the new tenant469if (account.tenantId !== scopeData.tenantId) {470forceRefresh = true;471}472} else {473// If we are requesting the home tenant and we don't yet have474// a token for the home tenant, we need to skip the cache475// to get a new token for the home tenant476if (account.tenantId !== account.idTokenClaims?.tid) {477forceRefresh = true;478}479}480// When claims are present, force refresh to ensure we get a token that satisfies the claims481let claims: string | undefined;482if (scopeData.claims) {483forceRefresh = true;484claims = scopeData.claims;485}486let redirectUri: string | undefined;487// If we have the broker available and are on macOS, we HAVE to include the redirect URI or MSAL will throw an error.488// HOWEVER, if we are _not_ using the broker, we MUST NOT include the redirect URI or MSAL will throw an error.489if (cachedPca.isBrokerAvailable && process.platform === 'darwin') {490redirectUri = Config.macOSBrokerRedirectUri;491}492const result = await cachedPca.acquireTokenSilent({493account,494authority,495scopes: scopeData.scopesToSend,496claims,497redirectUri,498forceRefresh499});500sessions.push(this.sessionFromAuthenticationResult(result, scopeData.originalScopes));501} catch (e) {502// If we can't get a token silently, the account is probably in a bad state so we should skip it503// MSAL will log this already, so we don't need to log it again504this._telemetryReporter.sendTelemetryErrorEvent(e);505this._logger.info(`[getAllSessionsForPca] [${scopeData.scopeStr}] [${account.username}] failed to acquire token silently, skipping account`, JSON.stringify(e));506continue;507}508}509return sessions;510});511}512513private sessionFromAuthenticationResult(result: AuthenticationResult, scopes: readonly string[]): AuthenticationSession & { idToken: string } {514return {515accessToken: result.accessToken,516idToken: result.idToken,517id: result.account?.homeAccountId ?? result.uniqueId,518account: {519id: result.account?.homeAccountId ?? result.uniqueId,520label: result.account?.username.toLowerCase() ?? 'Unknown',521},522scopes523};524}525526private sessionFromAccountInfo(account: AccountInfo): AuthenticationSession {527return {528accessToken: '1234',529id: account.homeAccountId,530scopes: [],531account: {532id: account.homeAccountId,533label: account.username.toLowerCase(),534},535idToken: account.idToken,536};537}538}539540541