Skip to content
Merged
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 @@ -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; }
Expand Down
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,10 @@
<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 the MSAL broker to clear WAM/broker cached accounts on disconnect -->
<PackageReference Include="Microsoft.Identity.Client.Broker" 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 @@ -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;
Expand Down Expand Up @@ -140,10 +141,13 @@ private static async Task<InteractiveBrowserCredential> 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);
}

Expand Down Expand Up @@ -181,10 +185,13 @@ private static async Task<DeviceCodeCredential> 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);
}

Expand Down Expand Up @@ -425,17 +432,175 @@ private static bool TryFindCertificateBySubjectName(string subjectName, StoreLoc
/// <summary>
/// Signs out of the current session using the provided <see cref="IAuthContext"/>.
/// </summary>
/// <param name="authContext">The <see cref="IAuthContext"/> to sign-out from.</param>
public static async Task<IAuthContext> LogoutAsync()
/// <param name="signOutFromBroker">
/// When <c>true</c> 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.
/// </param>
/// <returns>The <see cref="IAuthContext"/> that was signed out from.</returns>
public static async Task<IAuthContext> LogoutAsync(bool signOutFromBroker = false)
{
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. 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)
{
try
{
await TokenCacheUtilities.ClearPersistedTokenCacheAsync(
Constants.CacheName,
authContext.ClientId,
GetAuthorityUrl(authContext),
homeAccountId).ConfigureAwait(false);
}
catch (Exception ex)
{
Comment thread
gavinbarron marked this conversation as resolved.
// 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, homeAccountId).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;
}

/// <summary>
/// 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.
/// </summary>
/// <summary>
/// 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 <see cref="AuthenticationRecord"/>), 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.
/// </summary>
/// <param name="authContext">The <see cref="IAuthContext"/> whose broker accounts should be removed.</param>
/// <param name="homeAccountId">
/// The HomeAccountId of the current session's account, used to scope removal to that account.
/// When <c>null</c> or empty, all accounts for the module are removed as a fallback.
/// </param>
private static async Task ClearBrokerTokenCacheAsync(IAuthContext authContext, string homeAccountId)
{
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);

// 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.
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);
}
}

/// <summary>
/// Reads the HomeAccountId of the account persisted for the current session, if any.
/// Returns <c>null</c> when no authentication record is available or it cannot be read.
/// </summary>
private static async Task<string> GetCurrentHomeAccountIdAsync()
{
try
{
var authRecord = await ReadAuthRecordAsync().ConfigureAwait(false);
return TryGetHomeAccountId(authRecord);
}
catch
{
// A missing or unreadable auth record simply means we cannot narrow the removal.
return null;
}
}

/// <summary>
/// Safely reads the HomeAccountId from an <see cref="AuthenticationRecord"/>. Capturing this
/// diagnostic identifier must never fail sign-in, so any error yields <c>null</c>.
/// </summary>
private static string TryGetHomeAccountId(AuthenticationRecord authRecord)
{
try
{
return authRecord?.HomeAccountId;
}
catch
{
return null;
}
}

/// <summary>
/// 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.
/// </summary>
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<AuthenticationRecord> ReadAuthRecordAsync()
{
// Try to create directory if it doesn't exist.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// ------------------------------------------------------------------------------
// 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
{
/// <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.
/// When <paramref name="homeAccountId"/> identifies the current session's account, only that
/// account is removed from the cache; otherwise the entire cache is cleared as a fallback.
/// </summary>
/// <param name="cacheName">The cache name (e.g., "mg.msal.cache").</param>
/// <param name="clientId">The module's client id, used to enumerate cached accounts.</param>
/// <param name="authority">The authority URL used to build the public client application.</param>
/// <param name="homeAccountId">
/// The HomeAccountId of the current session's account. When <c>null</c> or empty, or when no
/// matching account is found, the entire cache is cleared.
/// </param>
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, clientId, authority, homeAccountId).ConfigureAwait(false);
await ClearCacheAsync(cacheName + CaeDisabledSuffix, clientId, authority, homeAccountId).ConfigureAwait(false);
}

private static async Task ClearCacheAsync(string cacheFileName, string clientId, string authority, string homeAccountId)
{
var storageProperties = new StorageCreationPropertiesBuilder(cacheFileName, DefaultCacheDirectory)
.WithMacKeyChain(DefaultCacheKeychainService, cacheFileName)
.WithLinuxKeyring(
DefaultCacheKeyringSchema,
DefaultCacheKeyringCollection,
cacheFileName,
DefaultCacheKeyringAttribute1,
DefaultCacheKeyringAttribute2)
.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();
Comment thread
gavinbarron marked this conversation as resolved.
#pragma warning restore CS0618
}
}
}
Loading
Loading