Skip to content
Draft
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
@@ -0,0 +1,68 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using Azure.Core;
using Azure.Mcp.Core.Services.Azure.Authentication;
using Microsoft.Extensions.Logging;
using Xunit;

namespace Azure.Mcp.Core.UnitTests.Services.Azure.Authentication;

public class SingleIdentityTokenCredentialProviderTests
{
[Fact]
public async Task NullTenantId_ReturnsSameDefaultCredentialInstance()
{
var provider = CreateProvider();

TokenCredential first = await provider.GetTokenCredentialAsync(null, CancellationToken.None);
TokenCredential second = await provider.GetTokenCredentialAsync(null, CancellationToken.None);

Assert.Same(first, second);
Assert.Equal("CustomChainedCredential", first.GetType().Name);
}

[Fact]
public async Task SameTenantId_ReturnsCachedTenantCredentialInstance()
{
var provider = CreateProvider();

TokenCredential first = await provider.GetTokenCredentialAsync("tenant-a", CancellationToken.None);
TokenCredential second = await provider.GetTokenCredentialAsync("tenant-a", CancellationToken.None);

Assert.Same(first, second);
Assert.Equal("TenantAwareCredential", first.GetType().Name);
}

[Fact]
public async Task DifferentTenantIds_ReturnDifferentTenantCredentialInstances()
{
var provider = CreateProvider();

TokenCredential tenantA = await provider.GetTokenCredentialAsync("tenant-a", CancellationToken.None);
TokenCredential tenantB = await provider.GetTokenCredentialAsync("tenant-b", CancellationToken.None);

Assert.NotSame(tenantA, tenantB);
Assert.Equal("TenantAwareCredential", tenantA.GetType().Name);
Assert.Equal("TenantAwareCredential", tenantB.GetType().Name);
}

[Fact]
public async Task TenantCredential_IsDifferentFromDefaultCredential()
{
var provider = CreateProvider();

TokenCredential defaultCredential = await provider.GetTokenCredentialAsync(null, CancellationToken.None);
TokenCredential tenantCredential = await provider.GetTokenCredentialAsync("tenant-a", CancellationToken.None);

Assert.NotSame(defaultCredential, tenantCredential);
Assert.Equal("CustomChainedCredential", defaultCredential.GetType().Name);
Assert.Equal("TenantAwareCredential", tenantCredential.GetType().Name);
}

private static SingleIdentityTokenCredentialProvider CreateProvider()
{
var loggerFactory = LoggerFactory.Create(_ => { });
return new SingleIdentityTokenCredentialProvider(loggerFactory);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ public Task<TokenCredential> GetTokenCredentialAsync(
{
if (!_tenantSpecificCredentials.TryGetValue(tenantId, out tenantCredential))
{
tenantCredential = new CustomChainedCredential(
tenantCredential = new TenantAwareCredential(
tenantId,
_loggerFactory.CreateLogger<CustomChainedCredential>()
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using Azure.Core;
using Azure.Identity;
using Azure.Identity.Broker;
using Microsoft.Extensions.Logging;

namespace Azure.Mcp.Core.Services.Azure.Authentication;

/// <summary>
/// Tenant-scoped credential for explicit tenant switching scenarios.
/// This class is isolated from <see cref="CustomChainedCredential"/> behavior so default auth
/// flows are unchanged unless a tenant-specific credential is requested.
/// </summary>
internal class TenantAwareCredential(string tenantId, ILogger<CustomChainedCredential>? logger = null)
: CustomChainedCredential(tenantId, logger)
{
private const string BrowserAuthenticationTimeoutEnvVarName = "AZURE_MCP_BROWSER_AUTH_TIMEOUT_SECONDS";
private const string ClientIdEnvVarName = "AZURE_MCP_CLIENT_ID";
private const string TokenCacheName = "azure-mcp-msal.cache";

private readonly string _tenantId = tenantId;
private readonly TokenCredential _tenantScopedCredential = CreateTenantScopedCredential(tenantId);

public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(_tenantId))
{
return base.GetToken(requestContext, cancellationToken);
}

return _tenantScopedCredential.GetToken(requestContext, cancellationToken);
}

public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(_tenantId))
{
return base.GetTokenAsync(requestContext, cancellationToken);
}

return _tenantScopedCredential.GetTokenAsync(requestContext, cancellationToken);
}

private static TokenCredential CreateTenantScopedCredential(string tenantId)
{
// Explicit tenant switcher: avoid sticky-account behavior and stale auth record reuse.
// This enables a clean tenant-specific interactive auth path where MFA/2FA prompts can
// follow the external tenant policy.
IntPtr handle = WindowHandleProvider.GetWindowHandle();

string? clientId = Environment.GetEnvironmentVariable(ClientIdEnvVarName);

var brokerOptions = new InteractiveBrowserCredentialBrokerOptions(handle)
{
TenantId = tenantId,
UseDefaultBrokerAccount = false,
AuthenticationRecord = null,
TokenCachePersistenceOptions = new TokenCachePersistenceOptions
{
Name = TokenCacheName,
},
};

if (CustomChainedCredential.CloudConfiguration != null)
{
brokerOptions.AuthorityHost = CustomChainedCredential.CloudConfiguration.AuthorityHost;
}

if (!string.IsNullOrWhiteSpace(clientId))
{
brokerOptions.ClientId = clientId;
}

var browserCredential = new InteractiveBrowserCredential(brokerOptions);

string? timeoutValue = Environment.GetEnvironmentVariable(BrowserAuthenticationTimeoutEnvVarName);
int timeoutSeconds = 300;
if (!string.IsNullOrEmpty(timeoutValue) && int.TryParse(timeoutValue, out int parsedTimeout) && parsedTimeout > 0)
{
timeoutSeconds = parsedTimeout;
}

return new TimeoutTokenCredential(browserCredential, TimeSpan.FromSeconds(timeoutSeconds));
}
}
48 changes: 48 additions & 0 deletions servers/Fabric.Mcp.Server/SECURITY_HARDENED_ENTERPRISE_FORK.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Security-Hardened Enterprise Fork (Fabric MCP)

This fork introduces an enterprise-focused mitigation for cross-tenant authentication issues reported in:

- https://github.com/microsoft/mcp/issues/1797

## Problem Context

In multi-tenant environments, users may be prompted with incomplete or incorrect login behavior when switching tenants (including tenant-specific MFA/2FA journeys), especially when cached authentication context from another tenant is reused.

## Mitigation Implemented

### Tenant Switcher behavior via isolated `TenantAwareCredential`

File changed:

- `core/Microsoft.Mcp.Core/src/Services/Azure/Authentication/TenantAwareCredential.cs`
- `core/Microsoft.Mcp.Core/src/Services/Azure/Authentication/SingleIdentityTokenCredentialProvider.cs`

And intentionally preserved:

- `core/Microsoft.Mcp.Core/src/Services/Azure/Authentication/CustomChainedCredential.cs`

Key behavior added:

1. **Isolated tenant-specific credential path**
- `SingleIdentityTokenCredentialProvider` continues to use `CustomChainedCredential` for default auth.
- Only explicit tenant-scoped requests are routed to `TenantAwareCredential`.

2. **Cross-tenant cache protection**
- `TenantAwareCredential` avoids replaying prior `AuthenticationRecord` values that may belong to a different tenant.

3. **Account picker / interactive safety for tenant switching**
- For explicit tenant switching, `UseDefaultBrokerAccount` is suppressed and tenant-specific interactive auth is used, reducing sticky-account behavior.

4. **Production-mode guardrail preserved**
- Default authentication flow remains intact for non-tenant-specific paths.

## Enterprise Security Rationale

- Reduces accidental token reuse across tenants.
- Encourages explicit tenant-bound login flow for MFA/2FA completion.
- Maintains secure non-interactive behavior for production workloads.

## Notes

- This change is an incremental hardening attempt for tenant-switch reliability and MFA redirect handling.
- Final behavior can vary by host OS account broker, Entra tenant policy, and credential chain settings.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
changes:
- section: "Bugs Fixed"
description: "Improved cross-tenant Fabric authentication by isolating explicit tenant-switch flows into a dedicated tenant-aware credential path while preserving default authentication behavior."
Loading