From fcc8882ce3f8e289254e8937e0c94d6e8477efb1 Mon Sep 17 00:00:00 2001 From: "Gavin Barron (from Dev Box)" Date: Mon, 22 Jun 2026 16:26:05 -0700 Subject: [PATCH 1/7] fix: Clear persisted MSAL token cache on Disconnect-MgGraph When ContextScope is CurrentUser, Disconnect-MgGraph now clears the disk-persisted MSAL token cache (both .cae and .nocae variants) using MsalCacheHelper.Clear() from Microsoft.Identity.Client.Extensions.Msal. Previously only the in-memory cache and auth record file were cleared, leaving cached tokens on disk that could be reused without re-authentication. Fixes #3648 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...Microsoft.Graph.Authentication.Core.csproj | 4 +- .../Utilities/AuthenticationHelpers.cs | 12 +++ .../Utilities/TokenCacheUtilities.cs | 67 ++++++++++++++++ .../TokenCache/TokenCacheUtilitiesTests.cs | 80 +++++++++++++++++++ .../descriptions/Disconnect-MgGraph.md | 2 +- src/Authentication/docs/Disconnect-MgGraph.md | 19 ++++- 6 files changed, 180 insertions(+), 4 deletions(-) create mode 100644 src/Authentication/Authentication.Core/Utilities/TokenCacheUtilities.cs create mode 100644 src/Authentication/Authentication.Test/TokenCache/TokenCacheUtilitiesTests.cs diff --git a/src/Authentication/Authentication.Core/Microsoft.Graph.Authentication.Core.csproj b/src/Authentication/Authentication.Core/Microsoft.Graph.Authentication.Core.csproj index 54f37452ac4..a1a4944e102 100644 --- a/src/Authentication/Authentication.Core/Microsoft.Graph.Authentication.Core.csproj +++ b/src/Authentication/Authentication.Core/Microsoft.Graph.Authentication.Core.csproj @@ -4,7 +4,7 @@ 9.0 netstandard2.0;net6.0;net472 Microsoft.Graph.PowerShell.Authentication.Core - 2.35.1 + 2.38.0 true @@ -20,6 +20,8 @@ + + diff --git a/src/Authentication/Authentication.Core/Utilities/AuthenticationHelpers.cs b/src/Authentication/Authentication.Core/Utilities/AuthenticationHelpers.cs index e4448663d72..7d581c0205e 100644 --- a/src/Authentication/Authentication.Core/Utilities/AuthenticationHelpers.cs +++ b/src/Authentication/Authentication.Core/Utilities/AuthenticationHelpers.cs @@ -430,6 +430,18 @@ public static async Task LogoutAsync() { var authContext = GraphSession.Instance.AuthContext; GraphSession.Instance.InMemoryTokenCache?.ClearCache(); + if (authContext?.ContextScope == ContextScope.CurrentUser) + { + try + { + await TokenCacheUtilities.ClearPersistedTokenCacheAsync(Constants.CacheName).ConfigureAwait(false); + } + catch (Exception) + { + // Non-fatal: persisted cache clearing may fail on some platforms. + // The auth record and in-memory state are still cleared below. + } + } GraphSession.Instance.AuthContext = null; GraphSession.Instance.GraphHttpClient = null; await DeleteAuthRecordAsync().ConfigureAwait(false); diff --git a/src/Authentication/Authentication.Core/Utilities/TokenCacheUtilities.cs b/src/Authentication/Authentication.Core/Utilities/TokenCacheUtilities.cs new file mode 100644 index 00000000000..8f6fcc3f023 --- /dev/null +++ b/src/Authentication/Authentication.Core/Utilities/TokenCacheUtilities.cs @@ -0,0 +1,67 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +// ------------------------------------------------------------------------------ + +using Microsoft.Identity.Client.Extensions.Msal; +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; + +namespace Microsoft.Graph.PowerShell.Authentication.Core.Utilities +{ + /// + /// Utilities for managing the MSAL token cache persisted to disk by Azure.Identity. + /// + internal static class TokenCacheUtilities + { + // Azure.Identity internal constants for cache storage configuration. + // See: Azure/azure-sdk-for-net - sdk/core/Azure.Core/src/Identity/Constants.cs + private const string DefaultCacheKeychainService = "Microsoft.Developer.IdentityService"; + private const string DefaultCacheKeyringSchema = "msal.cache"; + private const string DefaultCacheKeyringCollection = "default"; + private static readonly KeyValuePair DefaultCacheKeyringAttribute1 = + new KeyValuePair("MsalClientID", "Microsoft.Developer.IdentityService"); + private static readonly KeyValuePair DefaultCacheKeyringAttribute2 = + new KeyValuePair("Microsoft.Developer.IdentityService", "1.0.0.0"); + + // Azure.Identity appends CAE suffixes to the cache name internally. + private const string CaeEnabledSuffix = ".cae"; + private const string CaeDisabledSuffix = ".nocae"; + + private static readonly string DefaultCacheDirectory = + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + ".IdentityService"); + + /// + /// Clears the persisted MSAL token cache files created by Azure.Identity + /// for the given cache name. Clears both CAE-enabled and CAE-disabled variants. + /// + /// The cache name (e.g., "mg.msal.cache"). + public static async Task ClearPersistedTokenCacheAsync(string cacheName) + { + // Azure.Identity creates separate caches for CAE-enabled and CAE-disabled tokens. + await ClearCacheAsync(cacheName + CaeEnabledSuffix).ConfigureAwait(false); + await ClearCacheAsync(cacheName + CaeDisabledSuffix).ConfigureAwait(false); + } + + private static async Task ClearCacheAsync(string cacheFileName) + { + var storageProperties = new StorageCreationPropertiesBuilder(cacheFileName, DefaultCacheDirectory) + .WithMacKeyChain(DefaultCacheKeychainService, cacheFileName) + .WithLinuxKeyring( + DefaultCacheKeyringSchema, + DefaultCacheKeyringCollection, + cacheFileName, + DefaultCacheKeyringAttribute1, + DefaultCacheKeyringAttribute2) + .Build(); + + var cacheHelper = await MsalCacheHelper.CreateAsync(storageProperties).ConfigureAwait(false); +#pragma warning disable CS0618 // MsalCacheHelper.Clear is obsolete but is the correct approach for full cache wipe on disconnect + cacheHelper.Clear(); +#pragma warning restore CS0618 + } + } +} diff --git a/src/Authentication/Authentication.Test/TokenCache/TokenCacheUtilitiesTests.cs b/src/Authentication/Authentication.Test/TokenCache/TokenCacheUtilitiesTests.cs new file mode 100644 index 00000000000..a1cb20bc303 --- /dev/null +++ b/src/Authentication/Authentication.Test/TokenCache/TokenCacheUtilitiesTests.cs @@ -0,0 +1,80 @@ +using Microsoft.Graph.PowerShell.Authentication; +using Microsoft.Graph.PowerShell.Authentication.Core.TokenCache; +using Microsoft.Graph.PowerShell.Authentication.Core.Utilities; +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Graph.Authentication.Test.TokenCache +{ + public class TokenCacheUtilitiesTests : IDisposable + { + public TokenCacheUtilitiesTests() + { + GraphSession.Initialize(() => new GraphSession()); + GraphSession.Instance.InMemoryTokenCache = new InMemoryTokenCache(); + } + + public void Dispose() + { + GraphSession.Reset(); + } + + [Fact] + public async Task LogoutAsyncShouldClearInMemoryCacheForProcessScope() + { + // Arrange + GraphSession.Instance.InMemoryTokenCache = new InMemoryTokenCache( + Encoding.UTF8.GetBytes("mockTokenData")); + GraphSession.Instance.AuthContext = new AuthContext + { + AuthType = AuthenticationType.UserProvidedAccessToken, + ContextScope = ContextScope.Process + }; + + // Act + var result = await AuthenticationHelpers.LogoutAsync(); + + // Assert + Assert.NotNull(result); + Assert.Equal(AuthenticationType.UserProvidedAccessToken, result.AuthType); + Assert.Null(GraphSession.Instance.AuthContext); + Assert.Null(GraphSession.Instance.GraphHttpClient); + Assert.Empty(GraphSession.Instance.InMemoryTokenCache.ReadTokenData()); + } + + [Fact] + public async Task LogoutAsyncShouldNotThrowWhenAuthContextIsNull() + { + // Arrange + GraphSession.Instance.AuthContext = null; + + // Act - should not throw even though there's no auth context + var result = await AuthenticationHelpers.LogoutAsync(); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task LogoutAsyncShouldAttemptCacheClearForCurrentUserScope() + { + // Arrange + GraphSession.Instance.AuthContext = new AuthContext + { + AuthType = AuthenticationType.UserProvidedAccessToken, + ContextScope = ContextScope.CurrentUser + }; + + // Act - should not throw even if no persisted cache exists on disk + var result = await AuthenticationHelpers.LogoutAsync(); + + // Assert + Assert.NotNull(result); + Assert.Equal(ContextScope.CurrentUser, result.ContextScope); + Assert.Null(GraphSession.Instance.AuthContext); + } + } +} diff --git a/src/Authentication/descriptions/Disconnect-MgGraph.md b/src/Authentication/descriptions/Disconnect-MgGraph.md index bf3a8afface..5964655e8fe 100644 --- a/src/Authentication/descriptions/Disconnect-MgGraph.md +++ b/src/Authentication/descriptions/Disconnect-MgGraph.md @@ -1 +1 @@ -Use Disconnect-MgGraph to sign out. \ No newline at end of file +Use Disconnect-MgGraph to sign out. This clears the persisted MSAL token cache from disk when using CurrentUser context scope, as well as removing the in-memory token cache and authentication record. \ No newline at end of file diff --git a/src/Authentication/docs/Disconnect-MgGraph.md b/src/Authentication/docs/Disconnect-MgGraph.md index 0414a17ed25..81acd8cb713 100644 --- a/src/Authentication/docs/Disconnect-MgGraph.md +++ b/src/Authentication/docs/Disconnect-MgGraph.md @@ -13,11 +13,11 @@ Once you're signed in, you'll remain signed in until you invoke Disconnect-MgGra ## SYNTAX ``` -Disconnect-MgGraph [] +Disconnect-MgGraph [-ProgressAction ] [] ``` ## DESCRIPTION -Use Disconnect-MgGraph to sign out. +Use Disconnect-MgGraph to sign out. This clears the persisted MSAL token cache from disk when using CurrentUser context scope, as well as removing the in-memory token cache and authentication record. ## EXAMPLES @@ -30,6 +30,21 @@ Use Disconnect-MgGraph to sign out. ## PARAMETERS +### -ProgressAction +{{ Fill ProgressAction Description }} + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### CommonParameters This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). From b54191c59843ef4f5cfb308df31fc497e9d5a4b8 Mon Sep 17 00:00:00 2001 From: "Gavin Barron (from Dev Box)" Date: Tue, 7 Jul 2026 16:46:34 -0700 Subject: [PATCH 2/7] feat: add -SignOutFromBroker switch to Disconnect-MgGraph for optional WAM cache clearing Adds an opt-in -SignOutFromBroker switch that removes cached accounts from the Windows broker (WAM) on disconnect. Because the broker store is shared at the OS level, this is opt-in to avoid signing users out of other broker-enabled apps by default. Also replaces silent cache-clear catch blocks with best-effort WriteWarning/WriteDebug diagnostics so failures are diagnosable. All cache/broker clearing remains non-fatal. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...Microsoft.Graph.Authentication.Core.csproj | 2 + .../Utilities/AuthenticationHelpers.cs | 72 ++++++++++++++++++- .../TokenCache/TokenCacheUtilitiesTests.cs | 44 ++++++++++++ .../Cmdlets/DisconnectMgGraph.cs | 11 ++- .../descriptions/Disconnect-MgGraph.md | 2 +- src/Authentication/docs/Disconnect-MgGraph.md | 26 ++++++- .../examples/Disconnect-MgGraph.md | 8 ++- 7 files changed, 157 insertions(+), 8 deletions(-) diff --git a/src/Authentication/Authentication.Core/Microsoft.Graph.Authentication.Core.csproj b/src/Authentication/Authentication.Core/Microsoft.Graph.Authentication.Core.csproj index a1a4944e102..9760b5f2395 100644 --- a/src/Authentication/Authentication.Core/Microsoft.Graph.Authentication.Core.csproj +++ b/src/Authentication/Authentication.Core/Microsoft.Graph.Authentication.Core.csproj @@ -20,6 +20,8 @@ + + diff --git a/src/Authentication/Authentication.Core/Utilities/AuthenticationHelpers.cs b/src/Authentication/Authentication.Core/Utilities/AuthenticationHelpers.cs index 7d581c0205e..2fe4d62f5e5 100644 --- a/src/Authentication/Authentication.Core/Utilities/AuthenticationHelpers.cs +++ b/src/Authentication/Authentication.Core/Utilities/AuthenticationHelpers.cs @@ -8,6 +8,7 @@ using Microsoft.Graph.Authentication; using Microsoft.Graph.PowerShell.Authentication.Core.Extensions; using Microsoft.Identity.Client; +using Microsoft.Identity.Client.Broker; using Microsoft.Identity.Client.Extensions.Msal; using System; using System.Diagnostics.Tracing; @@ -425,8 +426,13 @@ private static bool TryFindCertificateBySubjectName(string subjectName, StoreLoc /// /// Signs out of the current session using the provided . /// - /// The to sign-out from. - public static async Task LogoutAsync() + /// + /// When true and the Windows broker (WAM) is in use, cached accounts for this module + /// are also removed from the broker. This affects the shared OS-level broker store and may + /// sign the user out of other broker-enabled applications using the same Windows account. + /// + /// The that was signed out from. + public static async Task LogoutAsync(bool signOutFromBroker = false) { var authContext = GraphSession.Instance.AuthContext; GraphSession.Instance.InMemoryTokenCache?.ClearCache(); @@ -436,18 +442,78 @@ public static async Task LogoutAsync() { await TokenCacheUtilities.ClearPersistedTokenCacheAsync(Constants.CacheName).ConfigureAwait(false); } - catch (Exception) + catch (Exception ex) { // Non-fatal: persisted cache clearing may fail on some platforms. // The auth record and in-memory state are still cleared below. + LogCacheClearFailure("Failed to clear the persisted MSAL token cache during Disconnect-MgGraph", ex); } } + + if (signOutFromBroker && authContext != null && ShouldUseWam(authContext)) + { + try + { + await ClearBrokerTokenCacheAsync(authContext).ConfigureAwait(false); + } + catch (Exception ex) + { + // Non-fatal: removing broker accounts may fail. Disconnect still completes. + LogCacheClearFailure("Failed to remove cached accounts from the Windows broker (WAM) during Disconnect-MgGraph", ex); + } + } + GraphSession.Instance.AuthContext = null; GraphSession.Instance.GraphHttpClient = null; await DeleteAuthRecordAsync().ConfigureAwait(false); return authContext; } + /// + /// Removes cached accounts for the current module from the Windows broker (WAM). + /// This only has an effect on Windows when the broker is in use. Because the broker store is + /// shared at the OS level, removing accounts here may also sign the user out of other + /// broker-enabled applications (for example Visual Studio, Azure CLI, or Azure PowerShell) + /// that are using the same Windows account. + /// + /// The whose broker accounts should be removed. + private static async Task ClearBrokerTokenCacheAsync(IAuthContext authContext) + { + if (authContext is null) + throw new AuthenticationException(ErrorConstants.Message.MissingAuthContext); + + var pca = PublicClientApplicationBuilder + .Create(authContext.ClientId) + .WithAuthority(GetAuthorityUrl(authContext)) + .WithBroker(new BrokerOptions(BrokerOptions.OperatingSystems.Windows)) + .WithParentActivityOrWindow(WindowHandleUtlities.GetConsoleOrTerminalWindow) + .Build(); + + var accounts = await pca.GetAccountsAsync().ConfigureAwait(false); + foreach (var account in accounts) + { + await pca.RemoveAsync(account).ConfigureAwait(false); + } + } + + /// + /// Writes a best-effort diagnostic for a non-fatal cache clearing failure. Logging must never + /// prevent Disconnect-MgGraph from completing, so any failure to log is swallowed. + /// + private static void LogCacheClearFailure(string summary, Exception ex) + { + try + { + var writer = GraphSession.Instance.OutputWriter; + writer.WriteWarning?.Invoke($"{summary}: {ex.Message}"); + writer.WriteDebug?.Invoke($"{summary}: {ex}"); + } + catch + { + // Diagnostics are best-effort and must not break sign-out. + } + } + private static async Task ReadAuthRecordAsync() { // Try to create directory if it doesn't exist. diff --git a/src/Authentication/Authentication.Test/TokenCache/TokenCacheUtilitiesTests.cs b/src/Authentication/Authentication.Test/TokenCache/TokenCacheUtilitiesTests.cs index a1cb20bc303..5c24256d511 100644 --- a/src/Authentication/Authentication.Test/TokenCache/TokenCacheUtilitiesTests.cs +++ b/src/Authentication/Authentication.Test/TokenCache/TokenCacheUtilitiesTests.cs @@ -15,6 +15,7 @@ public TokenCacheUtilitiesTests() { GraphSession.Initialize(() => new GraphSession()); GraphSession.Instance.InMemoryTokenCache = new InMemoryTokenCache(); + GraphSession.Instance.GraphOption = new GraphOption(); } public void Dispose() @@ -76,5 +77,48 @@ public async Task LogoutAsyncShouldAttemptCacheClearForCurrentUserScope() Assert.Equal(ContextScope.CurrentUser, result.ContextScope); Assert.Null(GraphSession.Instance.AuthContext); } + + [Fact] + public async Task LogoutAsyncWithSignOutFromBrokerShouldNotThrowWhenBrokerNotInUse() + { + // Arrange - a custom client id with WAM disabled means the broker path is skipped, + // so no real broker accounts are touched during the test. + GraphSession.Instance.GraphOption = new GraphOption { DisableWAMForMSGraph = true }; + GraphSession.Instance.AuthContext = new AuthContext + { + ClientId = "11111111-1111-1111-1111-111111111111", + AuthType = AuthenticationType.UserProvidedAccessToken, + ContextScope = ContextScope.Process + }; + + // Act - requesting broker sign-out must not throw when the broker is not in use. + var result = await AuthenticationHelpers.LogoutAsync(signOutFromBroker: true); + + // Assert + Assert.NotNull(result); + Assert.Null(GraphSession.Instance.AuthContext); + Assert.Null(GraphSession.Instance.GraphHttpClient); + } + + [Fact] + public async Task LogoutAsyncWithSignOutFromBrokerShouldStillClearContextForCurrentUser() + { + // Arrange - WAM disabled so the broker path is skipped; the file cache clear still runs. + GraphSession.Instance.GraphOption = new GraphOption { DisableWAMForMSGraph = true }; + GraphSession.Instance.AuthContext = new AuthContext + { + ClientId = "11111111-1111-1111-1111-111111111111", + AuthType = AuthenticationType.UserProvidedAccessToken, + ContextScope = ContextScope.CurrentUser + }; + + // Act + var result = await AuthenticationHelpers.LogoutAsync(signOutFromBroker: true); + + // Assert + Assert.NotNull(result); + Assert.Equal(ContextScope.CurrentUser, result.ContextScope); + Assert.Null(GraphSession.Instance.AuthContext); + } } } diff --git a/src/Authentication/Authentication/Cmdlets/DisconnectMgGraph.cs b/src/Authentication/Authentication/Cmdlets/DisconnectMgGraph.cs index b5ae307ffe2..9556ea2e2c8 100644 --- a/src/Authentication/Authentication/Cmdlets/DisconnectMgGraph.cs +++ b/src/Authentication/Authentication/Cmdlets/DisconnectMgGraph.cs @@ -17,6 +17,15 @@ public class DisconnectMgGraph : PSCmdlet { private readonly CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource(); + /// + /// When specified, also removes cached accounts for this module from the Windows broker (WAM). + /// Because the broker store is shared at the OS level, this may sign the user out of other + /// broker-enabled applications (for example Visual Studio, Azure CLI, or Azure PowerShell) + /// that are using the same Windows account. Has no effect when the broker is not in use. + /// + [Parameter(Mandatory = false, HelpMessage = "Also removes cached accounts from the Windows broker (WAM). This is a shared, OS-level store, so it may sign you out of other broker-enabled applications (e.g. Visual Studio, Azure CLI, Azure PowerShell) using the same Windows account.")] + public SwitchParameter SignOutFromBroker { get; set; } + protected override void BeginProcessing() { base.BeginProcessing(); @@ -49,7 +58,7 @@ private async Task ProcessRecordAsync() } else { - var authContext = await AuthenticationHelpers.LogoutAsync(); + var authContext = await AuthenticationHelpers.LogoutAsync(SignOutFromBroker.IsPresent); WriteObject(authContext); } } diff --git a/src/Authentication/descriptions/Disconnect-MgGraph.md b/src/Authentication/descriptions/Disconnect-MgGraph.md index 5964655e8fe..ce368333992 100644 --- a/src/Authentication/descriptions/Disconnect-MgGraph.md +++ b/src/Authentication/descriptions/Disconnect-MgGraph.md @@ -1 +1 @@ -Use Disconnect-MgGraph to sign out. This clears the persisted MSAL token cache from disk when using CurrentUser context scope, as well as removing the in-memory token cache and authentication record. \ No newline at end of file +Use Disconnect-MgGraph to sign out. This clears the persisted MSAL token cache from disk when using CurrentUser context scope, as well as removing the in-memory token cache and authentication record. Use the -SignOutFromBroker switch to additionally remove cached accounts from the Windows broker (WAM); note that the broker store is shared at the OS level, so this can also sign you out of other broker-enabled applications (for example Visual Studio, Azure CLI, or Azure PowerShell) that use the same Windows account. \ No newline at end of file diff --git a/src/Authentication/docs/Disconnect-MgGraph.md b/src/Authentication/docs/Disconnect-MgGraph.md index 81acd8cb713..6883ac73d29 100644 --- a/src/Authentication/docs/Disconnect-MgGraph.md +++ b/src/Authentication/docs/Disconnect-MgGraph.md @@ -13,11 +13,11 @@ Once you're signed in, you'll remain signed in until you invoke Disconnect-MgGra ## SYNTAX ``` -Disconnect-MgGraph [-ProgressAction ] [] +Disconnect-MgGraph [-SignOutFromBroker] [-ProgressAction ] [] ``` ## DESCRIPTION -Use Disconnect-MgGraph to sign out. This clears the persisted MSAL token cache from disk when using CurrentUser context scope, as well as removing the in-memory token cache and authentication record. +Use Disconnect-MgGraph to sign out. This clears the persisted MSAL token cache from disk when using CurrentUser context scope, as well as removing the in-memory token cache and authentication record. Use the -SignOutFromBroker switch to additionally remove cached accounts from the Windows broker (WAM); note that the broker store is shared at the OS level, so this can also sign you out of other broker-enabled applications (for example Visual Studio, Azure CLI, or Azure PowerShell) that use the same Windows account. ## EXAMPLES @@ -28,8 +28,30 @@ PS C:\> Disconnect-MgGraph Use Disconnect-MgGraph to sign out. +### Example 2: Sign out and also clear the Windows broker (WAM) cache +```powershell +PS C:\> Disconnect-MgGraph -SignOutFromBroker +``` + +Signs out and additionally removes cached accounts from the Windows broker (WAM). Because the broker store is shared at the OS level, this can also sign you out of other broker-enabled applications (for example Visual Studio, Azure CLI, or Azure PowerShell) using the same Windows account. + ## PARAMETERS +### -SignOutFromBroker +Also removes cached accounts for this module from the Windows broker (WAM). Because the broker store is shared at the OS level, this can sign you out of other broker-enabled applications (for example Visual Studio, Azure CLI, or Azure PowerShell) that use the same Windows account. Has no effect when the broker is not in use. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: False +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -ProgressAction {{ Fill ProgressAction Description }} diff --git a/src/Authentication/examples/Disconnect-MgGraph.md b/src/Authentication/examples/Disconnect-MgGraph.md index f60fbc769a8..39cdd1ec6b6 100644 --- a/src/Authentication/examples/Disconnect-MgGraph.md +++ b/src/Authentication/examples/Disconnect-MgGraph.md @@ -3,4 +3,10 @@ ```powershell PS C:\> Disconnect-MgGraph ``` -Use Disconnect-MgGraph to sign out. \ No newline at end of file +Use Disconnect-MgGraph to sign out. + +### Example 2: Sign out and also clear the Windows broker (WAM) cache +```powershell +PS C:\> Disconnect-MgGraph -SignOutFromBroker +``` +Signs out and additionally removes cached accounts from the Windows broker (WAM). Because the broker store is shared at the OS level, this can also sign you out of other broker-enabled applications (for example Visual Studio, Azure CLI, or Azure PowerShell) using the same Windows account. \ No newline at end of file From 208ec40421448b41335abed802562617a7c528b9 Mon Sep 17 00:00:00 2001 From: "Gavin Barron (from Dev Box)" Date: Tue, 7 Jul 2026 16:59:43 -0700 Subject: [PATCH 3/7] feat: narrow broker cache clearing to the current session's account ClearBrokerTokenCacheAsync now removes only the account that signed in for the current session, matched via the persisted AuthenticationRecord.HomeAccountId against IAccount.HomeAccountId.Identifier. When no session account can be identified, it falls back to removing all accounts for the module. This reduces the shared-broker blast radius so other accounts the user signed into via this module are left untouched. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Utilities/AuthenticationHelpers.cs | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/Authentication/Authentication.Core/Utilities/AuthenticationHelpers.cs b/src/Authentication/Authentication.Core/Utilities/AuthenticationHelpers.cs index 2fe4d62f5e5..715b9f623eb 100644 --- a/src/Authentication/Authentication.Core/Utilities/AuthenticationHelpers.cs +++ b/src/Authentication/Authentication.Core/Utilities/AuthenticationHelpers.cs @@ -476,6 +476,16 @@ public static async Task LogoutAsync(bool signOutFromBroker = fals /// broker-enabled applications (for example Visual Studio, Azure CLI, or Azure PowerShell) /// that are using the same Windows account. /// + /// + /// Removes cached accounts for the current module from the Windows broker (WAM). + /// This only has an effect on Windows when the broker is in use. When the current session's + /// account can be identified (via the persisted ), only that + /// account is removed to limit the impact on other broker-enabled applications. When no account + /// can be identified, all accounts for this module are removed as a fallback. + /// Because the broker store is shared at the OS level, removing an account here may also sign + /// the user out of other broker-enabled applications (for example Visual Studio, Azure CLI, or + /// Azure PowerShell) that are using the same Windows account. + /// /// The whose broker accounts should be removed. private static async Task ClearBrokerTokenCacheAsync(IAuthContext authContext) { @@ -490,12 +500,51 @@ private static async Task ClearBrokerTokenCacheAsync(IAuthContext authContext) .Build(); var accounts = await pca.GetAccountsAsync().ConfigureAwait(false); + + // Narrow removal to the account that signed in for this session, identified by the + // HomeAccountId persisted in the AuthenticationRecord. This avoids removing other accounts + // the user may have signed into via this module from the shared broker store. + var homeAccountId = await GetCurrentHomeAccountIdAsync().ConfigureAwait(false); + if (!string.IsNullOrEmpty(homeAccountId)) + { + var matchingAccounts = accounts + .Where(a => string.Equals(a.HomeAccountId?.Identifier, homeAccountId, StringComparison.OrdinalIgnoreCase)) + .ToList(); + if (matchingAccounts.Count > 0) + { + foreach (var account in matchingAccounts) + { + await pca.RemoveAsync(account).ConfigureAwait(false); + } + return; + } + } + + // Fallback: no identifiable session account, remove all accounts for this module. foreach (var account in accounts) { await pca.RemoveAsync(account).ConfigureAwait(false); } } + /// + /// Reads the HomeAccountId of the account persisted for the current session, if any. + /// Returns null when no authentication record is available or it cannot be read. + /// + private static async Task GetCurrentHomeAccountIdAsync() + { + try + { + var authRecord = await ReadAuthRecordAsync().ConfigureAwait(false); + return authRecord?.HomeAccountId; + } + catch + { + // A missing or unreadable auth record simply means we cannot narrow the removal. + return null; + } + } + /// /// Writes a best-effort diagnostic for a non-fatal cache clearing failure. Logging must never /// prevent Disconnect-MgGraph from completing, so any failure to log is swallowed. From cdc9d50aa1b56785bdec1bc67a230f7cf7b3ba57 Mon Sep 17 00:00:00 2001 From: "Gavin Barron (from Dev Box)" Date: Tue, 7 Jul 2026 17:05:03 -0700 Subject: [PATCH 4/7] feat: narrow persisted file cache clearing to the current session's account ClearPersistedTokenCacheAsync now registers the on-disk MSAL cache with a public client application and removes only the current session's account (matched by HomeAccountId) from both the .cae and .nocae cache files. When no session account can be identified, it falls back to a full MsalCacheHelper.Clear(). HomeAccountId is now resolved once in LogoutAsync and shared between the file-cache and broker clearing paths for consistent, account-scoped sign-out. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Utilities/AuthenticationHelpers.cs | 20 ++++++-- .../Utilities/TokenCacheUtilities.cs | 46 +++++++++++++++++-- 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/src/Authentication/Authentication.Core/Utilities/AuthenticationHelpers.cs b/src/Authentication/Authentication.Core/Utilities/AuthenticationHelpers.cs index 715b9f623eb..6818184dca8 100644 --- a/src/Authentication/Authentication.Core/Utilities/AuthenticationHelpers.cs +++ b/src/Authentication/Authentication.Core/Utilities/AuthenticationHelpers.cs @@ -436,11 +436,20 @@ public static async Task LogoutAsync(bool signOutFromBroker = fals { var authContext = GraphSession.Instance.AuthContext; GraphSession.Instance.InMemoryTokenCache?.ClearCache(); + + // Identify the account that signed in for this session so cache clearing can be scoped to + // it. When unavailable, callers fall back to clearing all accounts for the module. + var homeAccountId = await GetCurrentHomeAccountIdAsync().ConfigureAwait(false); + if (authContext?.ContextScope == ContextScope.CurrentUser) { try { - await TokenCacheUtilities.ClearPersistedTokenCacheAsync(Constants.CacheName).ConfigureAwait(false); + await TokenCacheUtilities.ClearPersistedTokenCacheAsync( + Constants.CacheName, + authContext.ClientId, + GetAuthorityUrl(authContext), + homeAccountId).ConfigureAwait(false); } catch (Exception ex) { @@ -454,7 +463,7 @@ public static async Task LogoutAsync(bool signOutFromBroker = fals { try { - await ClearBrokerTokenCacheAsync(authContext).ConfigureAwait(false); + await ClearBrokerTokenCacheAsync(authContext, homeAccountId).ConfigureAwait(false); } catch (Exception ex) { @@ -487,7 +496,11 @@ public static async Task LogoutAsync(bool signOutFromBroker = fals /// Azure PowerShell) that are using the same Windows account. /// /// The whose broker accounts should be removed. - private static async Task ClearBrokerTokenCacheAsync(IAuthContext authContext) + /// + /// The HomeAccountId of the current session's account, used to scope removal to that account. + /// When null or empty, all accounts for the module are removed as a fallback. + /// + private static async Task ClearBrokerTokenCacheAsync(IAuthContext authContext, string homeAccountId) { if (authContext is null) throw new AuthenticationException(ErrorConstants.Message.MissingAuthContext); @@ -504,7 +517,6 @@ private static async Task ClearBrokerTokenCacheAsync(IAuthContext authContext) // Narrow removal to the account that signed in for this session, identified by the // HomeAccountId persisted in the AuthenticationRecord. This avoids removing other accounts // the user may have signed into via this module from the shared broker store. - var homeAccountId = await GetCurrentHomeAccountIdAsync().ConfigureAwait(false); if (!string.IsNullOrEmpty(homeAccountId)) { var matchingAccounts = accounts diff --git a/src/Authentication/Authentication.Core/Utilities/TokenCacheUtilities.cs b/src/Authentication/Authentication.Core/Utilities/TokenCacheUtilities.cs index 8f6fcc3f023..3af70927031 100644 --- a/src/Authentication/Authentication.Core/Utilities/TokenCacheUtilities.cs +++ b/src/Authentication/Authentication.Core/Utilities/TokenCacheUtilities.cs @@ -2,10 +2,12 @@ // Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. // ------------------------------------------------------------------------------ +using Microsoft.Identity.Client; using Microsoft.Identity.Client.Extensions.Msal; using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Threading.Tasks; namespace Microsoft.Graph.PowerShell.Authentication.Core.Utilities @@ -37,16 +39,24 @@ internal static class TokenCacheUtilities /// /// Clears the persisted MSAL token cache files created by Azure.Identity /// for the given cache name. Clears both CAE-enabled and CAE-disabled variants. + /// When identifies the current session's account, only that + /// account is removed from the cache; otherwise the entire cache is cleared as a fallback. /// /// The cache name (e.g., "mg.msal.cache"). - public static async Task ClearPersistedTokenCacheAsync(string cacheName) + /// The module's client id, used to enumerate cached accounts. + /// The authority URL used to build the public client application. + /// + /// The HomeAccountId of the current session's account. When null or empty, or when no + /// matching account is found, the entire cache is cleared. + /// + public static async Task ClearPersistedTokenCacheAsync(string cacheName, string clientId = null, string authority = null, string homeAccountId = null) { // Azure.Identity creates separate caches for CAE-enabled and CAE-disabled tokens. - await ClearCacheAsync(cacheName + CaeEnabledSuffix).ConfigureAwait(false); - await ClearCacheAsync(cacheName + CaeDisabledSuffix).ConfigureAwait(false); + await ClearCacheAsync(cacheName + CaeEnabledSuffix, clientId, authority, homeAccountId).ConfigureAwait(false); + await ClearCacheAsync(cacheName + CaeDisabledSuffix, clientId, authority, homeAccountId).ConfigureAwait(false); } - private static async Task ClearCacheAsync(string cacheFileName) + private static async Task ClearCacheAsync(string cacheFileName, string clientId, string authority, string homeAccountId) { var storageProperties = new StorageCreationPropertiesBuilder(cacheFileName, DefaultCacheDirectory) .WithMacKeyChain(DefaultCacheKeychainService, cacheFileName) @@ -59,6 +69,34 @@ private static async Task ClearCacheAsync(string cacheFileName) .Build(); var cacheHelper = await MsalCacheHelper.CreateAsync(storageProperties).ConfigureAwait(false); + + // When the current session's account can be identified, remove only that account from the + // persisted cache so other accounts the user has signed into this module remain intact. + if (!string.IsNullOrEmpty(homeAccountId) && !string.IsNullOrEmpty(clientId)) + { + var builder = PublicClientApplicationBuilder.Create(clientId); + if (!string.IsNullOrEmpty(authority)) + { + builder = builder.WithAuthority(authority); + } + var pca = builder.Build(); + cacheHelper.RegisterCache(pca.UserTokenCache); + + var accounts = await pca.GetAccountsAsync().ConfigureAwait(false); + var matchingAccounts = accounts + .Where(a => string.Equals(a.HomeAccountId?.Identifier, homeAccountId, StringComparison.OrdinalIgnoreCase)) + .ToList(); + if (matchingAccounts.Count > 0) + { + foreach (var account in matchingAccounts) + { + await pca.RemoveAsync(account).ConfigureAwait(false); + } + return; + } + } + + // Fallback: no identifiable session account, clear the entire cache. #pragma warning disable CS0618 // MsalCacheHelper.Clear is obsolete but is the correct approach for full cache wipe on disconnect cacheHelper.Clear(); #pragma warning restore CS0618 From 582d652b67fe539708323853af17dfa4da7387fd Mon Sep 17 00:00:00 2001 From: "Gavin Barron (from Dev Box)" Date: Tue, 7 Jul 2026 17:27:01 -0700 Subject: [PATCH 5/7] feat: add HomeAccountId to IAuthContext for session-scoped disconnect isolation Captures the signed-in account's HomeAccountId on the session auth context at sign-in (interactive and device-code flows). LogoutAsync now scopes cache clearing on the session's own HomeAccountId, falling back to the shared auth record and then to a full clear. This isolates Disconnect-MgGraph to the identity used in the current session, so other identities cached in the shared per-user store are preserved. HomeAccountId is read defensively so capturing this diagnostic id can never fail sign-in. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Interfaces/IAuthContext.cs | 1 + .../Utilities/AuthenticationHelpers.cs | 36 ++++++++++++++++--- .../TokenCache/TokenCacheUtilitiesTests.cs | 22 ++++++++++++ .../Authentication/Models/AuthContext.cs | 1 + 4 files changed, 55 insertions(+), 5 deletions(-) diff --git a/src/Authentication/Authentication.Core/Interfaces/IAuthContext.cs b/src/Authentication/Authentication.Core/Interfaces/IAuthContext.cs index 94ca3f9cd2d..9956c8dcf25 100644 --- a/src/Authentication/Authentication.Core/Interfaces/IAuthContext.cs +++ b/src/Authentication/Authentication.Core/Interfaces/IAuthContext.cs @@ -45,6 +45,7 @@ public interface IAuthContext string Environment { get; set; } string AppName { get; set; } string Account { get; set; } + string HomeAccountId { get; set; } string CertificateThumbprint { get; set; } string CertificateSubjectName { get; set; } bool SendCertificateChain { get; set; } diff --git a/src/Authentication/Authentication.Core/Utilities/AuthenticationHelpers.cs b/src/Authentication/Authentication.Core/Utilities/AuthenticationHelpers.cs index 6818184dca8..e190172495f 100644 --- a/src/Authentication/Authentication.Core/Utilities/AuthenticationHelpers.cs +++ b/src/Authentication/Authentication.Core/Utilities/AuthenticationHelpers.cs @@ -141,10 +141,13 @@ private static async Task GetInteractiveBrowserCre }); } await WriteAuthRecordAsync(authRecord).ConfigureAwait(false); + authContext.HomeAccountId = TryGetHomeAccountId(authRecord); return interactiveBrowserCredential; } - interactiveOptions.AuthenticationRecord = await ReadAuthRecordAsync().ConfigureAwait(false); + var interactiveAuthRecord = await ReadAuthRecordAsync().ConfigureAwait(false); + interactiveOptions.AuthenticationRecord = interactiveAuthRecord; + authContext.HomeAccountId = TryGetHomeAccountId(interactiveAuthRecord); return new InteractiveBrowserCredential(interactiveOptions); } @@ -182,10 +185,13 @@ private static async Task GetDeviceCodeCredentialAsync(IAu var deviceCodeCredential = new DeviceCodeCredential(deviceCodeOptions); var authRecord = await deviceCodeCredential.AuthenticateAsync(new TokenRequestContext(authContext.Scopes), cancellationToken).ConfigureAwait(false); await WriteAuthRecordAsync(authRecord).ConfigureAwait(false); + authContext.HomeAccountId = TryGetHomeAccountId(authRecord); return deviceCodeCredential; } - deviceCodeOptions.AuthenticationRecord = await ReadAuthRecordAsync().ConfigureAwait(false); + var deviceCodeAuthRecord = await ReadAuthRecordAsync().ConfigureAwait(false); + deviceCodeOptions.AuthenticationRecord = deviceCodeAuthRecord; + authContext.HomeAccountId = TryGetHomeAccountId(deviceCodeAuthRecord); return new DeviceCodeCredential(deviceCodeOptions); } @@ -438,8 +444,12 @@ public static async Task LogoutAsync(bool signOutFromBroker = fals GraphSession.Instance.InMemoryTokenCache?.ClearCache(); // Identify the account that signed in for this session so cache clearing can be scoped to - // it. When unavailable, callers fall back to clearing all accounts for the module. - var homeAccountId = await GetCurrentHomeAccountIdAsync().ConfigureAwait(false); + // it. Prefer the HomeAccountId captured on the session's auth context (set at sign-in) for + // correct isolation when multiple identities share the per-user persisted store. Fall back + // to the persisted auth record, then to clearing all accounts when neither is available. + var homeAccountId = !string.IsNullOrEmpty(authContext?.HomeAccountId) + ? authContext.HomeAccountId + : await GetCurrentHomeAccountIdAsync().ConfigureAwait(false); if (authContext?.ContextScope == ContextScope.CurrentUser) { @@ -548,7 +558,7 @@ private static async Task GetCurrentHomeAccountIdAsync() try { var authRecord = await ReadAuthRecordAsync().ConfigureAwait(false); - return authRecord?.HomeAccountId; + return TryGetHomeAccountId(authRecord); } catch { @@ -557,6 +567,22 @@ private static async Task GetCurrentHomeAccountIdAsync() } } + /// + /// Safely reads the HomeAccountId from an . Capturing this + /// diagnostic identifier must never fail sign-in, so any error yields null. + /// + private static string TryGetHomeAccountId(AuthenticationRecord authRecord) + { + try + { + return authRecord?.HomeAccountId; + } + catch + { + return null; + } + } + /// /// Writes a best-effort diagnostic for a non-fatal cache clearing failure. Logging must never /// prevent Disconnect-MgGraph from completing, so any failure to log is swallowed. diff --git a/src/Authentication/Authentication.Test/TokenCache/TokenCacheUtilitiesTests.cs b/src/Authentication/Authentication.Test/TokenCache/TokenCacheUtilitiesTests.cs index 5c24256d511..d64b56e4c6b 100644 --- a/src/Authentication/Authentication.Test/TokenCache/TokenCacheUtilitiesTests.cs +++ b/src/Authentication/Authentication.Test/TokenCache/TokenCacheUtilitiesTests.cs @@ -100,6 +100,28 @@ public async Task LogoutAsyncWithSignOutFromBrokerShouldNotThrowWhenBrokerNotInU Assert.Null(GraphSession.Instance.GraphHttpClient); } + [Fact] + public async Task LogoutAsyncShouldUseAuthContextHomeAccountIdForCurrentUserScope() + { + // Arrange - a CurrentUser context with a session HomeAccountId exercises the account-scoped + // file cache clearing path. With no matching account on disk it safely falls back. + GraphSession.Instance.AuthContext = new AuthContext + { + AuthType = AuthenticationType.Delegated, + ContextScope = ContextScope.CurrentUser, + HomeAccountId = "00000000-0000-0000-0000-000000000000.11111111-1111-1111-1111-111111111111" + }; + + // Act - should not throw even if no persisted cache exists on disk + var result = await AuthenticationHelpers.LogoutAsync(); + + // Assert + Assert.NotNull(result); + Assert.Equal(ContextScope.CurrentUser, result.ContextScope); + Assert.Equal("00000000-0000-0000-0000-000000000000.11111111-1111-1111-1111-111111111111", result.HomeAccountId); + Assert.Null(GraphSession.Instance.AuthContext); + } + [Fact] public async Task LogoutAsyncWithSignOutFromBrokerShouldStillClearContextForCurrentUser() { diff --git a/src/Authentication/Authentication/Models/AuthContext.cs b/src/Authentication/Authentication/Models/AuthContext.cs index 58c4b85949e..94fc9aabef2 100644 --- a/src/Authentication/Authentication/Models/AuthContext.cs +++ b/src/Authentication/Authentication/Models/AuthContext.cs @@ -21,6 +21,7 @@ public class AuthContext : IAuthContext public string CertificateSubjectName { get; set; } public bool SendCertificateChain { get; set; } public string Account { get; set; } + public string HomeAccountId { get; set; } public string AppName { get; set; } public ContextScope ContextScope { get; set; } public X509Certificate2 Certificate { get; set; } From 491caff4a1c2fde96e0e2e25a9b4177b647e5856 Mon Sep 17 00:00:00 2001 From: "Gavin Barron (from Dev Box)" Date: Tue, 7 Jul 2026 17:45:01 -0700 Subject: [PATCH 6/7] docs: fill in -ProgressAction description for Disconnect-MgGraph Replaces the platyPS-generated '{{ Fill ProgressAction Description }}' placeholder with a proper description of the PowerShell 7.4+ ProgressAction common parameter. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Authentication/docs/Disconnect-MgGraph.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Authentication/docs/Disconnect-MgGraph.md b/src/Authentication/docs/Disconnect-MgGraph.md index 6883ac73d29..58db78b868e 100644 --- a/src/Authentication/docs/Disconnect-MgGraph.md +++ b/src/Authentication/docs/Disconnect-MgGraph.md @@ -53,7 +53,7 @@ Accept wildcard characters: False ``` ### -ProgressAction -{{ Fill ProgressAction Description }} +Determines how PowerShell responds to progress updates generated by this cmdlet. This is a common parameter introduced in PowerShell 7.4. ```yaml Type: ActionPreference From ad51fa5d615a8dd0c22da0c26b7d4cef975c9419 Mon Sep 17 00:00:00 2001 From: "Gavin Barron (from Dev Box)" Date: Wed, 8 Jul 2026 08:11:43 -0700 Subject: [PATCH 7/7] test: update Disconnect-MgGraph parameter count for -SignOutFromBroker The new -SignOutFromBroker switch raises the parameter count from 12 to 13 (SignOutFromBroker + PowerShell common parameters), so the Pester assertion is updated accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Authentication/test/Disconnect-MgGraph.Tests.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Authentication/Authentication/test/Disconnect-MgGraph.Tests.ps1 b/src/Authentication/Authentication/test/Disconnect-MgGraph.Tests.ps1 index 2bd31cd4de6..5eb69807e8e 100644 --- a/src/Authentication/Authentication/test/Disconnect-MgGraph.Tests.ps1 +++ b/src/Authentication/Authentication/test/Disconnect-MgGraph.Tests.ps1 @@ -20,7 +20,7 @@ Describe 'Disconnect-MgGraph' { $DisconnectMgGraphCommand = Get-Command Disconnect-MgGraph $DisconnectMgGraphCommand | Should -Not -BeNullOrEmpty $DisconnectMgGraphCommand.ParameterSets | Should -HaveCount 1 - $DisconnectMgGraphCommand.ParameterSets.Parameters | Should -HaveCount 12 # PS common parameters. + $DisconnectMgGraphCommand.ParameterSets.Parameters | Should -HaveCount 13 # SignOutFromBroker + PS common parameters. } It 'Should remove current AuthContext' {