Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<LangVersion>9.0</LangVersion>
<TargetFrameworks>netstandard2.0;net6.0;net472</TargetFrameworks>
<RootNamespace>Microsoft.Graph.PowerShell.Authentication.Core</RootNamespace>
<Version>2.35.1</Version>
<Version>2.38.0</Version>
<!-- Suppress .NET Target Framework Moniker (TFM) Support Build Warnings -->
<SuppressTfmSupportBuildWarnings>true</SuppressTfmSupportBuildWarnings>
</PropertyGroup>
Expand All @@ -20,6 +20,8 @@
<PackageReference Include="Azure.Identity.Broker" Version="1.4.0" />
<!-- Explicitly reference Microsoft.Identity.Client to ensure form_post support -->
<PackageReference Include="Microsoft.Identity.Client" Version="4.82.1" />
<!-- Explicitly reference MSAL Extensions to clear persisted token cache on disconnect -->
<PackageReference Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.78.0" />
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.3" />
<PackageReference Include="Microsoft.Graph.Core" Version="4.0.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,18 @@ public static async Task<IAuthContext> LogoutAsync()
{
var authContext = GraphSession.Instance.AuthContext;
GraphSession.Instance.InMemoryTokenCache?.ClearCache();
if (authContext?.ContextScope == ContextScope.CurrentUser)
{
try
{
await TokenCacheUtilities.ClearPersistedTokenCacheAsync(Constants.CacheName).ConfigureAwait(false);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

on Windows, WAM is on by default for interactive sign-in, and WAM keeps the account + refresh token in its own cache. So cleaning this file won't actually sign the user out of WAM.

For the broker we would need to ask WAM to forget the account. Is the Windows Broker in scope for this fix?

Copilot suggested something like:

if (ShouldUseWam(authContext))
{
    var pca = PublicClientApplicationBuilder
        .Create(authContext.ClientId)
        .WithAuthority(GetAuthorityUrl(authContext))
        .WithBroker(new BrokerOptions(BrokerOptions.OperatingSystems.Windows))
        .Build();
    foreach (var account in await pca.GetAccountsAsync().ConfigureAwait(false))
        await pca.RemoveAsync(account).ConfigureAwait(false);
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JonathanCrd I think we probably need to clear the WAM token cache if possible to ensure a more complete fix.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additionally, would using this approach avoid the need to have the TokenCacheUtilities class?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think so, the broker only covers Windows, and there are non-broker auth flows on windows too.

@gavinbarron gavinbarron Jun 24, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JonathanCrd Sorry, I wasn't very clear here I was more meaning the general approach of using a PCA instance to clear the cached tokens not that specific implementation. Maybe something like this to provide broad cover and eliminate the additional code that knows about the caching specifics

      var builder = PublicClientApplicationBuilder
              .Create(authContext.ClientId)
              .WithAuthority(GetAuthorityUrl(authContext));
      if (ShouldUseWam(authContext))
      {
          builder = builder
              .WithBroker(new BrokerOptions(BrokerOptions.OperatingSystems.Windows));
      }
      var pca = builder.Build();
      foreach (var account in await pca.GetAccountsAsync().ConfigureAwait(false))
          await pca.RemoveAsync(account).ConfigureAwait(false);

@g2vinay g2vinay Jun 24, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TokenCacheUtilities -> ClearPersistedTokenCacheAsync still needs to be retained as it deletes the file based caches. PCA code above is needed on top to clear the WAM caches.

Just heads up
WAM is shared broker storage across Windows Azure tools. Removing accounts would also sign out:
Visual Studio
Azure CLI
Azure PowerShell
VS Code Azure extensions
Any app using Azure.Identity with broker enabled

This can impact UX for other logged in flows.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@g2vinay does the above code impact all those other apps given that the PCABuilder is specifically targeting the ClientId for this Module?

}
catch (Exception)
{

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any actual failure here would be silent, could you add a log here so it's diagnosable?

// 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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Utilities for managing the MSAL token cache persisted to disk by Azure.Identity.
/// </summary>
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<string, string> DefaultCacheKeyringAttribute1 =
new KeyValuePair<string, string>("MsalClientID", "Microsoft.Developer.IdentityService");
private static readonly KeyValuePair<string, string> DefaultCacheKeyringAttribute2 =
new KeyValuePair<string, string>("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");

/// <summary>
/// Clears the persisted MSAL token cache files created by Azure.Identity
/// for the given cache name. Clears both CAE-enabled and CAE-disabled variants.
/// </summary>
/// <param name="cacheName">The cache name (e.g., "mg.msal.cache").</param>
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();
Comment on lines +62 to +63

@JonathanCrd JonathanCrd Jun 23, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is Clear() the intentional approach here? This would wipe out the entire MSAL Cache, removing all accounts, not only the ones in the current context.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My thinking here is that it would be roughly equivalent to the approach of iterating over the results from .GetAccounts() to remove each or them, how does this differ given that the cache files are managed per machine user?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. My comment was more about removing only the account being disconnected rather than all accounts, mainly from a UX perspective. That said, if there aren't any issues with clearing all accounts, then this approach works for me.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CLI does not offer an account parameter, so any user account selection is done during the browser flow, which requires Disconnect-MgGraph to trigger the account selection again, so this feel like the right option. I suppose that there could be the case where a user has closed a terminal and is running Connect-MgGraph again triggering account selection.
But either way I'm comfortable with fully clearing out any cached tokens,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Msal cache scope is per window user, so .clear() will only scope to disconnecting user, I believe.

#pragma warning restore CS0618
}
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
2 changes: 1 addition & 1 deletion src/Authentication/descriptions/Disconnect-MgGraph.md
Original file line number Diff line number Diff line change
@@ -1 +1 @@
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.
19 changes: 17 additions & 2 deletions src/Authentication/docs/Disconnect-MgGraph.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ Once you're signed in, you'll remain signed in until you invoke Disconnect-MgGra
## SYNTAX

```
Disconnect-MgGraph [<CommonParameters>]
Disconnect-MgGraph [-ProgressAction <ActionPreference>] [<CommonParameters>]
```

## 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

Expand All @@ -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).

Expand Down
Loading