Skip to content

Commit f6d24ad

Browse files
gavinbarronCopilotramsessanchez
authored
fix: Clear persisted MSAL token cache on Disconnect-MgGraph (#3649)
* 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> * 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> * 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> * 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> * 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> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Ramses Sanchez-Hernandez <63934382+ramsessanchez@users.noreply.github.com>
1 parent d45aeb2 commit f6d24ad

11 files changed

Lines changed: 485 additions & 11 deletions

File tree

src/Authentication/Authentication.Core/Interfaces/IAuthContext.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ public interface IAuthContext
4545
string Environment { get; set; }
4646
string AppName { get; set; }
4747
string Account { get; set; }
48+
string HomeAccountId { get; set; }
4849
string CertificateThumbprint { get; set; }
4950
string CertificateSubjectName { get; set; }
5051
bool SendCertificateChain { get; set; }

src/Authentication/Authentication.Core/Microsoft.Graph.Authentication.Core.csproj

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<LangVersion>9.0</LangVersion>
55
<TargetFrameworks>netstandard2.0;net6.0;net472</TargetFrameworks>
66
<RootNamespace>Microsoft.Graph.PowerShell.Authentication.Core</RootNamespace>
7-
<Version>2.35.1</Version>
7+
<Version>2.38.0</Version>
88
<!-- Suppress .NET Target Framework Moniker (TFM) Support Build Warnings -->
99
<SuppressTfmSupportBuildWarnings>true</SuppressTfmSupportBuildWarnings>
1010
</PropertyGroup>
@@ -20,6 +20,10 @@
2020
<PackageReference Include="Azure.Identity.Broker" Version="1.4.0" />
2121
<!-- Explicitly reference Microsoft.Identity.Client to ensure form_post support -->
2222
<PackageReference Include="Microsoft.Identity.Client" Version="4.82.1" />
23+
<!-- Explicitly reference the MSAL broker to clear WAM/broker cached accounts on disconnect -->
24+
<PackageReference Include="Microsoft.Identity.Client.Broker" Version="4.82.1" />
25+
<!-- Explicitly reference MSAL Extensions to clear persisted token cache on disconnect -->
26+
<PackageReference Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.78.0" />
2327
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.3" />
2428
<PackageReference Include="Microsoft.Graph.Core" Version="4.0.1" />
2529
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />

src/Authentication/Authentication.Core/Utilities/AuthenticationHelpers.cs

Lines changed: 169 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using Microsoft.Graph.Authentication;
99
using Microsoft.Graph.PowerShell.Authentication.Core.Extensions;
1010
using Microsoft.Identity.Client;
11+
using Microsoft.Identity.Client.Broker;
1112
using Microsoft.Identity.Client.Extensions.Msal;
1213
using System;
1314
using System.Diagnostics.Tracing;
@@ -140,10 +141,13 @@ private static async Task<InteractiveBrowserCredential> GetInteractiveBrowserCre
140141
});
141142
}
142143
await WriteAuthRecordAsync(authRecord).ConfigureAwait(false);
144+
authContext.HomeAccountId = TryGetHomeAccountId(authRecord);
143145
return interactiveBrowserCredential;
144146
}
145147

146-
interactiveOptions.AuthenticationRecord = await ReadAuthRecordAsync().ConfigureAwait(false);
148+
var interactiveAuthRecord = await ReadAuthRecordAsync().ConfigureAwait(false);
149+
interactiveOptions.AuthenticationRecord = interactiveAuthRecord;
150+
authContext.HomeAccountId = TryGetHomeAccountId(interactiveAuthRecord);
147151
return new InteractiveBrowserCredential(interactiveOptions);
148152
}
149153

@@ -181,10 +185,13 @@ private static async Task<DeviceCodeCredential> GetDeviceCodeCredentialAsync(IAu
181185
var deviceCodeCredential = new DeviceCodeCredential(deviceCodeOptions);
182186
var authRecord = await deviceCodeCredential.AuthenticateAsync(new TokenRequestContext(authContext.Scopes), cancellationToken).ConfigureAwait(false);
183187
await WriteAuthRecordAsync(authRecord).ConfigureAwait(false);
188+
authContext.HomeAccountId = TryGetHomeAccountId(authRecord);
184189
return deviceCodeCredential;
185190
}
186191

187-
deviceCodeOptions.AuthenticationRecord = await ReadAuthRecordAsync().ConfigureAwait(false);
192+
var deviceCodeAuthRecord = await ReadAuthRecordAsync().ConfigureAwait(false);
193+
deviceCodeOptions.AuthenticationRecord = deviceCodeAuthRecord;
194+
authContext.HomeAccountId = TryGetHomeAccountId(deviceCodeAuthRecord);
188195
return new DeviceCodeCredential(deviceCodeOptions);
189196
}
190197

@@ -425,17 +432,175 @@ private static bool TryFindCertificateBySubjectName(string subjectName, StoreLoc
425432
/// <summary>
426433
/// Signs out of the current session using the provided <see cref="IAuthContext"/>.
427434
/// </summary>
428-
/// <param name="authContext">The <see cref="IAuthContext"/> to sign-out from.</param>
429-
public static async Task<IAuthContext> LogoutAsync()
435+
/// <param name="signOutFromBroker">
436+
/// When <c>true</c> and the Windows broker (WAM) is in use, cached accounts for this module
437+
/// are also removed from the broker. This affects the shared OS-level broker store and may
438+
/// sign the user out of other broker-enabled applications using the same Windows account.
439+
/// </param>
440+
/// <returns>The <see cref="IAuthContext"/> that was signed out from.</returns>
441+
public static async Task<IAuthContext> LogoutAsync(bool signOutFromBroker = false)
430442
{
431443
var authContext = GraphSession.Instance.AuthContext;
432444
GraphSession.Instance.InMemoryTokenCache?.ClearCache();
445+
446+
// Identify the account that signed in for this session so cache clearing can be scoped to
447+
// it. Prefer the HomeAccountId captured on the session's auth context (set at sign-in) for
448+
// correct isolation when multiple identities share the per-user persisted store. Fall back
449+
// to the persisted auth record, then to clearing all accounts when neither is available.
450+
var homeAccountId = !string.IsNullOrEmpty(authContext?.HomeAccountId)
451+
? authContext.HomeAccountId
452+
: await GetCurrentHomeAccountIdAsync().ConfigureAwait(false);
453+
454+
if (authContext?.ContextScope == ContextScope.CurrentUser)
455+
{
456+
try
457+
{
458+
await TokenCacheUtilities.ClearPersistedTokenCacheAsync(
459+
Constants.CacheName,
460+
authContext.ClientId,
461+
GetAuthorityUrl(authContext),
462+
homeAccountId).ConfigureAwait(false);
463+
}
464+
catch (Exception ex)
465+
{
466+
// Non-fatal: persisted cache clearing may fail on some platforms.
467+
// The auth record and in-memory state are still cleared below.
468+
LogCacheClearFailure("Failed to clear the persisted MSAL token cache during Disconnect-MgGraph", ex);
469+
}
470+
}
471+
472+
if (signOutFromBroker && authContext != null && ShouldUseWam(authContext))
473+
{
474+
try
475+
{
476+
await ClearBrokerTokenCacheAsync(authContext, homeAccountId).ConfigureAwait(false);
477+
}
478+
catch (Exception ex)
479+
{
480+
// Non-fatal: removing broker accounts may fail. Disconnect still completes.
481+
LogCacheClearFailure("Failed to remove cached accounts from the Windows broker (WAM) during Disconnect-MgGraph", ex);
482+
}
483+
}
484+
433485
GraphSession.Instance.AuthContext = null;
434486
GraphSession.Instance.GraphHttpClient = null;
435487
await DeleteAuthRecordAsync().ConfigureAwait(false);
436488
return authContext;
437489
}
438490

491+
/// <summary>
492+
/// Removes cached accounts for the current module from the Windows broker (WAM).
493+
/// This only has an effect on Windows when the broker is in use. Because the broker store is
494+
/// shared at the OS level, removing accounts here may also sign the user out of other
495+
/// broker-enabled applications (for example Visual Studio, Azure CLI, or Azure PowerShell)
496+
/// that are using the same Windows account.
497+
/// </summary>
498+
/// <summary>
499+
/// Removes cached accounts for the current module from the Windows broker (WAM).
500+
/// This only has an effect on Windows when the broker is in use. When the current session's
501+
/// account can be identified (via the persisted <see cref="AuthenticationRecord"/>), only that
502+
/// account is removed to limit the impact on other broker-enabled applications. When no account
503+
/// can be identified, all accounts for this module are removed as a fallback.
504+
/// Because the broker store is shared at the OS level, removing an account here may also sign
505+
/// the user out of other broker-enabled applications (for example Visual Studio, Azure CLI, or
506+
/// Azure PowerShell) that are using the same Windows account.
507+
/// </summary>
508+
/// <param name="authContext">The <see cref="IAuthContext"/> whose broker accounts should be removed.</param>
509+
/// <param name="homeAccountId">
510+
/// The HomeAccountId of the current session's account, used to scope removal to that account.
511+
/// When <c>null</c> or empty, all accounts for the module are removed as a fallback.
512+
/// </param>
513+
private static async Task ClearBrokerTokenCacheAsync(IAuthContext authContext, string homeAccountId)
514+
{
515+
if (authContext is null)
516+
throw new AuthenticationException(ErrorConstants.Message.MissingAuthContext);
517+
518+
var pca = PublicClientApplicationBuilder
519+
.Create(authContext.ClientId)
520+
.WithAuthority(GetAuthorityUrl(authContext))
521+
.WithBroker(new BrokerOptions(BrokerOptions.OperatingSystems.Windows))
522+
.WithParentActivityOrWindow(WindowHandleUtlities.GetConsoleOrTerminalWindow)
523+
.Build();
524+
525+
var accounts = await pca.GetAccountsAsync().ConfigureAwait(false);
526+
527+
// Narrow removal to the account that signed in for this session, identified by the
528+
// HomeAccountId persisted in the AuthenticationRecord. This avoids removing other accounts
529+
// the user may have signed into via this module from the shared broker store.
530+
if (!string.IsNullOrEmpty(homeAccountId))
531+
{
532+
var matchingAccounts = accounts
533+
.Where(a => string.Equals(a.HomeAccountId?.Identifier, homeAccountId, StringComparison.OrdinalIgnoreCase))
534+
.ToList();
535+
if (matchingAccounts.Count > 0)
536+
{
537+
foreach (var account in matchingAccounts)
538+
{
539+
await pca.RemoveAsync(account).ConfigureAwait(false);
540+
}
541+
return;
542+
}
543+
}
544+
545+
// Fallback: no identifiable session account, remove all accounts for this module.
546+
foreach (var account in accounts)
547+
{
548+
await pca.RemoveAsync(account).ConfigureAwait(false);
549+
}
550+
}
551+
552+
/// <summary>
553+
/// Reads the HomeAccountId of the account persisted for the current session, if any.
554+
/// Returns <c>null</c> when no authentication record is available or it cannot be read.
555+
/// </summary>
556+
private static async Task<string> GetCurrentHomeAccountIdAsync()
557+
{
558+
try
559+
{
560+
var authRecord = await ReadAuthRecordAsync().ConfigureAwait(false);
561+
return TryGetHomeAccountId(authRecord);
562+
}
563+
catch
564+
{
565+
// A missing or unreadable auth record simply means we cannot narrow the removal.
566+
return null;
567+
}
568+
}
569+
570+
/// <summary>
571+
/// Safely reads the HomeAccountId from an <see cref="AuthenticationRecord"/>. Capturing this
572+
/// diagnostic identifier must never fail sign-in, so any error yields <c>null</c>.
573+
/// </summary>
574+
private static string TryGetHomeAccountId(AuthenticationRecord authRecord)
575+
{
576+
try
577+
{
578+
return authRecord?.HomeAccountId;
579+
}
580+
catch
581+
{
582+
return null;
583+
}
584+
}
585+
586+
/// <summary>
587+
/// Writes a best-effort diagnostic for a non-fatal cache clearing failure. Logging must never
588+
/// prevent Disconnect-MgGraph from completing, so any failure to log is swallowed.
589+
/// </summary>
590+
private static void LogCacheClearFailure(string summary, Exception ex)
591+
{
592+
try
593+
{
594+
var writer = GraphSession.Instance.OutputWriter;
595+
writer.WriteWarning?.Invoke($"{summary}: {ex.Message}");
596+
writer.WriteDebug?.Invoke($"{summary}: {ex}");
597+
}
598+
catch
599+
{
600+
// Diagnostics are best-effort and must not break sign-out.
601+
}
602+
}
603+
439604
private static async Task<AuthenticationRecord> ReadAuthRecordAsync()
440605
{
441606
// Try to create directory if it doesn't exist.
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// ------------------------------------------------------------------------------
2+
// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information.
3+
// ------------------------------------------------------------------------------
4+
5+
using Microsoft.Identity.Client;
6+
using Microsoft.Identity.Client.Extensions.Msal;
7+
using System;
8+
using System.Collections.Generic;
9+
using System.IO;
10+
using System.Linq;
11+
using System.Threading.Tasks;
12+
13+
namespace Microsoft.Graph.PowerShell.Authentication.Core.Utilities
14+
{
15+
/// <summary>
16+
/// Utilities for managing the MSAL token cache persisted to disk by Azure.Identity.
17+
/// </summary>
18+
internal static class TokenCacheUtilities
19+
{
20+
// Azure.Identity internal constants for cache storage configuration.
21+
// See: Azure/azure-sdk-for-net - sdk/core/Azure.Core/src/Identity/Constants.cs
22+
private const string DefaultCacheKeychainService = "Microsoft.Developer.IdentityService";
23+
private const string DefaultCacheKeyringSchema = "msal.cache";
24+
private const string DefaultCacheKeyringCollection = "default";
25+
private static readonly KeyValuePair<string, string> DefaultCacheKeyringAttribute1 =
26+
new KeyValuePair<string, string>("MsalClientID", "Microsoft.Developer.IdentityService");
27+
private static readonly KeyValuePair<string, string> DefaultCacheKeyringAttribute2 =
28+
new KeyValuePair<string, string>("Microsoft.Developer.IdentityService", "1.0.0.0");
29+
30+
// Azure.Identity appends CAE suffixes to the cache name internally.
31+
private const string CaeEnabledSuffix = ".cae";
32+
private const string CaeDisabledSuffix = ".nocae";
33+
34+
private static readonly string DefaultCacheDirectory =
35+
Path.Combine(
36+
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
37+
".IdentityService");
38+
39+
/// <summary>
40+
/// Clears the persisted MSAL token cache files created by Azure.Identity
41+
/// for the given cache name. Clears both CAE-enabled and CAE-disabled variants.
42+
/// When <paramref name="homeAccountId"/> identifies the current session's account, only that
43+
/// account is removed from the cache; otherwise the entire cache is cleared as a fallback.
44+
/// </summary>
45+
/// <param name="cacheName">The cache name (e.g., "mg.msal.cache").</param>
46+
/// <param name="clientId">The module's client id, used to enumerate cached accounts.</param>
47+
/// <param name="authority">The authority URL used to build the public client application.</param>
48+
/// <param name="homeAccountId">
49+
/// The HomeAccountId of the current session's account. When <c>null</c> or empty, or when no
50+
/// matching account is found, the entire cache is cleared.
51+
/// </param>
52+
public static async Task ClearPersistedTokenCacheAsync(string cacheName, string clientId = null, string authority = null, string homeAccountId = null)
53+
{
54+
// Azure.Identity creates separate caches for CAE-enabled and CAE-disabled tokens.
55+
await ClearCacheAsync(cacheName + CaeEnabledSuffix, clientId, authority, homeAccountId).ConfigureAwait(false);
56+
await ClearCacheAsync(cacheName + CaeDisabledSuffix, clientId, authority, homeAccountId).ConfigureAwait(false);
57+
}
58+
59+
private static async Task ClearCacheAsync(string cacheFileName, string clientId, string authority, string homeAccountId)
60+
{
61+
var storageProperties = new StorageCreationPropertiesBuilder(cacheFileName, DefaultCacheDirectory)
62+
.WithMacKeyChain(DefaultCacheKeychainService, cacheFileName)
63+
.WithLinuxKeyring(
64+
DefaultCacheKeyringSchema,
65+
DefaultCacheKeyringCollection,
66+
cacheFileName,
67+
DefaultCacheKeyringAttribute1,
68+
DefaultCacheKeyringAttribute2)
69+
.Build();
70+
71+
var cacheHelper = await MsalCacheHelper.CreateAsync(storageProperties).ConfigureAwait(false);
72+
73+
// When the current session's account can be identified, remove only that account from the
74+
// persisted cache so other accounts the user has signed into this module remain intact.
75+
if (!string.IsNullOrEmpty(homeAccountId) && !string.IsNullOrEmpty(clientId))
76+
{
77+
var builder = PublicClientApplicationBuilder.Create(clientId);
78+
if (!string.IsNullOrEmpty(authority))
79+
{
80+
builder = builder.WithAuthority(authority);
81+
}
82+
var pca = builder.Build();
83+
cacheHelper.RegisterCache(pca.UserTokenCache);
84+
85+
var accounts = await pca.GetAccountsAsync().ConfigureAwait(false);
86+
var matchingAccounts = accounts
87+
.Where(a => string.Equals(a.HomeAccountId?.Identifier, homeAccountId, StringComparison.OrdinalIgnoreCase))
88+
.ToList();
89+
if (matchingAccounts.Count > 0)
90+
{
91+
foreach (var account in matchingAccounts)
92+
{
93+
await pca.RemoveAsync(account).ConfigureAwait(false);
94+
}
95+
return;
96+
}
97+
}
98+
99+
// Fallback: no identifiable session account, clear the entire cache.
100+
#pragma warning disable CS0618 // MsalCacheHelper.Clear is obsolete but is the correct approach for full cache wipe on disconnect
101+
cacheHelper.Clear();
102+
#pragma warning restore CS0618
103+
}
104+
}
105+
}

0 commit comments

Comments
 (0)