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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g
- `--yes` / `-y` option on `develop-mcp publish` — skips the interactive "Proceed with publish? (y/N)" confirmation.

### Fixed
- Commands requiring authentication no longer return misleading Graph 403 errors when Windows has multiple cached work accounts. The CLI detects a wrong-tenant token (`tid` claim mismatch), clears the MSAL token cache, and retries automatically (#430).
- `setup all` now exits silently on Ctrl+C instead of printing `ERROR: Setup failed: A task was canceled.` followed by a misleading partial summary.
- `setup all --m365` no longer fails with `AADSTS650053` because the Messaging Bot scope was hard-coded to scopes the resource SP does not publish (issue #429).
- `setup all` no longer fails with `AADSTS650053` for any drift between requested scopes and what a resource SP actually publishes (issue #429). Unpublished scopes are filtered out before building the consent URL; per-resource warnings surface what was dropped.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,13 @@ public static string[] GetRequiredRedirectUris(string clientAppId)
/// </summary>
public const string TokenCacheFileName = "auth-token.json";

/// <summary>
/// MSAL persistent token cache file name.
/// Used by MsalBrowserCredential (WAM/browser auth) and referenced by
/// AuthenticationService when clearing stale cross-tenant cached tokens.
/// </summary>
public const string MsalCacheFileName = "msal-token-cache";

/// <summary>
/// Token expiration buffer in minutes
/// Tokens are considered expired this many minutes before actual expiration
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,17 @@ public class AuthenticationService : IAuthenticationService
{
private readonly ILogger<AuthenticationService> _logger;
private readonly string _tokenCachePath;
// Stored so ClearStaleTokenCachesAsync can compute the MSAL cache path without a
// cross-class dependency on MsalBrowserCredential's private static field.
private readonly string _cacheDir;

public AuthenticationService(ILogger<AuthenticationService> logger)
{
_logger = logger;
var appDataPath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var cacheDir = Path.Combine(appDataPath, AuthenticationConstants.ApplicationName);
Directory.CreateDirectory(cacheDir);
_tokenCachePath = Path.Combine(cacheDir, AuthenticationConstants.TokenCacheFileName);
_cacheDir = Path.Combine(appDataPath, AuthenticationConstants.ApplicationName);
Directory.CreateDirectory(_cacheDir);
_tokenCachePath = Path.Combine(_cacheDir, AuthenticationConstants.TokenCacheFileName);
}

/// <summary>
Expand Down Expand Up @@ -198,6 +201,38 @@ public async Task<string> GetAccessTokenAsync(
_logger.LogDebug("Authentication required for Agent 365 Tools");
var token = await AuthenticateInteractivelyAsync(resourceUrl, tenantId, clientId, scopes, useInteractiveBrowser, loginHint: userId, ct: ct);

// Self-heal: validate the tid claim in the returned JWT against the requested tenant.
// WAM may silently select a cached work account from a different tenant when multiple
// Windows accounts are present (issue #430). On mismatch, clear both our JSON cache
// and the MSAL persistent cache to reset WAM's account selection, then retry once.
// Only compare tid when the requested tenantId is a GUID — JWT tid claims are always
// GUIDs, so comparison against a domain-form tenantId (e.g. contoso.onmicrosoft.com)
// would always appear as a mismatch, causing unnecessary cache clears and retry loops.
if (!string.IsNullOrWhiteSpace(tenantId) && Guid.TryParse(tenantId, out _))
{
var returnedTid = JwtHelper.TryDecodeClaim(token.AccessToken, "tid");
if (!string.IsNullOrWhiteSpace(returnedTid) &&
!string.Equals(returnedTid, tenantId, StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning(
"Authentication returned token for tenant {ReturnedTenant} but {RequestedTenant} is required. " +
"Clearing cached credentials and retrying...",
returnedTid, tenantId);
await ClearStaleTokenCachesAsync();
// Retry once with the same parameters — MSAL disk cache is now empty so WAM
// gets a clean slate and will either pick the correct account or prompt.
token = await AuthenticateInteractivelyAsync(resourceUrl, tenantId, clientId, scopes, useInteractiveBrowser, loginHint: userId, ct: ct);
var retryTid = JwtHelper.TryDecodeClaim(token.AccessToken, "tid");
if (!string.IsNullOrWhiteSpace(retryTid) &&
!string.Equals(retryTid, tenantId, StringComparison.OrdinalIgnoreCase))
{
throw new AzureAuthenticationException(
$"The account selected does not match the configured tenant ({tenantId}). " +
$"Ensure 'az login' targets the correct tenant, or select the correct account when prompted.");
}
}
}

// Validate the token identity before caching: if a userId was requested,
// ensure the returned token is actually for that user. WAM may return a
// guest/cross-app token for an account it considers "equivalent" (same Microsoft
Expand Down Expand Up @@ -348,7 +383,13 @@ private async Task<TokenInfo> AuthenticateInteractivelyAsync(
{
AccessToken = tokenResult.Token,
ExpiresOn = tokenResult.ExpiresOn.UtcDateTime,
TenantId = effectiveTenantId
// Store the decoded JWT tid only when the requested tenantId is also a GUID.
// If callers pass a domain name (e.g. contoso.onmicrosoft.com), storing the
// GUID tid would cause the next cache-read comparison to always fail, forcing
// re-authentication on every run.
TenantId = Guid.TryParse(effectiveTenantId, out _)
? JwtHelper.TryDecodeClaim(tokenResult.Token, "tid") ?? effectiveTenantId
: effectiveTenantId
};
}
catch (MsalAuthenticationFailedException ex) when (ex.Message.Contains("code_expired") || ex.InnerException?.Message.Contains("code_expired") == true)
Expand Down Expand Up @@ -686,27 +727,49 @@ protected virtual TokenCredential CreateDeviceCodeCredential(string clientId, st

private static string? TryExtractUpnFromJwt(string? jwt)
{
if (string.IsNullOrWhiteSpace(jwt)) return null;
// Try the UPN claim variants in order of specificity.
// Delegates to JwtHelper.TryDecodeClaim for the shared Base64Url decode.
return JwtHelper.TryDecodeClaim(jwt, "upn")
?? JwtHelper.TryDecodeClaim(jwt, "preferred_username")
?? JwtHelper.TryDecodeClaim(jwt, "unique_name");
}

/// <summary>
/// Deletes both the JSON token cache and the MSAL persistent cache.
/// Each deletion is independently non-fatal; errors are logged at Debug level.
/// </summary>
private Task ClearStaleTokenCachesAsync()
{
// 1. Our JSON token cache
try
{
var parts = jwt.Split('.');
if (parts.Length < 2) return null;
var payload = parts[1];
// JWT uses Base64Url encoding: replace URL-safe chars before standard Base64 decode.
payload = payload.Replace('-', '+').Replace('_', '/');
// Restore Base64 padding stripped by JWT encoding.
payload = payload.PadRight(payload.Length + (4 - payload.Length % 4) % 4, '=');
var bytes = Convert.FromBase64String(payload);
using var doc = JsonDocument.Parse(bytes);
if (doc.RootElement.TryGetProperty("upn", out var upn) && !string.IsNullOrWhiteSpace(upn.GetString()))
return upn.GetString();
if (doc.RootElement.TryGetProperty("preferred_username", out var pref) && !string.IsNullOrWhiteSpace(pref.GetString()))
return pref.GetString();
if (doc.RootElement.TryGetProperty("unique_name", out var uniqueName) && !string.IsNullOrWhiteSpace(uniqueName.GetString()))
return uniqueName.GetString();
if (File.Exists(_tokenCachePath))
{
File.Delete(_tokenCachePath);
_logger.LogDebug("Cleared JSON token cache at {Path}", _tokenCachePath);
}
}
catch { } // Static helper — no logger access. Caller logs via ResolveLoginHintFromCacheAsync.
return null;
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to clear JSON token cache: {Message}", ex.Message);
}

// 2. MSAL persistent cache (WAM/browser)
var msalCachePath = Path.Combine(_cacheDir, AuthenticationConstants.MsalCacheFileName);
try
{
if (File.Exists(msalCachePath))
{
File.Delete(msalCachePath);
_logger.LogDebug("Cleared MSAL token cache at {Path}", msalCachePath);
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to clear MSAL token cache: {Message}", ex.Message);
}

return Task.CompletedTask;
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1911,22 +1911,7 @@ public virtual async Task<bool> DeleteAgentInstanceAsync(
/// Returns null if the token cannot be decoded or the claim is absent.
/// </summary>
private static string? TryDecodeTokenClaim(string token, string claimName)
{
try
{
var parts = token.Split('.');
if (parts.Length < 2) return null;
var payload = parts[1];
payload = payload.PadRight(payload.Length + (4 - payload.Length % 4) % 4, '=');
var json = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(payload));
using var doc = JsonDocument.Parse(json);
return doc.RootElement.TryGetProperty(claimName, out var claim) ? claim.GetString() : null;
}
catch
{
return null;
}
}
=> JwtHelper.TryDecodeClaim(token, claimName);

/// <summary>
/// Attempts to extract a human-readable error message from a Graph API JSON error response body.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Text.Json;

namespace Microsoft.Agents.A365.DevTools.Cli.Services.Helpers;

/// <summary>
/// Shared helper for decoding JWT token claims.
/// Consolidates the duplicated Base64Url-decode logic that previously existed in
/// AuthenticationService.TryExtractUpnFromJwt, GraphApiService.TryDecodeTokenClaim,
/// and MsalBrowserCredential inline decode blocks.
/// </summary>
internal static class JwtHelper
{
/// <summary>
/// Decodes a single claim from the payload of a JWT string.
/// Returns null if the token is malformed, the claim is absent, or decoding fails.
/// </summary>
internal static string? TryDecodeClaim(string? jwt, string claimName)
{
if (string.IsNullOrWhiteSpace(jwt)) return null;
try
{
var parts = jwt.Split('.');
if (parts.Length < 2) return null;
var payload = parts[1];
// JWT uses Base64Url encoding: restore standard Base64 chars and padding.
payload = payload.Replace('-', '+').Replace('_', '/');
payload = payload.PadRight(payload.Length + (4 - payload.Length % 4) % 4, '=');
var bytes = Convert.FromBase64String(payload);
using var doc = JsonDocument.Parse(bytes);
return doc.RootElement.TryGetProperty(claimName, out var claim)
? claim.GetString()
: null;
}
catch
{
return null;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
using Azure.Core;
using Microsoft.Agents.A365.DevTools.Cli.Constants;
using Microsoft.Agents.A365.DevTools.Cli.Helpers;
using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers;
using Microsoft.Extensions.Logging;

namespace Microsoft.Agents.A365.DevTools.Cli.Services;
Expand Down Expand Up @@ -178,6 +179,65 @@ public MicrosoftGraphTokenProvider(
return null;
}

// Self-heal: validate the tid claim in the returned token against the requested tenant.
// WAM may silently select a cached Windows work account from a different tenant when
// multiple accounts are present (issue #430). On mismatch, clear the in-memory cache
// entry and the MSAL persistent disk cache, then retry once with forceRefresh so WAM
// gets a clean slate and either picks the correct account or prompts the user.
// Only compare tid when tenantId is a GUID — JWT tid claims are always GUIDs,
// so a domain-form tenantId (e.g. contoso.onmicrosoft.com) would always appear
// as a mismatch and clear caches unnecessarily.
var returnedTid = JwtHelper.TryDecodeClaim(token, "tid");
if (!string.IsNullOrWhiteSpace(returnedTid) &&
Guid.TryParse(tenantId, out _) &&
!string.Equals(returnedTid, tenantId, StringComparison.OrdinalIgnoreCase))
{
Comment thread
sellakumaran marked this conversation as resolved.
_logger.LogWarning(
"Graph token returned for tenant {ReturnedTenant} but {RequestedTenant} is required. " +
"Clearing cached credentials and retrying...",
returnedTid, tenantId);

// Evict in-memory entry so the retry actually calls through to MSAL/PS.
_tokenCache.TryRemove(cacheKey, out _);

// Delete the MSAL persistent cache file so WAM starts with a clean account list.
var msalCachePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
AuthenticationConstants.ApplicationName,
AuthenticationConstants.MsalCacheFileName);
try
{
if (File.Exists(msalCachePath))
{
File.Delete(msalCachePath);
_logger.LogDebug("Cleared MSAL token cache at {Path}", msalCachePath);
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to clear MSAL token cache: {Message}", ex.Message);
}

// Retry once — do not recurse; use the underlying acquirer directly.
var retryToken = MsalTokenAcquirerOverride != null
? await MsalTokenAcquirerOverride(tenantId, validatedScopes, clientAppId, ct)
: await AcquireGraphTokenViaMsalAsync(tenantId, validatedScopes, clientAppId, ct, loginHint, forceRefresh: true);

if (!string.IsNullOrWhiteSpace(retryToken))
token = retryToken;

// Fail fast if the retry also returned the wrong tenant — caching and returning
// a known-bad token would produce the same misleading 403s the fix is meant to prevent.
var retryTid = JwtHelper.TryDecodeClaim(token, "tid");
if (!string.IsNullOrWhiteSpace(retryTid) &&
!string.Equals(retryTid, tenantId, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
$"Graph token retry returned token for tenant {retryTid} but {tenantId} is required. " +
$"Ensure 'az login' targets the correct tenant, or select the correct account when prompted.");
}
}

// Cache expiry from JWT exp; if parsing fails, cache short (10 min) to still reduce spam
if (!TryGetJwtExpiryUtc(token, out var expUtc))
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ public sealed class MsalBrowserCredential : TokenCredential
private static MsalCacheHelper? _cacheHelper;
private static readonly object _cacheHelperLock = new();

private static readonly string CacheFileName = "msal-token-cache";
private static readonly string CacheFileName = AuthenticationConstants.MsalCacheFileName;
private static readonly string CacheDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
AuthenticationConstants.ApplicationName);
Expand Down
Loading
Loading