From 74783cd7cc85863174c45d53dfae26749c1c0ae6 Mon Sep 17 00:00:00 2001 From: avdunn Date: Wed, 29 Jul 2026 17:26:43 -0700 Subject: [PATCH 01/14] First draft of cloud metadata improvements --- .../CertificatelessConstants.cs | 10 +- .../ManagedIdentityClientAssertion.cs | 11 +- .../OidcIdpSignedAssertionLoader.cs | 7 +- .../OidcIdpSignedAssertionProvider.cs | 24 +- .../CloudMetadataResolver.cs | 166 ++++++++++++++ .../CredentialsProvider.cs | 19 +- .../ServiceCollectionExtensions.cs | 11 + .../TokenAcquisition.cs | 18 +- .../CloudMetadataResolverTests.cs | 160 +++++++++++++ .../FederatedIdentityCaeTests.cs | 217 +++++++++++++++++- 10 files changed, 627 insertions(+), 16 deletions(-) create mode 100644 src/Microsoft.Identity.Web.TokenAcquisition/CloudMetadataResolver.cs create mode 100644 tests/Microsoft.Identity.Web.Test/CloudMetadataResolverTests.cs diff --git a/src/Microsoft.Identity.Web.Certificateless/CertificatelessConstants.cs b/src/Microsoft.Identity.Web.Certificateless/CertificatelessConstants.cs index 15b0e749b..6cd2e829c 100644 --- a/src/Microsoft.Identity.Web.Certificateless/CertificatelessConstants.cs +++ b/src/Microsoft.Identity.Web.Certificateless/CertificatelessConstants.cs @@ -5,7 +5,15 @@ namespace Microsoft.Identity.Web.Certificateless { internal class CertificatelessConstants { - // Managed Identity Federated Identity Credential + // Managed Identity Federated Identity Credential. + // Documented public-cloud fallback used only when MSAL's cloud metadata cannot supply the + // audience. The primary value now comes from MSAL (single source of truth); an explicit + // TokenExchangeUrl in configuration still overrides both. internal const string DefaultTokenExchangeUrl = "api://AzureADTokenExchange"; + + // Well-known public-cloud authority host, used to resolve the default managed-identity token + // exchange audience from MSAL's cloud metadata (the managed-identity leg has no AAD authority + // of its own to key on). + internal const string PublicCloudInstanceHost = "login.microsoftonline.com"; } } diff --git a/src/Microsoft.Identity.Web.Certificateless/ManagedIdentityClientAssertion.cs b/src/Microsoft.Identity.Web.Certificateless/ManagedIdentityClientAssertion.cs index 02a8ef32f..af3645744 100644 --- a/src/Microsoft.Identity.Web.Certificateless/ManagedIdentityClientAssertion.cs +++ b/src/Microsoft.Identity.Web.Certificateless/ManagedIdentityClientAssertion.cs @@ -9,6 +9,7 @@ using Microsoft.Identity.Client; using Microsoft.Identity.Client.AppConfig; using Microsoft.Identity.Client.Extensibility; +using Microsoft.Identity.Client.Instance.Discovery; using Microsoft.Identity.Web.Certificateless; using Microsoft.Identity.Web.TestOnly; using Microsoft.IdentityModel.LoggingExtensions; @@ -119,7 +120,15 @@ private ManagedIdentityClientAssertion( IKeyAttestationProvider? keyAttestationProvider, IMsalHttpClientFactory? testHttpClientFactory) { - _tokenExchangeUrl = tokenExchangeUrl ?? CertificatelessConstants.DefaultTokenExchangeUrl; + // Precedence: an explicit tokenExchangeUrl wins; otherwise take the audience from MSAL's + // cloud metadata for the public cloud (single source of truth), falling back to the + // documented constant. The managed-identity leg has no AAD authority to auto-resolve a + // sovereign cloud, so non-public clouds are configured via tokenExchangeUrl. + _tokenExchangeUrl = tokenExchangeUrl + ?? KnownCloudConfiguration.Default + .GetSettingsByAuthority(CertificatelessConstants.PublicCloudInstanceHost) + .TokenExchangeAudience() + ?? CertificatelessConstants.DefaultTokenExchangeUrl; _logger = logger; _keyAttestationProvider = keyAttestationProvider; diff --git a/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionLoader.cs b/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionLoader.cs index 7b5a99421..0be14620e 100644 --- a/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionLoader.cs +++ b/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionLoader.cs @@ -196,7 +196,12 @@ public async Task LoadIfNeededAsync(CredentialDescription credentialDescription, // Special case for Signed assertions with an FmiPath. // The provider needs to postpone getting the signed assertion until the first call, when ClientAssertionFmiPath will be provided. - signedAssertion = new OidcIdpSignedAssertionProvider(_tokenAcquirerFactory, microsoftIdentityApplicationOptions, credentialDescription.TokenExchangeUrl, _logger); + signedAssertion = new OidcIdpSignedAssertionProvider( + _tokenAcquirerFactory, + microsoftIdentityApplicationOptions, + credentialDescription.TokenExchangeUrl, + _logger, + CloudMetadataResolver.FromServiceProvider(_serviceProvider)); if (credentialDescription.CustomSignedAssertionProviderData.TryGetValue("RequiresSignedAssertionFmiPath", out object? requiresSignedAssertionFmiPathObj) && requiresSignedAssertionFmiPathObj is bool requiresSignedAssertionFmiPathBool && requiresSignedAssertionFmiPathBool) { signedAssertion.RequiresSignedAssertionFmiPath = true; diff --git a/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionProvider.cs b/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionProvider.cs index f3493d7e4..ab1b320eb 100644 --- a/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionProvider.cs +++ b/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionProvider.cs @@ -16,9 +16,6 @@ namespace Microsoft.Identity.Web.OidcFic { internal class OidcIdpSignedAssertionProvider : ClientAssertionProviderBase { - private const string DefaultTokenExchangeUrl = "api://AzureADTokenExchange"; - private const string DotDefaultSuffix = "/.default"; - // Signals the inner token acquisition to bind the assertion to an mTLS PoP certificate. // Kept in sync with the internal constant used by the token-acquisition pipeline. private const string TokenBindingParameterName = "IsTokenBinding"; @@ -37,6 +34,7 @@ internal class OidcIdpSignedAssertionProvider : ClientAssertionProviderBase private readonly MicrosoftIdentityApplicationOptions _options; private readonly string? _tokenExchangeUrl; private readonly ILogger? _logger; + private readonly CloudMetadataResolver _cloudMetadataResolver; public bool RequiresSignedAssertionFmiPath { get; internal set; } @@ -50,11 +48,17 @@ internal class OidcIdpSignedAssertionProvider : ClientAssertionProviderBase public override bool SupportsTokenBinding => true; public OidcIdpSignedAssertionProvider(ITokenAcquirerFactory tokenAcquirerFactory, MicrosoftIdentityApplicationOptions options, string? tokenExchangeUrl, ILogger? logger) + : this(tokenAcquirerFactory, options, tokenExchangeUrl, logger, cloudMetadataResolver: null) + { + } + + internal OidcIdpSignedAssertionProvider(ITokenAcquirerFactory tokenAcquirerFactory, MicrosoftIdentityApplicationOptions options, string? tokenExchangeUrl, ILogger? logger, CloudMetadataResolver? cloudMetadataResolver) { _tokenAcquirerFactory = tokenAcquirerFactory; _options = options; _tokenExchangeUrl = tokenExchangeUrl; _logger = logger; + _cloudMetadataResolver = cloudMetadataResolver ?? new CloudMetadataResolver(null, null); } protected override async Task GetClientAssertionAsync(AssertionRequestOptions? assertionRequestOptions) @@ -151,10 +155,14 @@ private async Task AcquireOidcAssertionResultAsync( { _tokenAcquirer ??= _tokenAcquirerFactory.GetTokenAcquirer(_options); - string tokenExchangeUrl = _tokenExchangeUrl ?? DefaultTokenExchangeUrl; - string effectiveTokenExchangeUrl = tokenExchangeUrl.EndsWith(DotDefaultSuffix, StringComparison.OrdinalIgnoreCase) - ? tokenExchangeUrl - : tokenExchangeUrl + DotDefaultSuffix; + // Precedence: an explicitly configured TokenExchangeUrl wins (as a per-call override); otherwise + // resolve the cloud-specific exchange scope from the layered resolver — a caller/MISE + // ICloudMetadataProvider (from DI) over MSAL's public baseline — keyed by this application's + // instance host (public cloud is unchanged via the documented fallback). The scope form + // (/.default) is used because this is a client-credentials / app-token acquisition. + string effectiveTokenExchangeUrl = _cloudMetadataResolver.ResolveTokenExchangeScope( + string.IsNullOrEmpty(_options.Instance) ? _options.Authority : _options.Instance, + perCallOverride: _tokenExchangeUrl); string? fmiPath = assertionRequestOptions?.ClientAssertionFmiPath; @@ -212,7 +220,7 @@ private async Task AcquireOidcAssertionResultAsync( if (_logger != null) { - _logger.AcquiringToken(tokenExchangeUrl, acquireTokenOptions?.FmiPath); + _logger.AcquiringToken(effectiveTokenExchangeUrl, acquireTokenOptions?.FmiPath); } CancellationToken effectiveCancellationToken = cancellationToken != default diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/CloudMetadataResolver.cs b/src/Microsoft.Identity.Web.TokenAcquisition/CloudMetadataResolver.cs new file mode 100644 index 000000000..402060168 --- /dev/null +++ b/src/Microsoft.Identity.Web.TokenAcquisition/CloudMetadataResolver.cs @@ -0,0 +1,166 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Client.Instance.Discovery; + +namespace Microsoft.Identity.Web +{ + /// + /// Resolves cloud-specific FIC (Federated Identity Credential) token-exchange values, keyed by the + /// request's authority host, by layering (highest precedence first): + /// + /// an explicit per-call override supplied by the caller; + /// an upstream (from DI) contributed by a caller or by an + /// upstream SDK such as MISE — this is how internal-only sovereign clouds become resolvable; + /// MSAL's built-in public cloud baseline ( / + /// ); + /// the documented public-cloud fallback (). + /// + /// + /// + /// + /// This is the single place in ID Web where the audience-vs-scope decision is made, so no call site + /// hand-builds either form: + /// + /// Managed-identity / "resource" contexts use the bare audience + /// (). + /// Client-credentials / app-token contexts use the computed scope (audience + /.default) + /// (). + /// + /// The /.default suffix computation itself is not duplicated here: it is delegated to MSAL's + /// , the single cross-stack owner of + /// that rule, so ID Web and MSAL can never diverge on it. + /// + /// + /// The upstream provider and MSAL baseline both expose the audience under the same key literal + /// ( equals + /// Microsoft.Identity.Client.Instance.Discovery.MsalCloudKeys.TokenExchangeAudience), so ID Web + /// "translates" between the two SDKs simply by reading the same literal from whichever source resolves + /// first — no runtime key remapping is needed. + /// + /// + internal sealed class CloudMetadataResolver + { + /// + /// Documented public-cloud fallback used only when no source resolves the requested authority host + /// (for example an unknown/private cloud with no configured entry). + /// + internal const string DefaultTokenExchangeAudience = "api://AzureADTokenExchange"; + + private readonly ICloudMetadataProvider? _upstreamProvider; + private readonly ICloudConfiguration _msalBaseline; + + /// + /// Initializes a new instance of the class. + /// + /// An optional upstream provider (from DI) contributed by a caller or + /// an upstream SDK (for example MISE). When null, only the MSAL baseline is consulted. + /// MSAL's public cloud configuration. When null, + /// is used. + internal CloudMetadataResolver(ICloudMetadataProvider? upstreamProvider, ICloudConfiguration? msalBaseline) + { + _upstreamProvider = upstreamProvider; + _msalBaseline = msalBaseline ?? KnownCloudConfiguration.Default; + } + + /// + /// Builds a resolver from the services registered in : an optional + /// upstream and an MSAL baseline. + /// + /// The DI service provider. + /// A configured . + internal static CloudMetadataResolver FromServiceProvider(IServiceProvider serviceProvider) + { + return new CloudMetadataResolver( + serviceProvider.GetService(), + serviceProvider.GetService()); + } + + /// + /// Resolves the bare FIC token-exchange audience (no /.default) for the cloud that owns + /// . Suitable for managed-identity / resource contexts. + /// + /// The request authority or instance (URL or bare host). + /// An optional explicit override that wins over all resolved sources. + internal string ResolveTokenExchangeAudience(string? authorityOrInstance, string? perCallOverride = null) + { + return ResolveAudience(authorityOrInstance, perCallOverride) ?? DefaultTokenExchangeAudience; + } + + /// + /// Resolves the FIC token-exchange scope (bare audience with /.default appended) for the + /// cloud that owns . Suitable for client-credentials / app-token + /// contexts. + /// + /// The request authority or instance (URL or bare host). + /// An optional explicit override that wins over all resolved sources. It + /// may be supplied bare or already suffixed with /.default; the suffix is applied idempotently. + internal string ResolveTokenExchangeScope(string? authorityOrInstance, string? perCallOverride = null) + { + string audience = ResolveTokenExchangeAudience(authorityOrInstance, perCallOverride); + + // Delegate the audience→scope (/.default) computation to MSAL's CloudSettingsExtensions — the + // single cross-stack owner of that rule — by wrapping the resolved bare audience in a + // CloudSettings. This guarantees ID Web and MSAL never diverge on the suffix rule. + var settings = new CloudSettings( + null, + new Dictionary(1, StringComparer.OrdinalIgnoreCase) + { + [MsalCloudKeys.TokenExchangeAudience] = audience, + }); + + return settings.TokenExchangeScope(); + } + + private string? ResolveAudience(string? authorityOrInstance, string? perCallOverride) + { + // 1. Explicit per-call override always wins. + if (!string.IsNullOrEmpty(perCallOverride)) + { + return perCallOverride; + } + + string? host = TryGetHost(authorityOrInstance); + + // 2. Upstream provider (caller / MISE, via Abstractions) wins over the MSAL baseline. This is how + // internal-only sovereign clouds — which MSAL does not ship — become resolvable end-to-end. + if (!string.IsNullOrEmpty(host)) + { + CloudMetadata? upstream = _upstreamProvider?.GetByAuthorityHost(host!); + if (upstream is not null + && upstream.TryGetValue(AbstractionsCloudKeys.TokenExchangeAudience, out string? upstreamAudience) + && !string.IsNullOrEmpty(upstreamAudience)) + { + return upstreamAudience; + } + } + + // 3. MSAL public baseline (same key literal as the upstream provider — no remap needed). + return _msalBaseline.GetSettingsByAuthority(host).TokenExchangeAudience(); + } + + /// + /// Extracts the host from an authority or instance URL (e.g. + /// https://login.microsoftonline.us/tenant → login.microsoftonline.us). If the value + /// is already a bare host it is returned as-is; null/empty yields null. + /// + internal static string? TryGetHost(string? authorityOrInstance) + { + if (string.IsNullOrEmpty(authorityOrInstance)) + { + return null; + } + + if (Uri.TryCreate(authorityOrInstance, UriKind.Absolute, out Uri? uri)) + { + return uri.Host; + } + + return authorityOrInstance; + } + } +} diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/CredentialsProvider.cs b/src/Microsoft.Identity.Web.TokenAcquisition/CredentialsProvider.cs index d5a1771d2..7d0ff20e3 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/CredentialsProvider.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/CredentialsProvider.cs @@ -19,17 +19,20 @@ internal partial class CredentialsProvider : ICredentialsProvider private readonly ITokenAcquisitionHost? _tokenHost; private readonly ICredentialsLoader _credentialsLoader; private readonly IReadOnlyList _certificatesObservers; + private readonly CloudMetadataResolver? _cloudMetadataResolver; public CredentialsProvider( ILogger logger, ICredentialsLoader credentialsLoader, IEnumerable certificatesObservers, - ITokenAcquisitionHost? tokenHost = null) + ITokenAcquisitionHost? tokenHost = null, + CloudMetadataResolver? cloudMetadataResolver = null) { _logger = logger; _tokenHost = tokenHost; _credentialsLoader = credentialsLoader; _certificatesObservers = [.. certificatesObservers]; + _cloudMetadataResolver = cloudMetadataResolver; } public Task GetCredentialAsync( @@ -63,6 +66,20 @@ public CredentialsProvider( if (!credential.Skip) { + // For the managed-identity FIC leg, resolve the cloud-specific token-exchange audience from + // the request authority host when the caller did not set one explicitly. This lets sovereign + // (public) and internal-only (via a registered ICloudMetadataProvider, e.g. MISE) clouds use + // the correct audience instead of the public-cloud default. Set only when empty so an + // explicit TokenExchangeUrl always wins; the resolved value is deterministic per authority. + if (_cloudMetadataResolver != null + && credential.SourceType == CredentialSource.SignedAssertionFromManagedIdentity + && string.IsNullOrEmpty(credential.TokenExchangeUrl) + && !string.IsNullOrEmpty(credentialSourceLoaderParameters?.Authority)) + { + credential.TokenExchangeUrl = _cloudMetadataResolver.ResolveTokenExchangeAudience( + credentialSourceLoaderParameters!.Authority); + } + // Load the credentials and record error messages in case we need to fail at the end try { diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/ServiceCollectionExtensions.cs b/src/Microsoft.Identity.Web.TokenAcquisition/ServiceCollectionExtensions.cs index dcdd6f9bb..4d382c552 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/ServiceCollectionExtensions.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/ServiceCollectionExtensions.cs @@ -9,6 +9,7 @@ using Microsoft.Extensions.Options; using Microsoft.Identity.Abstractions; using Microsoft.Identity.Client; +using Microsoft.Identity.Client.Instance.Discovery; using Microsoft.Identity.Web.Extensibility; using Microsoft.Identity.Web.Hosts; @@ -52,6 +53,16 @@ public static IServiceCollection AddTokenAcquisition( services.TryAddSingleton(); } + // Default cloud metadata provider used to resolve cloud-specific FIC token-exchange + // audiences by authority host. TryAdd lets a caller (or MISE) register a replacement or a + // composed provider before this call wins. + services.TryAddSingleton(KnownCloudConfiguration.Default); + + // The resolver layers an optional upstream ICloudMetadataProvider (registered by a caller or by + // MISE) over the MSAL baseline above, and is the single audience-vs-scope decision point shared + // by all FIC legs. Registered via a factory so it observes whichever provider/baseline win TryAdd. + services.TryAddSingleton(sp => CloudMetadataResolver.FromServiceProvider(sp)); + if (!HasImplementationType(services, typeof(MsalMtlsHttpClientFactory))) { services.TryAddSingleton(); diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs index 906b94161..cc5d4b929 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs @@ -21,6 +21,7 @@ using Microsoft.Identity.Abstractions; using Microsoft.Identity.Client; using Microsoft.Identity.Client.Extensibility; +using Microsoft.Identity.Client.Instance.Discovery; using Microsoft.Identity.Web.Experimental; using Microsoft.Identity.Web.Extensibility; using Microsoft.Identity.Web.TestOnly; @@ -87,7 +88,12 @@ class OAuthConstants /// internal readonly ConcurrentDictionary _agentUserFicAccountIds = new(); - private static readonly string[] s_ficScopes = new[] { "api://AzureADTokenExchange/.default" }; + /// + /// Resolves the cloud-specific FIC token-exchange audience/scope by authority host. Layers a caller + /// or upstream-SDK (MISE) over MSAL's built-in public baseline, + /// so a replaced or composed provider is honored on all FIC legs that key off an authority. + /// + private readonly CloudMetadataResolver _cloudMetadataResolver; private const string TokenBindingParameterName = "IsTokenBinding"; private const int MaxCertificateRetries = 1; @@ -155,6 +161,8 @@ public TokenAcquisition( } _credentialsProvider = credentialsProvider; + _cloudMetadataResolver = serviceProvider.GetService() + ?? CloudMetadataResolver.FromServiceProvider(serviceProvider); } #if NET6_0_OR_GREATER @@ -705,7 +713,10 @@ private async Task GetAuthenticationResultForUserInternalA // Leg 2: Get the agent's instance token (T2). // The assertion callback handles Leg 1 (blueprint → T1) transparently. - var leg2Builder = agentCca.AcquireTokenForClient(s_ficScopes); + // Resolve the cloud-specific FIC exchange scope from the agent authority host so sovereign + // clouds use the correct audience (public cloud is unchanged via the documented fallback). + var leg2Builder = agentCca.AcquireTokenForClient( + new[] { _cloudMetadataResolver.ResolveTokenExchangeScope(mergedOptions.Authority ?? mergedOptions.Instance) }); if (!string.IsNullOrEmpty(tenantId)) { leg2Builder.WithTenantId(tenantId); @@ -789,7 +800,8 @@ private async Task GetOrBuildAgentUserFicCcaAsyn blueprintOptions, isTokenBinding: false).ConfigureAwait(false); var leg1Builder = blueprintCca - .AcquireTokenForClient(s_ficScopes) + .AcquireTokenForClient( + new[] { _cloudMetadataResolver.ResolveTokenExchangeScope(blueprintOptions.Authority ?? blueprintOptions.Instance) }) .WithFmiPath(agentAppId) .WithSendX5C(blueprintOptions.SendX5C); diff --git a/tests/Microsoft.Identity.Web.Test/CloudMetadataResolverTests.cs b/tests/Microsoft.Identity.Web.Test/CloudMetadataResolverTests.cs new file mode 100644 index 000000000..2cf05a89a --- /dev/null +++ b/tests/Microsoft.Identity.Web.Test/CloudMetadataResolverTests.cs @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Generic; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Client.Instance.Discovery; +using Microsoft.Identity.Web; +using Xunit; + +namespace Microsoft.Identity.Web.Test +{ + /// + /// Unit tests for : the ID Web translator that layers a per-call + /// override over an upstream (caller / MISE) over MSAL's public + /// baseline, keyed by authority host. + /// + public class CloudMetadataResolverTests + { + private const string PublicHost = "login.microsoftonline.com"; + private const string UsGovAuthority = "https://login.microsoftonline.us/tenant"; + private const string UsGovHost = "login.microsoftonline.us"; + private const string NewCloudAuthority = "https://login.partner.example/tenant"; + private const string NewCloudHost = "login.partner.example"; + + [Fact] + public void ResolveAudience_UnknownHost_NoProvider_ReturnsDocumentedDefault() + { + // Arrange + var resolver = new CloudMetadataResolver(upstreamProvider: null, msalBaseline: null); + + // Act + string audience = resolver.ResolveTokenExchangeAudience(NewCloudAuthority); + + // Assert + Assert.Equal("api://AzureADTokenExchange", audience); + } + + [Fact] + public void ResolveAudience_KnownSovereignHost_NoProvider_ResolvesFromMsalBaseline() + { + // Arrange + var resolver = new CloudMetadataResolver(upstreamProvider: null, msalBaseline: null); + + // Act + string audience = resolver.ResolveTokenExchangeAudience(UsGovAuthority); + + // Assert + Assert.Equal("api://AzureADTokenExchangeUSGov", audience); + } + + [Fact] + public void ResolveAudience_UpstreamProvider_WinsOverMsalBaseline() + { + // Arrange: upstream overrides a cloud MSAL already ships (US Gov). + var provider = new InMemoryCloudMetadataProvider().AddOrUpdate( + UsGovHost, + new Dictionary + { + [AbstractionsCloudKeys.TokenExchangeAudience] = "api://AzureADTokenExchangeCustomGov", + }); + var resolver = new CloudMetadataResolver(provider, msalBaseline: null); + + // Act + string audience = resolver.ResolveTokenExchangeAudience(UsGovAuthority); + + // Assert + Assert.Equal("api://AzureADTokenExchangeCustomGov", audience); + } + + [Fact] + public void ResolveAudience_UpstreamProvider_ResolvesNewCloudUnknownToMsal() + { + // Arrange: a brand-new cloud MSAL does not ship becomes resolvable via the upstream provider. + var provider = new InMemoryCloudMetadataProvider().AddOrUpdate( + NewCloudHost, + new Dictionary + { + [AbstractionsCloudKeys.TokenExchangeAudience] = "api://AzureADTokenExchangeMyCloud", + }); + var resolver = new CloudMetadataResolver(provider, msalBaseline: null); + + // Act + string audience = resolver.ResolveTokenExchangeAudience(NewCloudAuthority); + + // Assert + Assert.Equal("api://AzureADTokenExchangeMyCloud", audience); + } + + [Fact] + public void ResolveAudience_PerCallOverride_WinsOverProviderAndBaseline() + { + // Arrange + var provider = new InMemoryCloudMetadataProvider().AddOrUpdate( + UsGovHost, + new Dictionary + { + [AbstractionsCloudKeys.TokenExchangeAudience] = "api://AzureADTokenExchangeCustomGov", + }); + var resolver = new CloudMetadataResolver(provider, msalBaseline: null); + + // Act + string audience = resolver.ResolveTokenExchangeAudience(UsGovAuthority, perCallOverride: "api://PerCallAudience"); + + // Assert + Assert.Equal("api://PerCallAudience", audience); + } + + [Fact] + public void ResolveScope_AppendsDefaultSuffix_Idempotently() + { + // Arrange + var resolver = new CloudMetadataResolver(upstreamProvider: null, msalBaseline: null); + + // Act + string fromBare = resolver.ResolveTokenExchangeScope(PublicHost, perCallOverride: "api://Aud"); + string fromSuffixed = resolver.ResolveTokenExchangeScope(PublicHost, perCallOverride: "api://Aud/.default"); + + // Assert + Assert.Equal("api://Aud/.default", fromBare); + Assert.Equal("api://Aud/.default", fromSuffixed); + } + + [Fact] + public void ResolveScope_KnownSovereignHost_ComputesScopeFromBaseline() + { + // Arrange + var resolver = new CloudMetadataResolver(upstreamProvider: null, msalBaseline: null); + + // Act + string scope = resolver.ResolveTokenExchangeScope(UsGovAuthority); + + // Assert + Assert.Equal("api://AzureADTokenExchangeUSGov/.default", scope); + } + + [Fact] + public void FromServiceProvider_HonorsRegisteredUpstreamProvider() + { + // Arrange + var provider = new InMemoryCloudMetadataProvider().AddOrUpdate( + NewCloudHost, + new Dictionary + { + [AbstractionsCloudKeys.TokenExchangeAudience] = "api://AzureADTokenExchangeMyCloud", + }); + var services = new ServiceCollection(); + services.AddSingleton(provider); + services.AddSingleton(KnownCloudConfiguration.Default); + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + + // Act + CloudMetadataResolver resolver = CloudMetadataResolver.FromServiceProvider(serviceProvider); + + // Assert + Assert.Equal("api://AzureADTokenExchangeMyCloud", resolver.ResolveTokenExchangeAudience(NewCloudAuthority)); + Assert.Equal("api://AzureADTokenExchangeUSGov", resolver.ResolveTokenExchangeAudience(UsGovAuthority)); + } + } +} diff --git a/tests/Microsoft.Identity.Web.Test/FederatedIdentityCaeTests.cs b/tests/Microsoft.Identity.Web.Test/FederatedIdentityCaeTests.cs index 9542b2436..3ea316103 100644 --- a/tests/Microsoft.Identity.Web.Test/FederatedIdentityCaeTests.cs +++ b/tests/Microsoft.Identity.Web.Test/FederatedIdentityCaeTests.cs @@ -231,7 +231,11 @@ public async Task Fic_CustomSignedAssertion_ClaimsAndCapabilities_AreSent_OnSeco }); // Assert endpoints, scopes, client IDs - Assert.Equal("api://AzureADTokenExchange/.default", credentialRequestHttpHandler.ActualRequestPostData["scope"]); + // The source app (c1) is configured on US Gov (login.microsoftonline.us), so the FIC + // token-exchange audience is now auto-resolved from MSAL's cloud metadata to the US Gov + // value instead of the public-cloud default. This is the cross-cloud-metadata feature + // correcting the previously hardcoded public-cloud audience. + Assert.Equal("api://AzureADTokenExchangeUSGov/.default", credentialRequestHttpHandler.ActualRequestPostData["scope"]); Assert.Equal(TestConstants.s_scopeForApp, tokenRequestHttpHandler.ActualRequestPostData["scope"]); Assert.Equal("c1", credentialRequestHttpHandler.ActualRequestPostData["client_id"]); Assert.Equal("https://login.microsoftonline.us/t1/oauth2/v2.0/token", @@ -269,5 +273,216 @@ public async Task Fic_CustomSignedAssertion_ClaimsAndCapabilities_AreSent_OnSeco // Bearer header returned Assert.StartsWith("Bearer", header, StringComparison.Ordinal); } + + [Fact] + public async Task Fic_CustomSignedAssertion_HonorsInjectedCloudMetadataProvider() + { + using var httpFactoryForTest = new MockHttpClientFactory(); + // First request (credential exchange) — its "scope" is what we assert on. + var credentialRequestHttpHandler = httpFactoryForTest.AddMockHandler( + MockHttpCreator.CreateClientCredentialTokenHandler("token-exchange-1")); + // Second request (actual token acquisition). + var tokenRequestHttpHandler = httpFactoryForTest.AddMockHandler( + MockHttpCreator.CreateClientCredentialTokenHandler("final-access-token")); + + TokenAcquirerFactoryTesting.ResetTokenAcquirerFactoryInTest(); + var tokenAcquirerFactory = TokenAcquirerFactory.GetDefaultInstance(); + tokenAcquirerFactory.Services.AddOidcFic(); + tokenAcquirerFactory.Services.AddSingleton(httpFactoryForTest); + + // A caller (or MISE) registers an upstream cloud-metadata provider that OVERRIDES the FIC + // audience for the source app's cloud (US Gov). This must win over MSAL's built-in US Gov value, + // proving the OIDC client-credentials FIC leg honors the injected ICloudMetadataProvider end-to-end. + tokenAcquirerFactory.Services.AddSingleton( + new InMemoryCloudMetadataProvider().AddOrUpdate( + "login.microsoftonline.us", + new Dictionary + { + [AbstractionsCloudKeys.TokenExchangeAudience] = "api://AzureADTokenExchangeCustomGov" + })); + + // Source app (provides assertion), configured on US Gov. + tokenAcquirerFactory.Services.Configure("AzureAd2", options => + { + options.Instance = "https://login.microsoftonline.us/"; + options.TenantId = "t1"; + options.ClientId = "c1"; + options.ClientCredentials = new[] + { + new CredentialDescription + { + SourceType = CredentialSource.ClientSecret, + ClientSecret = TestConstants.ClientSecret + } + }; + }); + + // Target app (uses custom signed assertion). + tokenAcquirerFactory.Services.Configure(options => + { + options.Instance = "https://login.microsoftonline.com/"; + options.TenantId = "t2"; + options.ClientId = "c2"; + options.ClientCredentials = new[] + { + new CredentialDescription + { + SourceType = CredentialSource.CustomSignedAssertion, + CustomSignedAssertionProviderName = "OidcIdpSignedAssertion", + CustomSignedAssertionProviderData = new Dictionary + { + ["ConfigurationSection"] = "AzureAd2" + } + } + }; + }); + + var serviceProvider = tokenAcquirerFactory.Build(); + var authorizationHeaderProvider = serviceProvider.GetRequiredService(); + + var header = await authorizationHeaderProvider.CreateAuthorizationHeaderForAppAsync( + TestConstants.s_scopeForApp); + + // The injected provider's audience — NOT MSAL's built-in "api://AzureADTokenExchangeUSGov" — + // is used for the credential exchange, with /.default computed by the resolver. + Assert.Equal("api://AzureADTokenExchangeCustomGov/.default", credentialRequestHttpHandler.ActualRequestPostData["scope"]); + Assert.StartsWith("Bearer", header, StringComparison.Ordinal); + } + + // --------------------------------------------------------------------------------------------- + // Cross-cloud token-exchange (FIC) pseudo-E2E tests: public / sovereign x default / custom. + // + // Each test builds the REAL OIDC-CC FIC pipeline (ID Web -> MSAL) and mocks ONLY MSAL's outbound + // HTTP, then asserts the credential-exchange request's POST 'scope' equals the expected cloud- + // specific token-exchange audience + "/.default". Auto-resolution here flows from MSAL's built-in + // baseline (KnownCloudData) keyed by the SOURCE app's cloud — no injected provider needed for the + // clouds MSAL ships (public, US Gov). A caller override (credential TokenExchangeUrl) must win. + // These mirror the MISE UserFic pseudo-E2E tests one layer down. NOT for the final PRs. + // --------------------------------------------------------------------------------------------- + + [Fact] + public Task Fic_Exchange_PublicCloud_DefaultEndpoint_SendsPublicAudienceAsync() + => RunFicExchangeScenarioAsync( + scenario: "Public cloud, default endpoint (auto-resolve via MSAL baseline)", + sourceInstance: "https://login.microsoftonline.com/", + sourceAuthority: null, + customTokenExchangeUrl: null, + expectedExchangeScope: "api://AzureADTokenExchange/.default"); + + [Fact] + public Task Fic_Exchange_UsGovCloud_DefaultEndpoint_AutoResolvesUsGovAudienceAsync() + // Source app configured via AUTHORITY ONLY (Instance empty) — this is the regression guard for + // the OidcIdpSignedAssertionProvider fix: '_options.Instance ?? _options.Authority' silently + // fell back to the public audience because Instance defaults to "" (not null). US Gov is a + // cloud MSAL ships, so its audience auto-resolves from the baseline with no injected provider. + => RunFicExchangeScenarioAsync( + scenario: "US Gov cloud (Authority-only source), default endpoint (auto-resolve via MSAL baseline)", + sourceInstance: null, + sourceAuthority: "https://login.microsoftonline.us/t1", + customTokenExchangeUrl: null, + expectedExchangeScope: "api://AzureADTokenExchangeUSGov/.default"); + + [Fact] + public Task Fic_Exchange_PublicCloud_CustomEndpoint_SendsCustomAudienceAsync() + => RunFicExchangeScenarioAsync( + scenario: "Public cloud, caller-provided custom endpoint (override wins)", + sourceInstance: "https://login.microsoftonline.com/", + sourceAuthority: null, + customTokenExchangeUrl: "api://MyCustomTokenExchange", + expectedExchangeScope: "api://MyCustomTokenExchange/.default"); + + [Fact] + public Task Fic_Exchange_UsGovCloud_CustomEndpoint_OverrideWinsAsync() + => RunFicExchangeScenarioAsync( + scenario: "US Gov cloud, caller-provided custom endpoint (override wins over MSAL baseline)", + sourceInstance: "https://login.microsoftonline.us/", + sourceAuthority: null, + customTokenExchangeUrl: "api://MyCustomTokenExchange", + expectedExchangeScope: "api://MyCustomTokenExchange/.default"); + + /// + /// Drives the real OIDC-CC FIC pipeline (ID Web -> MSAL, mocking only MSAL HTTP) and asserts the + /// credential-exchange request carried as its POST 'scope'. + /// The source app ("AzureAd2") is configured via EITHER OR + /// (exactly one non-null); cloud resolution keys off it. + /// + private async Task RunFicExchangeScenarioAsync( + string scenario, + string? sourceInstance, + string? sourceAuthority, + string? customTokenExchangeUrl, + string expectedExchangeScope) + { + using var httpFactoryForTest = new MockHttpClientFactory(); + // Leg 1: the credential EXCHANGE (client-credentials on the source cloud). Its 'scope' is the + // cloud-specific token-exchange audience under test. + var credentialRequestHttpHandler = httpFactoryForTest.AddMockHandler( + MockHttpCreator.CreateClientCredentialTokenHandler("token-exchange-1")); + // Leg 2: the target app's actual token acquisition using the signed assertion. + httpFactoryForTest.AddMockHandler( + MockHttpCreator.CreateClientCredentialTokenHandler("final-access-token")); + + TokenAcquirerFactoryTesting.ResetTokenAcquirerFactoryInTest(); + var tokenAcquirerFactory = TokenAcquirerFactory.GetDefaultInstance(); + tokenAcquirerFactory.Services.AddOidcFic(); + tokenAcquirerFactory.Services.AddSingleton(httpFactoryForTest); + + // Source app (provides assertion), configured on the cloud under test — via Instance OR Authority. + tokenAcquirerFactory.Services.Configure("AzureAd2", options => + { + if (!string.IsNullOrEmpty(sourceInstance)) + { + options.Instance = sourceInstance; + options.TenantId = "t1"; + } + else + { + options.Authority = sourceAuthority; + } + + options.ClientId = "c1"; + options.ClientCredentials = new[] + { + new CredentialDescription + { + SourceType = CredentialSource.ClientSecret, + ClientSecret = TestConstants.ClientSecret + } + }; + }); + + // Target app (uses the custom signed assertion; a per-call TokenExchangeUrl overrides resolution). + var customSignedAssertion = new CredentialDescription + { + SourceType = CredentialSource.CustomSignedAssertion, + CustomSignedAssertionProviderName = "OidcIdpSignedAssertion", + CustomSignedAssertionProviderData = new Dictionary + { + ["ConfigurationSection"] = "AzureAd2" + } + }; + if (!string.IsNullOrEmpty(customTokenExchangeUrl)) + { + customSignedAssertion.TokenExchangeUrl = customTokenExchangeUrl; + } + + tokenAcquirerFactory.Services.Configure(options => + { + options.Instance = "https://login.microsoftonline.com/"; + options.TenantId = "t2"; + options.ClientId = "c2"; + options.ClientCredentials = new[] { customSignedAssertion }; + }); + + var serviceProvider = tokenAcquirerFactory.Build(); + var authorizationHeaderProvider = serviceProvider.GetRequiredService(); + + var header = await authorizationHeaderProvider.CreateAuthorizationHeaderForAppAsync( + TestConstants.s_scopeForApp); + + // PRIMARY: the exchange request MSAL sent carried the expected cloud-specific scope. + Assert.Equal(expectedExchangeScope, credentialRequestHttpHandler.ActualRequestPostData["scope"]); + Assert.StartsWith("Bearer", header, StringComparison.Ordinal); + } } } From 98ccbae8b4edea2c9fdc90e754555ab7c8dc8833 Mon Sep 17 00:00:00 2001 From: avdunn Date: Tue, 4 Aug 2026 13:51:07 -0700 Subject: [PATCH 02/14] Remove unnecessary null --- .../ManagedIdentityClientAssertion.cs | 2 +- .../CloudMetadataResolver.cs | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Microsoft.Identity.Web.Certificateless/ManagedIdentityClientAssertion.cs b/src/Microsoft.Identity.Web.Certificateless/ManagedIdentityClientAssertion.cs index af3645744..92d5efaa0 100644 --- a/src/Microsoft.Identity.Web.Certificateless/ManagedIdentityClientAssertion.cs +++ b/src/Microsoft.Identity.Web.Certificateless/ManagedIdentityClientAssertion.cs @@ -126,7 +126,7 @@ private ManagedIdentityClientAssertion( // sovereign cloud, so non-public clouds are configured via tokenExchangeUrl. _tokenExchangeUrl = tokenExchangeUrl ?? KnownCloudConfiguration.Default - .GetSettingsByAuthority(CertificatelessConstants.PublicCloudInstanceHost) + .GetSettingsByAuthorityHost(CertificatelessConstants.PublicCloudInstanceHost) .TokenExchangeAudience() ?? CertificatelessConstants.DefaultTokenExchangeUrl; _logger = logger; diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/CloudMetadataResolver.cs b/src/Microsoft.Identity.Web.TokenAcquisition/CloudMetadataResolver.cs index 402060168..a374f9ee6 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/CloudMetadataResolver.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/CloudMetadataResolver.cs @@ -107,7 +107,6 @@ internal string ResolveTokenExchangeScope(string? authorityOrInstance, string? p // single cross-stack owner of that rule — by wrapping the resolved bare audience in a // CloudSettings. This guarantees ID Web and MSAL never diverge on the suffix rule. var settings = new CloudSettings( - null, new Dictionary(1, StringComparer.OrdinalIgnoreCase) { [MsalCloudKeys.TokenExchangeAudience] = audience, @@ -140,7 +139,7 @@ internal string ResolveTokenExchangeScope(string? authorityOrInstance, string? p } // 3. MSAL public baseline (same key literal as the upstream provider — no remap needed). - return _msalBaseline.GetSettingsByAuthority(host).TokenExchangeAudience(); + return _msalBaseline.GetSettingsByAuthorityHost(host).TokenExchangeAudience(); } /// From 3050d4ffb6c054d10748d38ede2b7be1bd9ad13c Mon Sep 17 00:00:00 2001 From: avdunn Date: Tue, 4 Aug 2026 14:41:06 -0700 Subject: [PATCH 03/14] PR feedback and design changes --- .../OidcIdpSignedAssertionProvider.cs | 2 +- .../CloudMetadataResolver.cs | 53 +++++++++--- ...loudMetadataServiceCollectionExtensions.cs | 79 +++++++++++++++++ .../PublicAPI/NetCore/PublicAPI.Unshipped.txt | 2 + .../NetFramework/PublicAPI.Unshipped.txt | 2 + .../ServiceCollectionExtensions.cs | 11 +-- .../CloudMetadataResolverTests.cs | 86 +++++++++++++++++-- .../FederatedIdentityCaeTests.cs | 11 +-- 8 files changed, 205 insertions(+), 41 deletions(-) create mode 100644 src/Microsoft.Identity.Web.TokenAcquisition/CloudMetadataServiceCollectionExtensions.cs diff --git a/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionProvider.cs b/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionProvider.cs index ab1b320eb..94805a06c 100644 --- a/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionProvider.cs +++ b/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionProvider.cs @@ -58,7 +58,7 @@ internal OidcIdpSignedAssertionProvider(ITokenAcquirerFactory tokenAcquirerFacto _options = options; _tokenExchangeUrl = tokenExchangeUrl; _logger = logger; - _cloudMetadataResolver = cloudMetadataResolver ?? new CloudMetadataResolver(null, null); + _cloudMetadataResolver = cloudMetadataResolver ?? new CloudMetadataResolver(null, _logger); } protected override async Task GetClientAssertionAsync(AssertionRequestOptions? assertionRequestOptions) diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/CloudMetadataResolver.cs b/src/Microsoft.Identity.Web.TokenAcquisition/CloudMetadataResolver.cs index a374f9ee6..e66b3788d 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/CloudMetadataResolver.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/CloudMetadataResolver.cs @@ -2,8 +2,10 @@ // Licensed under the MIT License. using System; +using System.Collections.Concurrent; using System.Collections.Generic; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Identity.Abstractions; using Microsoft.Identity.Client.Instance.Discovery; @@ -16,8 +18,7 @@ namespace Microsoft.Identity.Web /// an explicit per-call override supplied by the caller; /// an upstream (from DI) contributed by a caller or by an /// upstream SDK such as MISE — this is how internal-only sovereign clouds become resolvable; - /// MSAL's built-in public cloud baseline ( / - /// ); + /// MSAL's built-in public cloud baseline (); /// the documented public-cloud fallback (). /// /// @@ -52,24 +53,29 @@ internal sealed class CloudMetadataResolver internal const string DefaultTokenExchangeAudience = "api://AzureADTokenExchange"; private readonly ICloudMetadataProvider? _upstreamProvider; - private readonly ICloudConfiguration _msalBaseline; + private readonly ILogger? _logger; + private readonly ConcurrentDictionary _warnedHosts = + new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); /// /// Initializes a new instance of the class. /// /// An optional upstream provider (from DI) contributed by a caller or - /// an upstream SDK (for example MISE). When null, only the MSAL baseline is consulted. - /// MSAL's public cloud configuration. When null, - /// is used. - internal CloudMetadataResolver(ICloudMetadataProvider? upstreamProvider, ICloudConfiguration? msalBaseline) + /// an upstream SDK (for example MISE). When null, only MSAL's public baseline + /// () and the documented fallback are consulted. + /// An optional logger used to emit a one-time diagnostic when a non-public + /// authority host resolves only to the public-cloud default (a likely missing provider entry). + internal CloudMetadataResolver(ICloudMetadataProvider? upstreamProvider, ILogger? logger = null) { _upstreamProvider = upstreamProvider; - _msalBaseline = msalBaseline ?? KnownCloudConfiguration.Default; + _logger = logger; } /// /// Builds a resolver from the services registered in : an optional - /// upstream and an MSAL baseline. + /// upstream (the single override seam) plus an optional logger. + /// MSAL's public baseline is read directly from , so it + /// is not a separate DI seam. /// /// The DI service provider. /// A configured . @@ -77,7 +83,7 @@ internal static CloudMetadataResolver FromServiceProvider(IServiceProvider servi { return new CloudMetadataResolver( serviceProvider.GetService(), - serviceProvider.GetService()); + serviceProvider.GetService>()); } /// @@ -88,7 +94,26 @@ internal static CloudMetadataResolver FromServiceProvider(IServiceProvider servi /// An optional explicit override that wins over all resolved sources. internal string ResolveTokenExchangeAudience(string? authorityOrInstance, string? perCallOverride = null) { - return ResolveAudience(authorityOrInstance, perCallOverride) ?? DefaultTokenExchangeAudience; + string? resolved = ResolveAudience(authorityOrInstance, perCallOverride); + if (resolved is not null) + { + return resolved; + } + + // No source recognized the host, so fall back to the documented public-cloud default. If the + // caller supplied a (non-public) authority host, surface a one-time warning per host: a sovereign + // or private cloud that resolves only to the public default is almost always a missing + // ICloudMetadataProvider entry rather than intended behavior. + string? host = TryGetHost(authorityOrInstance); + if (_logger != null && !string.IsNullOrEmpty(host) && _warnedHosts.TryAdd(host!, 0)) + { + _logger.LogWarning( + "Microsoft.Identity.Web could not resolve cloud-specific FIC token-exchange metadata for authority host '{AuthorityHost}'; using the public-cloud default audience '{DefaultAudience}'. If this is a sovereign or private cloud, register its metadata via an ICloudMetadataProvider (for example services.AddCloudMetadata(...), or MISE's services.AddMiseCloudMetadata(...)).", + host, + DefaultTokenExchangeAudience); + } + + return DefaultTokenExchangeAudience; } /// @@ -138,8 +163,10 @@ internal string ResolveTokenExchangeScope(string? authorityOrInstance, string? p } } - // 3. MSAL public baseline (same key literal as the upstream provider — no remap needed). - return _msalBaseline.GetSettingsByAuthorityHost(host).TokenExchangeAudience(); + // 3. MSAL public baseline, read directly from KnownCloudConfiguration.Default (same key literal as + // the upstream provider — no remap needed). Reading .Default directly keeps the upstream + // ICloudMetadataProvider as the single override seam rather than exposing a second DI seam. + return KnownCloudConfiguration.Default.GetSettingsByAuthorityHost(host).TokenExchangeAudience(); } /// diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/CloudMetadataServiceCollectionExtensions.cs b/src/Microsoft.Identity.Web.TokenAcquisition/CloudMetadataServiceCollectionExtensions.cs new file mode 100644 index 000000000..31f1163d0 --- /dev/null +++ b/src/Microsoft.Identity.Web.TokenAcquisition/CloudMetadataServiceCollectionExtensions.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Identity.Abstractions; + +namespace Microsoft.Identity.Web +{ + /// + /// Extension methods that register cloud-specific metadata (for example the Federated Identity Credential + /// token-exchange audience, keyed by authority host) from configuration, so Microsoft.Identity.Web can + /// resolve the correct value for sovereign or private clouds it and MSAL do not ship — with no code beyond + /// binding an appsettings.json section. + /// + public static class CloudMetadataServiceCollectionExtensions + { + /// + /// Registers an populated from + /// so Microsoft.Identity.Web resolves cloud-specific FIC token-exchange values (for clouds it and MSAL do + /// not know about) from configuration. This is the ID Web counterpart to MISE's + /// AddMiseCloudMetadata(IConfiguration) and uses the same section shape. + /// + /// The service collection to modify. + /// A configuration section whose immediate children are authority + /// hosts, each of whose children are metadata key/value pairs. For example: + /// + /// "CloudMetadata": { + /// "login.microsoftonline.us": { "token_exchange_audience": "api://AzureADTokenExchangeUSGov" }, + /// "login.mynewcloud.example": { "token_exchange_audience": "api://AzureADTokenExchangeMyCloud" } + /// } + /// + /// Keys should come from . + /// + /// The original service collection instance. + /// + /// The provider is registered with TryAddSingleton, so an a + /// caller (or an upstream SDK such as MISE) registered explicitly beforehand takes precedence and is + /// left untouched. + /// + public static IServiceCollection AddCloudMetadata(this IServiceCollection services, IConfiguration cloudMetadataSection) + { + if (services is null) + { + throw new ArgumentNullException(nameof(services)); + } + + if (cloudMetadataSection is null) + { + throw new ArgumentNullException(nameof(cloudMetadataSection)); + } + + var provider = new InMemoryCloudMetadataProvider(); + + foreach (IConfigurationSection hostSection in cloudMetadataSection.GetChildren()) + { + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (IConfigurationSection entry in hostSection.GetChildren()) + { + if (entry.Value is not null) + { + values[entry.Key] = entry.Value; + } + } + + if (values.Count > 0) + { + provider.AddOrUpdate(hostSection.Key, values); + } + } + + services.TryAddSingleton(provider); + return services; + } + } +} diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/NetCore/PublicAPI.Unshipped.txt b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/NetCore/PublicAPI.Unshipped.txt index 7dc5c5811..c07b07d4a 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/NetCore/PublicAPI.Unshipped.txt +++ b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/NetCore/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +Microsoft.Identity.Web.CloudMetadataServiceCollectionExtensions +static Microsoft.Identity.Web.CloudMetadataServiceCollectionExtensions.AddCloudMetadata(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, Microsoft.Extensions.Configuration.IConfiguration! cloudMetadataSection) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/NetFramework/PublicAPI.Unshipped.txt b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/NetFramework/PublicAPI.Unshipped.txt index 7dc5c5811..c07b07d4a 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/NetFramework/PublicAPI.Unshipped.txt +++ b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/NetFramework/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +Microsoft.Identity.Web.CloudMetadataServiceCollectionExtensions +static Microsoft.Identity.Web.CloudMetadataServiceCollectionExtensions.AddCloudMetadata(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, Microsoft.Extensions.Configuration.IConfiguration! cloudMetadataSection) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/ServiceCollectionExtensions.cs b/src/Microsoft.Identity.Web.TokenAcquisition/ServiceCollectionExtensions.cs index 4d382c552..e3d6c813d 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/ServiceCollectionExtensions.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/ServiceCollectionExtensions.cs @@ -9,7 +9,6 @@ using Microsoft.Extensions.Options; using Microsoft.Identity.Abstractions; using Microsoft.Identity.Client; -using Microsoft.Identity.Client.Instance.Discovery; using Microsoft.Identity.Web.Extensibility; using Microsoft.Identity.Web.Hosts; @@ -53,14 +52,10 @@ public static IServiceCollection AddTokenAcquisition( services.TryAddSingleton(); } - // Default cloud metadata provider used to resolve cloud-specific FIC token-exchange - // audiences by authority host. TryAdd lets a caller (or MISE) register a replacement or a - // composed provider before this call wins. - services.TryAddSingleton(KnownCloudConfiguration.Default); - // The resolver layers an optional upstream ICloudMetadataProvider (registered by a caller or by - // MISE) over the MSAL baseline above, and is the single audience-vs-scope decision point shared - // by all FIC legs. Registered via a factory so it observes whichever provider/baseline win TryAdd. + // MISE — the single override seam) over MSAL's public baseline, which it reads directly from + // KnownCloudConfiguration.Default. It is the single audience-vs-scope decision point shared by all + // FIC legs. Registered via a factory so it observes whichever ICloudMetadataProvider wins TryAdd. services.TryAddSingleton(sp => CloudMetadataResolver.FromServiceProvider(sp)); if (!HasImplementationType(services, typeof(MsalMtlsHttpClientFactory))) diff --git a/tests/Microsoft.Identity.Web.Test/CloudMetadataResolverTests.cs b/tests/Microsoft.Identity.Web.Test/CloudMetadataResolverTests.cs index 2cf05a89a..6e6b4b970 100644 --- a/tests/Microsoft.Identity.Web.Test/CloudMetadataResolverTests.cs +++ b/tests/Microsoft.Identity.Web.Test/CloudMetadataResolverTests.cs @@ -2,10 +2,13 @@ // Licensed under the MIT License. using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Identity.Abstractions; -using Microsoft.Identity.Client.Instance.Discovery; using Microsoft.Identity.Web; +using NSubstitute; using Xunit; namespace Microsoft.Identity.Web.Test @@ -27,7 +30,7 @@ public class CloudMetadataResolverTests public void ResolveAudience_UnknownHost_NoProvider_ReturnsDocumentedDefault() { // Arrange - var resolver = new CloudMetadataResolver(upstreamProvider: null, msalBaseline: null); + var resolver = new CloudMetadataResolver(upstreamProvider: null); // Act string audience = resolver.ResolveTokenExchangeAudience(NewCloudAuthority); @@ -40,7 +43,7 @@ public void ResolveAudience_UnknownHost_NoProvider_ReturnsDocumentedDefault() public void ResolveAudience_KnownSovereignHost_NoProvider_ResolvesFromMsalBaseline() { // Arrange - var resolver = new CloudMetadataResolver(upstreamProvider: null, msalBaseline: null); + var resolver = new CloudMetadataResolver(upstreamProvider: null); // Act string audience = resolver.ResolveTokenExchangeAudience(UsGovAuthority); @@ -59,7 +62,7 @@ public void ResolveAudience_UpstreamProvider_WinsOverMsalBaseline() { [AbstractionsCloudKeys.TokenExchangeAudience] = "api://AzureADTokenExchangeCustomGov", }); - var resolver = new CloudMetadataResolver(provider, msalBaseline: null); + var resolver = new CloudMetadataResolver(provider); // Act string audience = resolver.ResolveTokenExchangeAudience(UsGovAuthority); @@ -78,7 +81,7 @@ public void ResolveAudience_UpstreamProvider_ResolvesNewCloudUnknownToMsal() { [AbstractionsCloudKeys.TokenExchangeAudience] = "api://AzureADTokenExchangeMyCloud", }); - var resolver = new CloudMetadataResolver(provider, msalBaseline: null); + var resolver = new CloudMetadataResolver(provider); // Act string audience = resolver.ResolveTokenExchangeAudience(NewCloudAuthority); @@ -97,7 +100,7 @@ public void ResolveAudience_PerCallOverride_WinsOverProviderAndBaseline() { [AbstractionsCloudKeys.TokenExchangeAudience] = "api://AzureADTokenExchangeCustomGov", }); - var resolver = new CloudMetadataResolver(provider, msalBaseline: null); + var resolver = new CloudMetadataResolver(provider); // Act string audience = resolver.ResolveTokenExchangeAudience(UsGovAuthority, perCallOverride: "api://PerCallAudience"); @@ -110,7 +113,7 @@ public void ResolveAudience_PerCallOverride_WinsOverProviderAndBaseline() public void ResolveScope_AppendsDefaultSuffix_Idempotently() { // Arrange - var resolver = new CloudMetadataResolver(upstreamProvider: null, msalBaseline: null); + var resolver = new CloudMetadataResolver(upstreamProvider: null); // Act string fromBare = resolver.ResolveTokenExchangeScope(PublicHost, perCallOverride: "api://Aud"); @@ -125,7 +128,7 @@ public void ResolveScope_AppendsDefaultSuffix_Idempotently() public void ResolveScope_KnownSovereignHost_ComputesScopeFromBaseline() { // Arrange - var resolver = new CloudMetadataResolver(upstreamProvider: null, msalBaseline: null); + var resolver = new CloudMetadataResolver(upstreamProvider: null); // Act string scope = resolver.ResolveTokenExchangeScope(UsGovAuthority); @@ -146,7 +149,6 @@ public void FromServiceProvider_HonorsRegisteredUpstreamProvider() }); var services = new ServiceCollection(); services.AddSingleton(provider); - services.AddSingleton(KnownCloudConfiguration.Default); using ServiceProvider serviceProvider = services.BuildServiceProvider(); // Act @@ -156,5 +158,71 @@ public void FromServiceProvider_HonorsRegisteredUpstreamProvider() Assert.Equal("api://AzureADTokenExchangeMyCloud", resolver.ResolveTokenExchangeAudience(NewCloudAuthority)); Assert.Equal("api://AzureADTokenExchangeUSGov", resolver.ResolveTokenExchangeAudience(UsGovAuthority)); } + + [Fact] + public void AddCloudMetadata_FromConfiguration_RegistersResolvableProvider() + { + // Arrange: a new cloud MSAL/ID Web do not ship, supplied entirely from an appsettings-style section. + IConfiguration config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [$"CloudMetadata:{NewCloudHost}:{AbstractionsCloudKeys.TokenExchangeAudience}"] = "api://AzureADTokenExchangeMyCloud", + }) + .Build(); + var services = new ServiceCollection(); + + // Act + services.AddCloudMetadata(config.GetSection("CloudMetadata")); + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + + // Assert: the provider is registered and the resolver honors it end-to-end (audience + scope). + var provider = serviceProvider.GetRequiredService(); + Assert.Equal( + "api://AzureADTokenExchangeMyCloud", + provider.GetByAuthorityHost(NewCloudHost)!.GetValueOrDefault(AbstractionsCloudKeys.TokenExchangeAudience)); + + CloudMetadataResolver resolver = CloudMetadataResolver.FromServiceProvider(serviceProvider); + Assert.Equal("api://AzureADTokenExchangeMyCloud", resolver.ResolveTokenExchangeAudience(NewCloudAuthority)); + Assert.Equal("api://AzureADTokenExchangeMyCloud/.default", resolver.ResolveTokenExchangeScope(NewCloudAuthority)); + } + + [Fact] + public void ResolveAudience_UnknownNonPublicHost_WithLogger_WarnsOncePerHost() + { + // Arrange + var logger = Substitute.For>(); + var resolver = new CloudMetadataResolver(upstreamProvider: null, logger); + + // Act: an unrecognized (non-public) host falls back to the documented public default... + string audience = resolver.ResolveTokenExchangeAudience(NewCloudAuthority); + // ...and a second lookup for the same host must not warn again (deduped). + resolver.ResolveTokenExchangeAudience(NewCloudAuthority); + + // Assert + Assert.Equal("api://AzureADTokenExchange", audience); + Assert.Equal(1, CountWarnings(logger)); + } + + [Fact] + public void ResolveAudience_PublicOrKnownHost_WithLogger_DoesNotWarn() + { + // Arrange + var logger = Substitute.For>(); + var resolver = new CloudMetadataResolver(upstreamProvider: null, logger); + + // Act: public cloud and a sovereign cloud MSAL ships both resolve without a fallback warning. + resolver.ResolveTokenExchangeAudience("https://login.microsoftonline.com/tenant"); + resolver.ResolveTokenExchangeAudience(UsGovAuthority); + + // Assert + Assert.Equal(0, CountWarnings(logger)); + } + + private static int CountWarnings(ILogger logger) => + logger.ReceivedCalls().Count(call => + { + object?[] args = call.GetArguments(); + return args.Length > 0 && args[0] is LogLevel level && level == LogLevel.Warning; + }); } } diff --git a/tests/Microsoft.Identity.Web.Test/FederatedIdentityCaeTests.cs b/tests/Microsoft.Identity.Web.Test/FederatedIdentityCaeTests.cs index 3ea316103..ec45a55d4 100644 --- a/tests/Microsoft.Identity.Web.Test/FederatedIdentityCaeTests.cs +++ b/tests/Microsoft.Identity.Web.Test/FederatedIdentityCaeTests.cs @@ -357,7 +357,7 @@ public async Task Fic_CustomSignedAssertion_HonorsInjectedCloudMetadataProvider( // specific token-exchange audience + "/.default". Auto-resolution here flows from MSAL's built-in // baseline (KnownCloudData) keyed by the SOURCE app's cloud — no injected provider needed for the // clouds MSAL ships (public, US Gov). A caller override (credential TokenExchangeUrl) must win. - // These mirror the MISE UserFic pseudo-E2E tests one layer down. NOT for the final PRs. + // The US-Gov-default case is also the regression guard for the Authority-vs-Instance resolution fix. // --------------------------------------------------------------------------------------------- [Fact] @@ -391,15 +391,6 @@ public Task Fic_Exchange_PublicCloud_CustomEndpoint_SendsCustomAudienceAsync() customTokenExchangeUrl: "api://MyCustomTokenExchange", expectedExchangeScope: "api://MyCustomTokenExchange/.default"); - [Fact] - public Task Fic_Exchange_UsGovCloud_CustomEndpoint_OverrideWinsAsync() - => RunFicExchangeScenarioAsync( - scenario: "US Gov cloud, caller-provided custom endpoint (override wins over MSAL baseline)", - sourceInstance: "https://login.microsoftonline.us/", - sourceAuthority: null, - customTokenExchangeUrl: "api://MyCustomTokenExchange", - expectedExchangeScope: "api://MyCustomTokenExchange/.default"); - /// /// Drives the real OIDC-CC FIC pipeline (ID Web -> MSAL, mocking only MSAL HTTP) and asserts the /// credential-exchange request carried as its POST 'scope'. From caf1256507af67aadc3b0592fac163fc6fb4e831 Mon Sep 17 00:00:00 2001 From: avdunn Date: Tue, 4 Aug 2026 14:50:43 -0700 Subject: [PATCH 04/14] Review pass: harden authority/instance host selection, tidy comments Use IsNullOrEmpty(Authority) ? Instance : Authority in the agent-FIC legs so an empty-string authority falls through to Instance (mirrors the OIDC source-host fix); a plain ?? only guards null. Reword two comments to present-tense behavior descriptions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0f4470fd-e0ff-4d14-b2c7-ece13faf29ff --- .../CertificatelessConstants.cs | 2 +- .../TokenAcquisition.cs | 4 ++-- .../Microsoft.Identity.Web.Test/FederatedIdentityCaeTests.cs | 5 ++--- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/Microsoft.Identity.Web.Certificateless/CertificatelessConstants.cs b/src/Microsoft.Identity.Web.Certificateless/CertificatelessConstants.cs index 6cd2e829c..c7b50608b 100644 --- a/src/Microsoft.Identity.Web.Certificateless/CertificatelessConstants.cs +++ b/src/Microsoft.Identity.Web.Certificateless/CertificatelessConstants.cs @@ -7,7 +7,7 @@ internal class CertificatelessConstants { // Managed Identity Federated Identity Credential. // Documented public-cloud fallback used only when MSAL's cloud metadata cannot supply the - // audience. The primary value now comes from MSAL (single source of truth); an explicit + // audience. The primary value comes from MSAL (single source of truth); an explicit // TokenExchangeUrl in configuration still overrides both. internal const string DefaultTokenExchangeUrl = "api://AzureADTokenExchange"; diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs index cc5d4b929..9be327c98 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs @@ -716,7 +716,7 @@ private async Task GetAuthenticationResultForUserInternalA // Resolve the cloud-specific FIC exchange scope from the agent authority host so sovereign // clouds use the correct audience (public cloud is unchanged via the documented fallback). var leg2Builder = agentCca.AcquireTokenForClient( - new[] { _cloudMetadataResolver.ResolveTokenExchangeScope(mergedOptions.Authority ?? mergedOptions.Instance) }); + new[] { _cloudMetadataResolver.ResolveTokenExchangeScope(string.IsNullOrEmpty(mergedOptions.Authority) ? mergedOptions.Instance : mergedOptions.Authority) }); if (!string.IsNullOrEmpty(tenantId)) { leg2Builder.WithTenantId(tenantId); @@ -801,7 +801,7 @@ private async Task GetOrBuildAgentUserFicCcaAsyn var leg1Builder = blueprintCca .AcquireTokenForClient( - new[] { _cloudMetadataResolver.ResolveTokenExchangeScope(blueprintOptions.Authority ?? blueprintOptions.Instance) }) + new[] { _cloudMetadataResolver.ResolveTokenExchangeScope(string.IsNullOrEmpty(blueprintOptions.Authority) ? blueprintOptions.Instance : blueprintOptions.Authority) }) .WithFmiPath(agentAppId) .WithSendX5C(blueprintOptions.SendX5C); diff --git a/tests/Microsoft.Identity.Web.Test/FederatedIdentityCaeTests.cs b/tests/Microsoft.Identity.Web.Test/FederatedIdentityCaeTests.cs index ec45a55d4..ef48708db 100644 --- a/tests/Microsoft.Identity.Web.Test/FederatedIdentityCaeTests.cs +++ b/tests/Microsoft.Identity.Web.Test/FederatedIdentityCaeTests.cs @@ -232,9 +232,8 @@ public async Task Fic_CustomSignedAssertion_ClaimsAndCapabilities_AreSent_OnSeco // Assert endpoints, scopes, client IDs // The source app (c1) is configured on US Gov (login.microsoftonline.us), so the FIC - // token-exchange audience is now auto-resolved from MSAL's cloud metadata to the US Gov - // value instead of the public-cloud default. This is the cross-cloud-metadata feature - // correcting the previously hardcoded public-cloud audience. + // token-exchange audience auto-resolves from MSAL's cloud metadata to the US Gov value + // rather than the public-cloud default — the cross-cloud-metadata resolution under test. Assert.Equal("api://AzureADTokenExchangeUSGov/.default", credentialRequestHttpHandler.ActualRequestPostData["scope"]); Assert.Equal(TestConstants.s_scopeForApp, tokenRequestHttpHandler.ActualRequestPostData["scope"]); Assert.Equal("c1", credentialRequestHttpHandler.ActualRequestPostData["client_id"]); From c159481f860f3b775f5afbcc77b4aab1bc956cc9 Mon Sep 17 00:00:00 2001 From: avdunn Date: Wed, 5 Aug 2026 10:11:22 -0700 Subject: [PATCH 05/14] Small fixes and PR feedback --- .../OidcIdpSignedAssertionLoader.cs | 3 ++- .../CloudMetadataResolver.cs | 7 +++++++ .../TokenAcquisition.cs | 1 - .../FederatedIdentityCaeTests.cs | 5 +++-- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionLoader.cs b/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionLoader.cs index 0be14620e..e35d9373c 100644 --- a/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionLoader.cs +++ b/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionLoader.cs @@ -201,7 +201,8 @@ public async Task LoadIfNeededAsync(CredentialDescription credentialDescription, microsoftIdentityApplicationOptions, credentialDescription.TokenExchangeUrl, _logger, - CloudMetadataResolver.FromServiceProvider(_serviceProvider)); + _serviceProvider.GetService() + ?? CloudMetadataResolver.FromServiceProvider(_serviceProvider)); if (credentialDescription.CustomSignedAssertionProviderData.TryGetValue("RequiresSignedAssertionFmiPath", out object? requiresSignedAssertionFmiPathObj) && requiresSignedAssertionFmiPathObj is bool requiresSignedAssertionFmiPathBool && requiresSignedAssertionFmiPathBool) { signedAssertion.RequiresSignedAssertionFmiPath = true; diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/CloudMetadataResolver.cs b/src/Microsoft.Identity.Web.TokenAcquisition/CloudMetadataResolver.cs index e66b3788d..f3b4b6977 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/CloudMetadataResolver.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/CloudMetadataResolver.cs @@ -166,6 +166,13 @@ internal string ResolveTokenExchangeScope(string? authorityOrInstance, string? p // 3. MSAL public baseline, read directly from KnownCloudConfiguration.Default (same key literal as // the upstream provider — no remap needed). Reading .Default directly keeps the upstream // ICloudMetadataProvider as the single override seam rather than exposing a second DI seam. + // A null/empty host cannot match any cloud, so short-circuit to keep the resolver fail-soft + // (the caller then applies the documented public-cloud default). + if (string.IsNullOrEmpty(host)) + { + return null; + } + return KnownCloudConfiguration.Default.GetSettingsByAuthorityHost(host).TokenExchangeAudience(); } diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs index 9be327c98..73b459442 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs @@ -21,7 +21,6 @@ using Microsoft.Identity.Abstractions; using Microsoft.Identity.Client; using Microsoft.Identity.Client.Extensibility; -using Microsoft.Identity.Client.Instance.Discovery; using Microsoft.Identity.Web.Experimental; using Microsoft.Identity.Web.Extensibility; using Microsoft.Identity.Web.TestOnly; diff --git a/tests/Microsoft.Identity.Web.Test/FederatedIdentityCaeTests.cs b/tests/Microsoft.Identity.Web.Test/FederatedIdentityCaeTests.cs index ef48708db..39a528d24 100644 --- a/tests/Microsoft.Identity.Web.Test/FederatedIdentityCaeTests.cs +++ b/tests/Microsoft.Identity.Web.Test/FederatedIdentityCaeTests.cs @@ -280,8 +280,9 @@ public async Task Fic_CustomSignedAssertion_HonorsInjectedCloudMetadataProvider( // First request (credential exchange) — its "scope" is what we assert on. var credentialRequestHttpHandler = httpFactoryForTest.AddMockHandler( MockHttpCreator.CreateClientCredentialTokenHandler("token-exchange-1")); - // Second request (actual token acquisition). - var tokenRequestHttpHandler = httpFactoryForTest.AddMockHandler( + // Second request (actual token acquisition). The handler must be queued so the second leg has a + // response, but the test asserts only on the first (credential-exchange) request. + httpFactoryForTest.AddMockHandler( MockHttpCreator.CreateClientCredentialTokenHandler("final-access-token")); TokenAcquirerFactoryTesting.ResetTokenAcquirerFactoryInTest(); From bf7116f967e7378ab091716a055eedcbc65cda5d Mon Sep 17 00:00:00 2001 From: avdunn Date: Mon, 24 Aug 2026 09:44:09 -0700 Subject: [PATCH 06/14] PR feedback, simplify design --- .../ManagedIdentityClientAssertion.cs | 85 +++++-- .../OidcIdpSignedAssertionLoader.cs | 3 +- .../OidcIdpSignedAssertionProvider.cs | 15 +- .../CloudMetadataResolution.cs | 151 ++++++++++++ .../CloudMetadataResolver.cs | 199 --------------- ...loudMetadataServiceCollectionExtensions.cs | 11 +- .../CredentialsProvider.cs | 21 +- ...softIdentityHttpClientBuilderExtensions.cs | 3 +- .../ServiceCollectionExtensions.cs | 6 - .../TokenAcquisition.cs | 18 +- .../Microsoft.Identity.Web.UI.xml | 6 + .../CloudMetadataResolutionTests.cs | 161 +++++++++++++ .../CloudMetadataResolverTests.cs | 228 ------------------ .../FederatedIdentityCaeTests.cs | 2 +- ...dIdentityClientAssertionResolutionTests.cs | 99 ++++++++ 15 files changed, 520 insertions(+), 488 deletions(-) create mode 100644 src/Microsoft.Identity.Web.TokenAcquisition/CloudMetadataResolution.cs delete mode 100644 src/Microsoft.Identity.Web.TokenAcquisition/CloudMetadataResolver.cs create mode 100644 tests/Microsoft.Identity.Web.Test/CloudMetadataResolutionTests.cs delete mode 100644 tests/Microsoft.Identity.Web.Test/CloudMetadataResolverTests.cs create mode 100644 tests/Microsoft.Identity.Web.Test/ManagedIdentityClientAssertionResolutionTests.cs diff --git a/src/Microsoft.Identity.Web.Certificateless/ManagedIdentityClientAssertion.cs b/src/Microsoft.Identity.Web.Certificateless/ManagedIdentityClientAssertion.cs index 92d5efaa0..529c4bb8e 100644 --- a/src/Microsoft.Identity.Web.Certificateless/ManagedIdentityClientAssertion.cs +++ b/src/Microsoft.Identity.Web.Certificateless/ManagedIdentityClientAssertion.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using System; @@ -22,7 +22,8 @@ namespace Microsoft.Identity.Web public class ManagedIdentityClientAssertion : ClientAssertionProviderBase { private IManagedIdentityApplication _managedIdentityApplication; - private readonly string _tokenExchangeUrl; + private readonly string? _explicitTokenExchangeUrl; + private readonly string _defaultTokenExchangeUrl; private readonly ILogger? _logger; private readonly IKeyAttestationProvider? _keyAttestationProvider; private int _unattestedFlowLogged; @@ -41,8 +42,7 @@ public ManagedIdentityClientAssertion(string? managedIdentityClientId) : /// See https://aka.ms/ms-id-web/certificateless. /// /// Optional ClientId of the Managed Identity - /// Optional audience of the token to be requested from Managed Identity. Default value is "api://AzureADTokenExchange". - /// This value is different on clouds other than Azure Public + /// Optional audience of the token to be requested from Managed Identity. When omitted, the audience is auto-resolved from the calling confidential client's authority host (public cloud resolves to "api://AzureADTokenExchange"; sovereign/national clouds resolve to their own value), falling back to the public-cloud audience. public ManagedIdentityClientAssertion(string? managedIdentityClientId, string? tokenExchangeUrl) : this(managedIdentityClientId, tokenExchangeUrl, null) { @@ -52,8 +52,7 @@ public ManagedIdentityClientAssertion(string? managedIdentityClientId, string? t /// See https://aka.ms/ms-id-web/certificateless. /// /// Optional ClientId of the Managed Identity - /// Optional audience of the token to be requested from Managed Identity. Default value is "api://AzureADTokenExchange". - /// This value is different on clouds other than Azure Public + /// Optional audience of the token to be requested from Managed Identity. When omitted, the audience is auto-resolved from the calling confidential client's authority host (public cloud resolves to "api://AzureADTokenExchange"; sovereign/national clouds resolve to their own value), falling back to the public-cloud audience. /// A logger public ManagedIdentityClientAssertion( string? managedIdentityClientId, @@ -95,8 +94,7 @@ public ManagedIdentityClientAssertion( /// but allows injecting a custom MSAL HttpClient factory (used by tests). /// /// Optional ClientId of the Managed Identity - /// Optional audience of the token to be requested from Managed Identity. Default value is "api://AzureADTokenExchange". - /// This value is different on clouds other than Azure Public + /// Optional audience of the token to be requested from Managed Identity. When omitted, the audience is auto-resolved from the calling confidential client's authority host (public cloud resolves to "api://AzureADTokenExchange"; sovereign/national clouds resolve to their own value), falling back to the public-cloud audience. /// A logger. /// Optional MSAL HttpClient factory. internal ManagedIdentityClientAssertion( @@ -120,15 +118,20 @@ private ManagedIdentityClientAssertion( IKeyAttestationProvider? keyAttestationProvider, IMsalHttpClientFactory? testHttpClientFactory) { - // Precedence: an explicit tokenExchangeUrl wins; otherwise take the audience from MSAL's - // cloud metadata for the public cloud (single source of truth), falling back to the - // documented constant. The managed-identity leg has no AAD authority to auto-resolve a - // sovereign cloud, so non-public clouds are configured via tokenExchangeUrl. - _tokenExchangeUrl = tokenExchangeUrl - ?? KnownCloudConfiguration.Default - .GetSettingsByAuthorityHost(CertificatelessConstants.PublicCloudInstanceHost) - .TokenExchangeAudience() - ?? CertificatelessConstants.DefaultTokenExchangeUrl; + // Resolution precedence for the token-exchange audience (see ResolveTokenExchangeUrl): + // 1. An explicit tokenExchangeUrl passed here always wins. + // 2. Otherwise, resolve per-request from the calling confidential client's authority host + // (so a sovereign/national cloud request auto-selects its own audience). + // 3. Otherwise, fall back to the public-cloud audience computed here. + // The default is taken from MSAL's cloud metadata for the public cloud (single source of + // truth), falling back to the documented constant if the table has no public entry. + _explicitTokenExchangeUrl = tokenExchangeUrl; + _defaultTokenExchangeUrl = + (KnownCloudMetadata.Default.GetByAuthorityHost(CertificatelessConstants.PublicCloudInstanceHost) is { } publicValues + && publicValues.TryGetValue(CloudMetadataKeyNames.FederatedCredentialAudience, out string? publicAudience) + && !string.IsNullOrEmpty(publicAudience)) + ? publicAudience! + : CertificatelessConstants.DefaultTokenExchangeUrl; _logger = logger; _keyAttestationProvider = keyAttestationProvider; @@ -148,7 +151,7 @@ private ManagedIdentityClientAssertion( if (_logger != null) { builder = builder.WithLogging(new IdentityLoggerAdapter(_logger), enablePiiLogging: false); - _logger.LogInformation($"ManagedIdentityClientAssertion with tokenExchangeUrl={_tokenExchangeUrl}"); + _logger.LogInformation($"ManagedIdentityClientAssertion with tokenExchangeUrl={_explicitTokenExchangeUrl ?? _defaultTokenExchangeUrl}"); } _managedIdentityApplication = builder @@ -219,7 +222,7 @@ private async Task AcquireManagedIdentityTokenAsync( CancellationToken cancellationToken) { var miBuilder = _managedIdentityApplication - .AcquireTokenForManagedIdentity(_tokenExchangeUrl); + .AcquireTokenForManagedIdentity(ResolveTokenExchangeUrl(assertionRequestOptions)); if (bindToCertificate) { @@ -259,5 +262,49 @@ private async Task AcquireManagedIdentityTokenAsync( .ConfigureAwait(false); } + /// + /// Resolves the token-exchange audience for a given assertion request. + /// Precedence: an explicit tokenExchangeUrl supplied to the constructor always wins; otherwise the + /// audience is auto-resolved from the calling confidential client's authority host (so a + /// sovereign/national cloud request selects its own audience); otherwise the public-cloud default + /// is used. Because the resolution keys off the request authority, a single instance shared across + /// clouds still emits the correct per-cloud audience. + /// + private string ResolveTokenExchangeUrl(AssertionRequestOptions? assertionRequestOptions) + { + if (!string.IsNullOrEmpty(_explicitTokenExchangeUrl)) + { + return _explicitTokenExchangeUrl!; + } + + string? host = TryGetHost(assertionRequestOptions?.Authority); + if (!string.IsNullOrEmpty(host) && + KnownCloudMetadata.Default.GetByAuthorityHost(host) is { } values && + values.TryGetValue(CloudMetadataKeyNames.FederatedCredentialAudience, out string? audience) && + !string.IsNullOrEmpty(audience)) + { + return audience!; + } + + return _defaultTokenExchangeUrl; + } + + /// + /// Extracts the host from an authority string. Accepts either a full authority URI + /// (e.g. "https://login.microsoftonline.us/tenant") or a bare host, returning the input + /// unchanged when it is not a parseable absolute URI. + /// + private static string? TryGetHost(string? authority) + { + if (string.IsNullOrEmpty(authority)) + { + return null; + } + + return Uri.TryCreate(authority, UriKind.Absolute, out Uri? uri) + ? uri.Host + : authority; + } + } } diff --git a/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionLoader.cs b/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionLoader.cs index e35d9373c..001bac836 100644 --- a/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionLoader.cs +++ b/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionLoader.cs @@ -201,8 +201,7 @@ public async Task LoadIfNeededAsync(CredentialDescription credentialDescription, microsoftIdentityApplicationOptions, credentialDescription.TokenExchangeUrl, _logger, - _serviceProvider.GetService() - ?? CloudMetadataResolver.FromServiceProvider(_serviceProvider)); + _serviceProvider.GetService()); if (credentialDescription.CustomSignedAssertionProviderData.TryGetValue("RequiresSignedAssertionFmiPath", out object? requiresSignedAssertionFmiPathObj) && requiresSignedAssertionFmiPathObj is bool requiresSignedAssertionFmiPathBool && requiresSignedAssertionFmiPathBool) { signedAssertion.RequiresSignedAssertionFmiPath = true; diff --git a/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionProvider.cs b/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionProvider.cs index 94805a06c..a66537886 100644 --- a/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionProvider.cs +++ b/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionProvider.cs @@ -34,7 +34,7 @@ internal class OidcIdpSignedAssertionProvider : ClientAssertionProviderBase private readonly MicrosoftIdentityApplicationOptions _options; private readonly string? _tokenExchangeUrl; private readonly ILogger? _logger; - private readonly CloudMetadataResolver _cloudMetadataResolver; + private readonly ICloudMetadataProvider? _cloudMetadataProvider; public bool RequiresSignedAssertionFmiPath { get; internal set; } @@ -48,17 +48,17 @@ internal class OidcIdpSignedAssertionProvider : ClientAssertionProviderBase public override bool SupportsTokenBinding => true; public OidcIdpSignedAssertionProvider(ITokenAcquirerFactory tokenAcquirerFactory, MicrosoftIdentityApplicationOptions options, string? tokenExchangeUrl, ILogger? logger) - : this(tokenAcquirerFactory, options, tokenExchangeUrl, logger, cloudMetadataResolver: null) + : this(tokenAcquirerFactory, options, tokenExchangeUrl, logger, cloudMetadataProvider: null) { } - internal OidcIdpSignedAssertionProvider(ITokenAcquirerFactory tokenAcquirerFactory, MicrosoftIdentityApplicationOptions options, string? tokenExchangeUrl, ILogger? logger, CloudMetadataResolver? cloudMetadataResolver) + internal OidcIdpSignedAssertionProvider(ITokenAcquirerFactory tokenAcquirerFactory, MicrosoftIdentityApplicationOptions options, string? tokenExchangeUrl, ILogger? logger, ICloudMetadataProvider? cloudMetadataProvider) { _tokenAcquirerFactory = tokenAcquirerFactory; _options = options; _tokenExchangeUrl = tokenExchangeUrl; _logger = logger; - _cloudMetadataResolver = cloudMetadataResolver ?? new CloudMetadataResolver(null, _logger); + _cloudMetadataProvider = cloudMetadataProvider; } protected override async Task GetClientAssertionAsync(AssertionRequestOptions? assertionRequestOptions) @@ -156,13 +156,14 @@ private async Task AcquireOidcAssertionResultAsync( _tokenAcquirer ??= _tokenAcquirerFactory.GetTokenAcquirer(_options); // Precedence: an explicitly configured TokenExchangeUrl wins (as a per-call override); otherwise - // resolve the cloud-specific exchange scope from the layered resolver — a caller/MISE + // resolve the cloud-specific exchange scope from the layered resolver — a caller's // ICloudMetadataProvider (from DI) over MSAL's public baseline — keyed by this application's // instance host (public cloud is unchanged via the documented fallback). The scope form // (/.default) is used because this is a client-credentials / app-token acquisition. - string effectiveTokenExchangeUrl = _cloudMetadataResolver.ResolveTokenExchangeScope( + string effectiveTokenExchangeUrl = CloudMetadataResolution.ResolveTokenExchangeScope( string.IsNullOrEmpty(_options.Instance) ? _options.Authority : _options.Instance, - perCallOverride: _tokenExchangeUrl); + perCallOverride: _tokenExchangeUrl, + _cloudMetadataProvider); string? fmiPath = assertionRequestOptions?.ClientAssertionFmiPath; diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/CloudMetadataResolution.cs b/src/Microsoft.Identity.Web.TokenAcquisition/CloudMetadataResolution.cs new file mode 100644 index 000000000..a7f93cec6 --- /dev/null +++ b/src/Microsoft.Identity.Web.TokenAcquisition/CloudMetadataResolution.cs @@ -0,0 +1,151 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using Microsoft.Identity.Client.Instance.Discovery; +using AbstractionsCloudKeys = Microsoft.Identity.Abstractions.CloudMetadataKeyNames; +using ICloudMetadataProvider = Microsoft.Identity.Abstractions.ICloudMetadataProvider; +using MsalCloudKeys = Microsoft.Identity.Client.Instance.Discovery.CloudMetadataKeyNames; + +namespace Microsoft.Identity.Web +{ + /// + /// Resolves cloud-specific FIC (Federated Identity Credential) token-exchange values, keyed by the + /// request's authority host, by layering (highest precedence first): + /// + /// an explicit per-call override supplied by the caller; + /// an upstream (from DI) contributed by a caller or an + /// upstream SDK — this is how internal-only sovereign clouds become resolvable; + /// MSAL's built-in public baseline (). + /// + /// + /// + /// + /// This is the single place in ID Web where the audience-vs-scope decision is made, so no call site + /// hand-builds either form: + /// + /// Managed-identity / "resource" contexts use the bare audience + /// (). + /// Client-credentials / app-token contexts use the computed scope (audience + /.default) + /// (). + /// + /// The /.default suffix computation itself is not duplicated here: it is delegated to MSAL's + /// , the single cross-stack owner of that rule, so ID + /// Web and MSAL can never diverge on it. + /// + /// + /// The upstream provider and the MSAL baseline both expose the audience under the same key literal + /// ( equals + /// ), so ID Web "translates" between the two SDKs + /// simply by reading the same literal from whichever source resolves first — no runtime key remapping is + /// needed. + /// + /// + internal static class CloudMetadataResolution + { + /// + /// Documented public-cloud default, used only when there is no authority host to key a lookup off + /// (for example the managed-identity leg, which has no AAD authority). + /// + internal const string DefaultTokenExchangeAudience = "api://AzureADTokenExchange"; + + /// + /// Resolves the bare FIC token-exchange audience (no /.default) for the cloud that owns + /// . Suitable for managed-identity / resource contexts. + /// + /// The request authority or instance (URL or bare host). + /// An optional explicit override that wins over all resolved sources. + /// An optional upstream provider (from DI) contributed by a caller or + /// an upstream SDK. When null, only MSAL's public baseline is consulted. + /// Thrown when a non-empty authority host resolves to no + /// cloud-specific value from any source, so ID Web cannot pick a correct audience for that cloud. + internal static string ResolveTokenExchangeAudience( + string? authorityOrInstance, + string? perCallOverride, + ICloudMetadataProvider? upstreamProvider) + { + // 1. Explicit per-call override always wins. + if (!string.IsNullOrEmpty(perCallOverride)) + { + return perCallOverride!; + } + + string? host = TryGetHost(authorityOrInstance); + + // 2. No authority/instance to key a lookup off (e.g. the managed-identity leg, which has no AAD + // authority): use the documented public-cloud default rather than throwing. + if (string.IsNullOrEmpty(host)) + { + return DefaultTokenExchangeAudience; + } + + // 3. Upstream provider (caller / an upstream SDK, via Abstractions) wins over MSAL's baseline. This + // is how internal-only sovereign clouds — which MSAL does not ship — become resolvable end-to-end. + var upstream = upstreamProvider?.GetByAuthorityHost(host!); + if (upstream is not null + && upstream.TryGetValue(AbstractionsCloudKeys.FederatedCredentialAudience, out string? upstreamAudience) + && !string.IsNullOrEmpty(upstreamAudience)) + { + return upstreamAudience!; + } + + // 4. MSAL public baseline. The same key literal as the upstream provider, so no remap is needed. + var baseline = KnownCloudMetadata.Default.GetByAuthorityHost(host!); + if (baseline is not null + && baseline.TryGetValue(MsalCloudKeys.FederatedCredentialAudience, out string? baselineAudience) + && !string.IsNullOrEmpty(baselineAudience)) + { + return baselineAudience!; + } + + // 5. A non-empty host that nothing recognized: fail loudly rather than silently exchanging against + // the wrong (public-cloud) audience. Sovereign or private clouds are opt-in via a provider. + throw new InvalidOperationException( + $"No cloud-specific federated-credential token-exchange audience is registered for authority host '{host}'. " + + "Register it by calling services.AddCloudMetadata(...), by registering an ICloudMetadataProvider, " + + "or set the token-exchange URL explicitly on the credential (TokenExchangeUrl)."); + } + + /// + /// Resolves the FIC token-exchange scope (bare audience with /.default appended) for the + /// cloud that owns . Suitable for client-credentials / app-token + /// contexts. + /// + /// The request authority or instance (URL or bare host). + /// An optional explicit override that wins over all resolved sources. It + /// may be supplied bare or already suffixed with /.default; the suffix is applied idempotently. + /// An optional upstream provider (from DI) contributed by a caller or + /// an upstream SDK. When null, only MSAL's public baseline is consulted. + internal static string ResolveTokenExchangeScope( + string? authorityOrInstance, + string? perCallOverride, + ICloudMetadataProvider? upstreamProvider) + { + string audience = ResolveTokenExchangeAudience(authorityOrInstance, perCallOverride, upstreamProvider); + + // Delegate the audience→scope (/.default) computation to MSAL's TokenExchangeScope — the single + // cross-stack owner of that rule — so ID Web and MSAL never diverge on the suffix rule. + return TokenExchangeScope.FromAudience(audience); + } + + /// + /// Extracts the host from an authority or instance URL (e.g. + /// https://login.microsoftonline.us/tenant → login.microsoftonline.us). If the value + /// is already a bare host it is returned as-is; null/empty yields null. + /// + internal static string? TryGetHost(string? authorityOrInstance) + { + if (string.IsNullOrEmpty(authorityOrInstance)) + { + return null; + } + + if (Uri.TryCreate(authorityOrInstance, UriKind.Absolute, out Uri? uri)) + { + return uri.Host; + } + + return authorityOrInstance; + } + } +} diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/CloudMetadataResolver.cs b/src/Microsoft.Identity.Web.TokenAcquisition/CloudMetadataResolver.cs deleted file mode 100644 index f3b4b6977..000000000 --- a/src/Microsoft.Identity.Web.TokenAcquisition/CloudMetadataResolver.cs +++ /dev/null @@ -1,199 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Identity.Abstractions; -using Microsoft.Identity.Client.Instance.Discovery; - -namespace Microsoft.Identity.Web -{ - /// - /// Resolves cloud-specific FIC (Federated Identity Credential) token-exchange values, keyed by the - /// request's authority host, by layering (highest precedence first): - /// - /// an explicit per-call override supplied by the caller; - /// an upstream (from DI) contributed by a caller or by an - /// upstream SDK such as MISE — this is how internal-only sovereign clouds become resolvable; - /// MSAL's built-in public cloud baseline (); - /// the documented public-cloud fallback (). - /// - /// - /// - /// - /// This is the single place in ID Web where the audience-vs-scope decision is made, so no call site - /// hand-builds either form: - /// - /// Managed-identity / "resource" contexts use the bare audience - /// (). - /// Client-credentials / app-token contexts use the computed scope (audience + /.default) - /// (). - /// - /// The /.default suffix computation itself is not duplicated here: it is delegated to MSAL's - /// , the single cross-stack owner of - /// that rule, so ID Web and MSAL can never diverge on it. - /// - /// - /// The upstream provider and MSAL baseline both expose the audience under the same key literal - /// ( equals - /// Microsoft.Identity.Client.Instance.Discovery.MsalCloudKeys.TokenExchangeAudience), so ID Web - /// "translates" between the two SDKs simply by reading the same literal from whichever source resolves - /// first — no runtime key remapping is needed. - /// - /// - internal sealed class CloudMetadataResolver - { - /// - /// Documented public-cloud fallback used only when no source resolves the requested authority host - /// (for example an unknown/private cloud with no configured entry). - /// - internal const string DefaultTokenExchangeAudience = "api://AzureADTokenExchange"; - - private readonly ICloudMetadataProvider? _upstreamProvider; - private readonly ILogger? _logger; - private readonly ConcurrentDictionary _warnedHosts = - new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - - /// - /// Initializes a new instance of the class. - /// - /// An optional upstream provider (from DI) contributed by a caller or - /// an upstream SDK (for example MISE). When null, only MSAL's public baseline - /// () and the documented fallback are consulted. - /// An optional logger used to emit a one-time diagnostic when a non-public - /// authority host resolves only to the public-cloud default (a likely missing provider entry). - internal CloudMetadataResolver(ICloudMetadataProvider? upstreamProvider, ILogger? logger = null) - { - _upstreamProvider = upstreamProvider; - _logger = logger; - } - - /// - /// Builds a resolver from the services registered in : an optional - /// upstream (the single override seam) plus an optional logger. - /// MSAL's public baseline is read directly from , so it - /// is not a separate DI seam. - /// - /// The DI service provider. - /// A configured . - internal static CloudMetadataResolver FromServiceProvider(IServiceProvider serviceProvider) - { - return new CloudMetadataResolver( - serviceProvider.GetService(), - serviceProvider.GetService>()); - } - - /// - /// Resolves the bare FIC token-exchange audience (no /.default) for the cloud that owns - /// . Suitable for managed-identity / resource contexts. - /// - /// The request authority or instance (URL or bare host). - /// An optional explicit override that wins over all resolved sources. - internal string ResolveTokenExchangeAudience(string? authorityOrInstance, string? perCallOverride = null) - { - string? resolved = ResolveAudience(authorityOrInstance, perCallOverride); - if (resolved is not null) - { - return resolved; - } - - // No source recognized the host, so fall back to the documented public-cloud default. If the - // caller supplied a (non-public) authority host, surface a one-time warning per host: a sovereign - // or private cloud that resolves only to the public default is almost always a missing - // ICloudMetadataProvider entry rather than intended behavior. - string? host = TryGetHost(authorityOrInstance); - if (_logger != null && !string.IsNullOrEmpty(host) && _warnedHosts.TryAdd(host!, 0)) - { - _logger.LogWarning( - "Microsoft.Identity.Web could not resolve cloud-specific FIC token-exchange metadata for authority host '{AuthorityHost}'; using the public-cloud default audience '{DefaultAudience}'. If this is a sovereign or private cloud, register its metadata via an ICloudMetadataProvider (for example services.AddCloudMetadata(...), or MISE's services.AddMiseCloudMetadata(...)).", - host, - DefaultTokenExchangeAudience); - } - - return DefaultTokenExchangeAudience; - } - - /// - /// Resolves the FIC token-exchange scope (bare audience with /.default appended) for the - /// cloud that owns . Suitable for client-credentials / app-token - /// contexts. - /// - /// The request authority or instance (URL or bare host). - /// An optional explicit override that wins over all resolved sources. It - /// may be supplied bare or already suffixed with /.default; the suffix is applied idempotently. - internal string ResolveTokenExchangeScope(string? authorityOrInstance, string? perCallOverride = null) - { - string audience = ResolveTokenExchangeAudience(authorityOrInstance, perCallOverride); - - // Delegate the audience→scope (/.default) computation to MSAL's CloudSettingsExtensions — the - // single cross-stack owner of that rule — by wrapping the resolved bare audience in a - // CloudSettings. This guarantees ID Web and MSAL never diverge on the suffix rule. - var settings = new CloudSettings( - new Dictionary(1, StringComparer.OrdinalIgnoreCase) - { - [MsalCloudKeys.TokenExchangeAudience] = audience, - }); - - return settings.TokenExchangeScope(); - } - - private string? ResolveAudience(string? authorityOrInstance, string? perCallOverride) - { - // 1. Explicit per-call override always wins. - if (!string.IsNullOrEmpty(perCallOverride)) - { - return perCallOverride; - } - - string? host = TryGetHost(authorityOrInstance); - - // 2. Upstream provider (caller / MISE, via Abstractions) wins over the MSAL baseline. This is how - // internal-only sovereign clouds — which MSAL does not ship — become resolvable end-to-end. - if (!string.IsNullOrEmpty(host)) - { - CloudMetadata? upstream = _upstreamProvider?.GetByAuthorityHost(host!); - if (upstream is not null - && upstream.TryGetValue(AbstractionsCloudKeys.TokenExchangeAudience, out string? upstreamAudience) - && !string.IsNullOrEmpty(upstreamAudience)) - { - return upstreamAudience; - } - } - - // 3. MSAL public baseline, read directly from KnownCloudConfiguration.Default (same key literal as - // the upstream provider — no remap needed). Reading .Default directly keeps the upstream - // ICloudMetadataProvider as the single override seam rather than exposing a second DI seam. - // A null/empty host cannot match any cloud, so short-circuit to keep the resolver fail-soft - // (the caller then applies the documented public-cloud default). - if (string.IsNullOrEmpty(host)) - { - return null; - } - - return KnownCloudConfiguration.Default.GetSettingsByAuthorityHost(host).TokenExchangeAudience(); - } - - /// - /// Extracts the host from an authority or instance URL (e.g. - /// https://login.microsoftonline.us/tenant → login.microsoftonline.us). If the value - /// is already a bare host it is returned as-is; null/empty yields null. - /// - internal static string? TryGetHost(string? authorityOrInstance) - { - if (string.IsNullOrEmpty(authorityOrInstance)) - { - return null; - } - - if (Uri.TryCreate(authorityOrInstance, UriKind.Absolute, out Uri? uri)) - { - return uri.Host; - } - - return authorityOrInstance; - } - } -} diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/CloudMetadataServiceCollectionExtensions.cs b/src/Microsoft.Identity.Web.TokenAcquisition/CloudMetadataServiceCollectionExtensions.cs index 31f1163d0..a83e074c2 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/CloudMetadataServiceCollectionExtensions.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/CloudMetadataServiceCollectionExtensions.cs @@ -21,24 +21,23 @@ public static class CloudMetadataServiceCollectionExtensions /// /// Registers an populated from /// so Microsoft.Identity.Web resolves cloud-specific FIC token-exchange values (for clouds it and MSAL do - /// not know about) from configuration. This is the ID Web counterpart to MISE's - /// AddMiseCloudMetadata(IConfiguration) and uses the same section shape. + /// not know about) from configuration, with no code beyond binding an appsettings.json section. /// /// The service collection to modify. /// A configuration section whose immediate children are authority /// hosts, each of whose children are metadata key/value pairs. For example: /// /// "CloudMetadata": { - /// "login.microsoftonline.us": { "token_exchange_audience": "api://AzureADTokenExchangeUSGov" }, - /// "login.mynewcloud.example": { "token_exchange_audience": "api://AzureADTokenExchangeMyCloud" } + /// "login.microsoftonline.us": { "federated_credential_audience": "api://AzureADTokenExchangeUSGov" }, + /// "login.mynewcloud.example": { "federated_credential_audience": "api://AzureADTokenExchangeMyCloud" } /// } /// - /// Keys should come from . + /// Keys should come from . /// /// The original service collection instance. /// /// The provider is registered with TryAddSingleton, so an a - /// caller (or an upstream SDK such as MISE) registered explicitly beforehand takes precedence and is + /// caller (or an upstream SDK) registered explicitly beforehand takes precedence and is /// left untouched. /// public static IServiceCollection AddCloudMetadata(this IServiceCollection services, IConfiguration cloudMetadataSection) diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/CredentialsProvider.cs b/src/Microsoft.Identity.Web.TokenAcquisition/CredentialsProvider.cs index 7d0ff20e3..469bd53ec 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/CredentialsProvider.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/CredentialsProvider.cs @@ -19,20 +19,20 @@ internal partial class CredentialsProvider : ICredentialsProvider private readonly ITokenAcquisitionHost? _tokenHost; private readonly ICredentialsLoader _credentialsLoader; private readonly IReadOnlyList _certificatesObservers; - private readonly CloudMetadataResolver? _cloudMetadataResolver; + private readonly ICloudMetadataProvider? _cloudMetadataProvider; public CredentialsProvider( ILogger logger, ICredentialsLoader credentialsLoader, IEnumerable certificatesObservers, ITokenAcquisitionHost? tokenHost = null, - CloudMetadataResolver? cloudMetadataResolver = null) + ICloudMetadataProvider? cloudMetadataProvider = null) { _logger = logger; _tokenHost = tokenHost; _credentialsLoader = credentialsLoader; _certificatesObservers = [.. certificatesObservers]; - _cloudMetadataResolver = cloudMetadataResolver; + _cloudMetadataProvider = cloudMetadataProvider; } public Task GetCredentialAsync( @@ -68,16 +68,17 @@ public CredentialsProvider( { // For the managed-identity FIC leg, resolve the cloud-specific token-exchange audience from // the request authority host when the caller did not set one explicitly. This lets sovereign - // (public) and internal-only (via a registered ICloudMetadataProvider, e.g. MISE) clouds use - // the correct audience instead of the public-cloud default. Set only when empty so an - // explicit TokenExchangeUrl always wins; the resolved value is deterministic per authority. - if (_cloudMetadataResolver != null - && credential.SourceType == CredentialSource.SignedAssertionFromManagedIdentity + // (public) and internal-only (via a registered ICloudMetadataProvider) clouds use the correct + // audience instead of the public-cloud default. Set only when empty so an explicit + // TokenExchangeUrl always wins; the resolved value is deterministic per authority. + if (credential.SourceType == CredentialSource.SignedAssertionFromManagedIdentity && string.IsNullOrEmpty(credential.TokenExchangeUrl) && !string.IsNullOrEmpty(credentialSourceLoaderParameters?.Authority)) { - credential.TokenExchangeUrl = _cloudMetadataResolver.ResolveTokenExchangeAudience( - credentialSourceLoaderParameters!.Authority); + credential.TokenExchangeUrl = CloudMetadataResolution.ResolveTokenExchangeAudience( + credentialSourceLoaderParameters!.Authority, + perCallOverride: null, + _cloudMetadataProvider); } // Load the credentials and record error messages in case we need to fail at the end diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/MicrosoftIdentityHttpClientBuilderExtensions.cs b/src/Microsoft.Identity.Web.TokenAcquisition/MicrosoftIdentityHttpClientBuilderExtensions.cs index 430d24227..dbfe3741e 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/MicrosoftIdentityHttpClientBuilderExtensions.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/MicrosoftIdentityHttpClientBuilderExtensions.cs @@ -472,7 +472,8 @@ public static IHttpClientBuilder AddMicrosoftIdentityMessageHandler( GetLogger(services), loader, services.GetServices() ?? [], - services.GetService()); + services.GetService(), + services.GetService()); } return null; diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/ServiceCollectionExtensions.cs b/src/Microsoft.Identity.Web.TokenAcquisition/ServiceCollectionExtensions.cs index e3d6c813d..dcdd6f9bb 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/ServiceCollectionExtensions.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/ServiceCollectionExtensions.cs @@ -52,12 +52,6 @@ public static IServiceCollection AddTokenAcquisition( services.TryAddSingleton(); } - // The resolver layers an optional upstream ICloudMetadataProvider (registered by a caller or by - // MISE — the single override seam) over MSAL's public baseline, which it reads directly from - // KnownCloudConfiguration.Default. It is the single audience-vs-scope decision point shared by all - // FIC legs. Registered via a factory so it observes whichever ICloudMetadataProvider wins TryAdd. - services.TryAddSingleton(sp => CloudMetadataResolver.FromServiceProvider(sp)); - if (!HasImplementationType(services, typeof(MsalMtlsHttpClientFactory))) { services.TryAddSingleton(); diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs index 73b459442..161af728d 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs @@ -88,11 +88,12 @@ class OAuthConstants internal readonly ConcurrentDictionary _agentUserFicAccountIds = new(); /// - /// Resolves the cloud-specific FIC token-exchange audience/scope by authority host. Layers a caller - /// or upstream-SDK (MISE) over MSAL's built-in public baseline, - /// so a replaced or composed provider is honored on all FIC legs that key off an authority. + /// Resolves the cloud-specific FIC token-exchange audience/scope by authority host. An optional + /// caller or upstream-SDK (from DI) is layered over MSAL's + /// built-in public baseline, so a replaced or composed provider is honored on all FIC legs that key + /// off an authority. /// - private readonly CloudMetadataResolver _cloudMetadataResolver; + private readonly ICloudMetadataProvider? _cloudMetadataProvider; private const string TokenBindingParameterName = "IsTokenBinding"; private const int MaxCertificateRetries = 1; @@ -156,12 +157,11 @@ public TokenAcquisition( if (credentialsProvider == null) { var credentialsLoader = serviceProvider.GetService() ?? throw new InvalidOperationException("Either ICredentialsProvider or ICredentialsLoader must be registered and neither were."); - credentialsProvider = new CredentialsProvider(new LogAdapter(logger), credentialsLoader, [.. serviceProvider.GetServices()], tokenAcquisitionHost); + credentialsProvider = new CredentialsProvider(new LogAdapter(logger), credentialsLoader, [.. serviceProvider.GetServices()], tokenAcquisitionHost, serviceProvider.GetService()); } _credentialsProvider = credentialsProvider; - _cloudMetadataResolver = serviceProvider.GetService() - ?? CloudMetadataResolver.FromServiceProvider(serviceProvider); + _cloudMetadataProvider = serviceProvider.GetService(); } #if NET6_0_OR_GREATER @@ -715,7 +715,7 @@ private async Task GetAuthenticationResultForUserInternalA // Resolve the cloud-specific FIC exchange scope from the agent authority host so sovereign // clouds use the correct audience (public cloud is unchanged via the documented fallback). var leg2Builder = agentCca.AcquireTokenForClient( - new[] { _cloudMetadataResolver.ResolveTokenExchangeScope(string.IsNullOrEmpty(mergedOptions.Authority) ? mergedOptions.Instance : mergedOptions.Authority) }); + new[] { CloudMetadataResolution.ResolveTokenExchangeScope(string.IsNullOrEmpty(mergedOptions.Authority) ? mergedOptions.Instance : mergedOptions.Authority, perCallOverride: null, _cloudMetadataProvider) }); if (!string.IsNullOrEmpty(tenantId)) { leg2Builder.WithTenantId(tenantId); @@ -800,7 +800,7 @@ private async Task GetOrBuildAgentUserFicCcaAsyn var leg1Builder = blueprintCca .AcquireTokenForClient( - new[] { _cloudMetadataResolver.ResolveTokenExchangeScope(string.IsNullOrEmpty(blueprintOptions.Authority) ? blueprintOptions.Instance : blueprintOptions.Authority) }) + new[] { CloudMetadataResolution.ResolveTokenExchangeScope(string.IsNullOrEmpty(blueprintOptions.Authority) ? blueprintOptions.Instance : blueprintOptions.Authority, perCallOverride: null, _cloudMetadataProvider) }) .WithFmiPath(agentAppId) .WithSendX5C(blueprintOptions.SendX5C); diff --git a/src/Microsoft.Identity.Web.UI/Microsoft.Identity.Web.UI.xml b/src/Microsoft.Identity.Web.UI/Microsoft.Identity.Web.UI.xml index 684cd4bd7..bdf9463be 100644 --- a/src/Microsoft.Identity.Web.UI/Microsoft.Identity.Web.UI.xml +++ b/src/Microsoft.Identity.Web.UI/Microsoft.Identity.Web.UI.xml @@ -77,6 +77,12 @@ closes. Comparison is case-insensitive because the RFC 3986 encoding is hex-case-insensitive. + + Canonical implementation: RedirectUriHelper.HasPercentEncodedSlashPrefix in + Microsoft.Identity.Web. This private copy exists because RedirectUriHelper + is internal and Microsoft.Identity.Web.UI is a separate assembly. Keep both + in sync. + diff --git a/tests/Microsoft.Identity.Web.Test/CloudMetadataResolutionTests.cs b/tests/Microsoft.Identity.Web.Test/CloudMetadataResolutionTests.cs new file mode 100644 index 000000000..460d5208e --- /dev/null +++ b/tests/Microsoft.Identity.Web.Test/CloudMetadataResolutionTests.cs @@ -0,0 +1,161 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Web; +using Xunit; + +namespace Microsoft.Identity.Web.Test +{ + /// + /// Unit tests for : the ID Web translator that layers a per-call + /// override over an upstream (caller / upstream SDK) over MSAL's + /// public baseline, keyed by authority host. + /// + public class CloudMetadataResolutionTests + { + private const string PublicHost = "login.microsoftonline.com"; + private const string UsGovAuthority = "https://login.microsoftonline.us/tenant"; + private const string UsGovHost = "login.microsoftonline.us"; + private const string NewCloudAuthority = "https://login.partner.example/tenant"; + private const string NewCloudHost = "login.partner.example"; + + [Fact] + public void ResolveAudience_NoAuthorityHost_ReturnsDocumentedDefault() + { + // A null/empty authority (for example the managed-identity leg, which has no AAD authority) + // resolves to the documented public-cloud default rather than throwing. + Assert.Equal( + "api://AzureADTokenExchange", + CloudMetadataResolution.ResolveTokenExchangeAudience(null, perCallOverride: null, upstreamProvider: null)); + Assert.Equal( + "api://AzureADTokenExchange", + CloudMetadataResolution.ResolveTokenExchangeAudience(string.Empty, perCallOverride: null, upstreamProvider: null)); + } + + [Fact] + public void ResolveAudience_UnknownHost_NoProvider_Throws() + { + // W5 decision: a non-empty authority host that nothing recognizes fails loudly rather than + // silently exchanging against the wrong (public-cloud) audience. + InvalidOperationException ex = Assert.Throws(() => + CloudMetadataResolution.ResolveTokenExchangeAudience(NewCloudAuthority, perCallOverride: null, upstreamProvider: null)); + + Assert.Contains(NewCloudHost, ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void ResolveAudience_KnownSovereignHost_NoProvider_ResolvesFromMsalBaseline() + { + string audience = CloudMetadataResolution.ResolveTokenExchangeAudience( + UsGovAuthority, perCallOverride: null, upstreamProvider: null); + + Assert.Equal("api://AzureADTokenExchangeUSGov", audience); + } + + [Fact] + public void ResolveAudience_UpstreamProvider_WinsOverMsalBaseline() + { + // Upstream overrides a cloud MSAL already ships (US Gov). + var provider = new InMemoryCloudMetadataProvider().AddOrUpdate( + UsGovHost, + new Dictionary + { + [CloudMetadataKeyNames.FederatedCredentialAudience] = "api://AzureADTokenExchangeCustomGov", + }); + + string audience = CloudMetadataResolution.ResolveTokenExchangeAudience( + UsGovAuthority, perCallOverride: null, provider); + + Assert.Equal("api://AzureADTokenExchangeCustomGov", audience); + } + + [Fact] + public void ResolveAudience_UpstreamProvider_ResolvesNewCloudUnknownToMsal() + { + // A brand-new cloud MSAL does not ship becomes resolvable via the upstream provider. + var provider = new InMemoryCloudMetadataProvider().AddOrUpdate( + NewCloudHost, + new Dictionary + { + [CloudMetadataKeyNames.FederatedCredentialAudience] = "api://AzureADTokenExchangeMyCloud", + }); + + string audience = CloudMetadataResolution.ResolveTokenExchangeAudience( + NewCloudAuthority, perCallOverride: null, provider); + + Assert.Equal("api://AzureADTokenExchangeMyCloud", audience); + } + + [Fact] + public void ResolveAudience_PerCallOverride_WinsOverProviderAndBaseline() + { + var provider = new InMemoryCloudMetadataProvider().AddOrUpdate( + UsGovHost, + new Dictionary + { + [CloudMetadataKeyNames.FederatedCredentialAudience] = "api://AzureADTokenExchangeCustomGov", + }); + + string audience = CloudMetadataResolution.ResolveTokenExchangeAudience( + UsGovAuthority, perCallOverride: "api://PerCallAudience", provider); + + Assert.Equal("api://PerCallAudience", audience); + } + + [Fact] + public void ResolveScope_AppendsDefaultSuffix_Idempotently() + { + string fromBare = CloudMetadataResolution.ResolveTokenExchangeScope( + PublicHost, perCallOverride: "api://Aud", upstreamProvider: null); + string fromSuffixed = CloudMetadataResolution.ResolveTokenExchangeScope( + PublicHost, perCallOverride: "api://Aud/.default", upstreamProvider: null); + + Assert.Equal("api://Aud/.default", fromBare); + Assert.Equal("api://Aud/.default", fromSuffixed); + } + + [Fact] + public void ResolveScope_KnownSovereignHost_ComputesScopeFromBaseline() + { + string scope = CloudMetadataResolution.ResolveTokenExchangeScope( + UsGovAuthority, perCallOverride: null, upstreamProvider: null); + + Assert.Equal("api://AzureADTokenExchangeUSGov/.default", scope); + } + + [Fact] + public void AddCloudMetadata_FromConfiguration_RegistersResolvableProvider() + { + // A new cloud MSAL/ID Web do not ship, supplied entirely from an appsettings-style section. + IConfiguration config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [$"CloudMetadata:{NewCloudHost}:{CloudMetadataKeyNames.FederatedCredentialAudience}"] = "api://AzureADTokenExchangeMyCloud", + }) + .Build(); + var services = new ServiceCollection(); + + // Act + services.AddCloudMetadata(config.GetSection("CloudMetadata")); + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + + // Assert: the provider is registered and drives resolution end-to-end (audience + scope). + var provider = serviceProvider.GetRequiredService(); + Assert.Equal( + "api://AzureADTokenExchangeMyCloud", + provider.GetByAuthorityHost(NewCloudHost)![CloudMetadataKeyNames.FederatedCredentialAudience]); + + Assert.Equal( + "api://AzureADTokenExchangeMyCloud", + CloudMetadataResolution.ResolveTokenExchangeAudience(NewCloudAuthority, perCallOverride: null, provider)); + Assert.Equal( + "api://AzureADTokenExchangeMyCloud/.default", + CloudMetadataResolution.ResolveTokenExchangeScope(NewCloudAuthority, perCallOverride: null, provider)); + } + } +} diff --git a/tests/Microsoft.Identity.Web.Test/CloudMetadataResolverTests.cs b/tests/Microsoft.Identity.Web.Test/CloudMetadataResolverTests.cs deleted file mode 100644 index 6e6b4b970..000000000 --- a/tests/Microsoft.Identity.Web.Test/CloudMetadataResolverTests.cs +++ /dev/null @@ -1,228 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using System.Collections.Generic; -using System.Linq; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Identity.Abstractions; -using Microsoft.Identity.Web; -using NSubstitute; -using Xunit; - -namespace Microsoft.Identity.Web.Test -{ - /// - /// Unit tests for : the ID Web translator that layers a per-call - /// override over an upstream (caller / MISE) over MSAL's public - /// baseline, keyed by authority host. - /// - public class CloudMetadataResolverTests - { - private const string PublicHost = "login.microsoftonline.com"; - private const string UsGovAuthority = "https://login.microsoftonline.us/tenant"; - private const string UsGovHost = "login.microsoftonline.us"; - private const string NewCloudAuthority = "https://login.partner.example/tenant"; - private const string NewCloudHost = "login.partner.example"; - - [Fact] - public void ResolveAudience_UnknownHost_NoProvider_ReturnsDocumentedDefault() - { - // Arrange - var resolver = new CloudMetadataResolver(upstreamProvider: null); - - // Act - string audience = resolver.ResolveTokenExchangeAudience(NewCloudAuthority); - - // Assert - Assert.Equal("api://AzureADTokenExchange", audience); - } - - [Fact] - public void ResolveAudience_KnownSovereignHost_NoProvider_ResolvesFromMsalBaseline() - { - // Arrange - var resolver = new CloudMetadataResolver(upstreamProvider: null); - - // Act - string audience = resolver.ResolveTokenExchangeAudience(UsGovAuthority); - - // Assert - Assert.Equal("api://AzureADTokenExchangeUSGov", audience); - } - - [Fact] - public void ResolveAudience_UpstreamProvider_WinsOverMsalBaseline() - { - // Arrange: upstream overrides a cloud MSAL already ships (US Gov). - var provider = new InMemoryCloudMetadataProvider().AddOrUpdate( - UsGovHost, - new Dictionary - { - [AbstractionsCloudKeys.TokenExchangeAudience] = "api://AzureADTokenExchangeCustomGov", - }); - var resolver = new CloudMetadataResolver(provider); - - // Act - string audience = resolver.ResolveTokenExchangeAudience(UsGovAuthority); - - // Assert - Assert.Equal("api://AzureADTokenExchangeCustomGov", audience); - } - - [Fact] - public void ResolveAudience_UpstreamProvider_ResolvesNewCloudUnknownToMsal() - { - // Arrange: a brand-new cloud MSAL does not ship becomes resolvable via the upstream provider. - var provider = new InMemoryCloudMetadataProvider().AddOrUpdate( - NewCloudHost, - new Dictionary - { - [AbstractionsCloudKeys.TokenExchangeAudience] = "api://AzureADTokenExchangeMyCloud", - }); - var resolver = new CloudMetadataResolver(provider); - - // Act - string audience = resolver.ResolveTokenExchangeAudience(NewCloudAuthority); - - // Assert - Assert.Equal("api://AzureADTokenExchangeMyCloud", audience); - } - - [Fact] - public void ResolveAudience_PerCallOverride_WinsOverProviderAndBaseline() - { - // Arrange - var provider = new InMemoryCloudMetadataProvider().AddOrUpdate( - UsGovHost, - new Dictionary - { - [AbstractionsCloudKeys.TokenExchangeAudience] = "api://AzureADTokenExchangeCustomGov", - }); - var resolver = new CloudMetadataResolver(provider); - - // Act - string audience = resolver.ResolveTokenExchangeAudience(UsGovAuthority, perCallOverride: "api://PerCallAudience"); - - // Assert - Assert.Equal("api://PerCallAudience", audience); - } - - [Fact] - public void ResolveScope_AppendsDefaultSuffix_Idempotently() - { - // Arrange - var resolver = new CloudMetadataResolver(upstreamProvider: null); - - // Act - string fromBare = resolver.ResolveTokenExchangeScope(PublicHost, perCallOverride: "api://Aud"); - string fromSuffixed = resolver.ResolveTokenExchangeScope(PublicHost, perCallOverride: "api://Aud/.default"); - - // Assert - Assert.Equal("api://Aud/.default", fromBare); - Assert.Equal("api://Aud/.default", fromSuffixed); - } - - [Fact] - public void ResolveScope_KnownSovereignHost_ComputesScopeFromBaseline() - { - // Arrange - var resolver = new CloudMetadataResolver(upstreamProvider: null); - - // Act - string scope = resolver.ResolveTokenExchangeScope(UsGovAuthority); - - // Assert - Assert.Equal("api://AzureADTokenExchangeUSGov/.default", scope); - } - - [Fact] - public void FromServiceProvider_HonorsRegisteredUpstreamProvider() - { - // Arrange - var provider = new InMemoryCloudMetadataProvider().AddOrUpdate( - NewCloudHost, - new Dictionary - { - [AbstractionsCloudKeys.TokenExchangeAudience] = "api://AzureADTokenExchangeMyCloud", - }); - var services = new ServiceCollection(); - services.AddSingleton(provider); - using ServiceProvider serviceProvider = services.BuildServiceProvider(); - - // Act - CloudMetadataResolver resolver = CloudMetadataResolver.FromServiceProvider(serviceProvider); - - // Assert - Assert.Equal("api://AzureADTokenExchangeMyCloud", resolver.ResolveTokenExchangeAudience(NewCloudAuthority)); - Assert.Equal("api://AzureADTokenExchangeUSGov", resolver.ResolveTokenExchangeAudience(UsGovAuthority)); - } - - [Fact] - public void AddCloudMetadata_FromConfiguration_RegistersResolvableProvider() - { - // Arrange: a new cloud MSAL/ID Web do not ship, supplied entirely from an appsettings-style section. - IConfiguration config = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - [$"CloudMetadata:{NewCloudHost}:{AbstractionsCloudKeys.TokenExchangeAudience}"] = "api://AzureADTokenExchangeMyCloud", - }) - .Build(); - var services = new ServiceCollection(); - - // Act - services.AddCloudMetadata(config.GetSection("CloudMetadata")); - using ServiceProvider serviceProvider = services.BuildServiceProvider(); - - // Assert: the provider is registered and the resolver honors it end-to-end (audience + scope). - var provider = serviceProvider.GetRequiredService(); - Assert.Equal( - "api://AzureADTokenExchangeMyCloud", - provider.GetByAuthorityHost(NewCloudHost)!.GetValueOrDefault(AbstractionsCloudKeys.TokenExchangeAudience)); - - CloudMetadataResolver resolver = CloudMetadataResolver.FromServiceProvider(serviceProvider); - Assert.Equal("api://AzureADTokenExchangeMyCloud", resolver.ResolveTokenExchangeAudience(NewCloudAuthority)); - Assert.Equal("api://AzureADTokenExchangeMyCloud/.default", resolver.ResolveTokenExchangeScope(NewCloudAuthority)); - } - - [Fact] - public void ResolveAudience_UnknownNonPublicHost_WithLogger_WarnsOncePerHost() - { - // Arrange - var logger = Substitute.For>(); - var resolver = new CloudMetadataResolver(upstreamProvider: null, logger); - - // Act: an unrecognized (non-public) host falls back to the documented public default... - string audience = resolver.ResolveTokenExchangeAudience(NewCloudAuthority); - // ...and a second lookup for the same host must not warn again (deduped). - resolver.ResolveTokenExchangeAudience(NewCloudAuthority); - - // Assert - Assert.Equal("api://AzureADTokenExchange", audience); - Assert.Equal(1, CountWarnings(logger)); - } - - [Fact] - public void ResolveAudience_PublicOrKnownHost_WithLogger_DoesNotWarn() - { - // Arrange - var logger = Substitute.For>(); - var resolver = new CloudMetadataResolver(upstreamProvider: null, logger); - - // Act: public cloud and a sovereign cloud MSAL ships both resolve without a fallback warning. - resolver.ResolveTokenExchangeAudience("https://login.microsoftonline.com/tenant"); - resolver.ResolveTokenExchangeAudience(UsGovAuthority); - - // Assert - Assert.Equal(0, CountWarnings(logger)); - } - - private static int CountWarnings(ILogger logger) => - logger.ReceivedCalls().Count(call => - { - object?[] args = call.GetArguments(); - return args.Length > 0 && args[0] is LogLevel level && level == LogLevel.Warning; - }); - } -} diff --git a/tests/Microsoft.Identity.Web.Test/FederatedIdentityCaeTests.cs b/tests/Microsoft.Identity.Web.Test/FederatedIdentityCaeTests.cs index 39a528d24..eb83d7265 100644 --- a/tests/Microsoft.Identity.Web.Test/FederatedIdentityCaeTests.cs +++ b/tests/Microsoft.Identity.Web.Test/FederatedIdentityCaeTests.cs @@ -298,7 +298,7 @@ public async Task Fic_CustomSignedAssertion_HonorsInjectedCloudMetadataProvider( "login.microsoftonline.us", new Dictionary { - [AbstractionsCloudKeys.TokenExchangeAudience] = "api://AzureADTokenExchangeCustomGov" + [CloudMetadataKeyNames.FederatedCredentialAudience] = "api://AzureADTokenExchangeCustomGov" })); // Source app (provides assertion), configured on US Gov. diff --git a/tests/Microsoft.Identity.Web.Test/ManagedIdentityClientAssertionResolutionTests.cs b/tests/Microsoft.Identity.Web.Test/ManagedIdentityClientAssertionResolutionTests.cs new file mode 100644 index 000000000..7705127ca --- /dev/null +++ b/tests/Microsoft.Identity.Web.Test/ManagedIdentityClientAssertionResolutionTests.cs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Identity.Client; +using Microsoft.Identity.Web.Test.Common; +using Microsoft.Identity.Web.Test.Common.Mocks; +using Microsoft.Identity.Web.TestOnly; +using Xunit; + +namespace Microsoft.Identity.Web.Tests.Certificateless +{ + /// + /// Verifies that auto-resolves the FIC token-exchange + /// audience from the calling confidential client's authority host (flowed in via + /// ), so a single instance emits the correct + /// per-cloud audience, while an explicit audience always wins. + /// + [Collection(nameof(TokenAcquirerFactorySingletonProtection))] + public class ManagedIdentityClientAssertionResolutionTests + { + private const string UamiClientId = "04ca4d6a-c720-4ba1-aa06-f6634b73fe7a"; + + [Theory] + [InlineData("https://login.microsoftonline.com/tenant", "api://AzureADTokenExchange")] + [InlineData("https://login.microsoftonline.us/tenant", "api://AzureADTokenExchangeUSGov")] + [InlineData("https://login.partner.microsoftonline.cn/tenant", "api://AzureADTokenExchangeChina")] + public async Task GetSignedAssertion_AutoResolvesAudienceFromRequestAuthorityAsync( + string authority, string expectedAudience) + { + MockHttpMessageHandler handler = await AcquireAssertionAsync( + explicitAudience: null, + authority: authority); + + AssertResource(handler, expectedAudience); + } + + [Fact] + public async Task GetSignedAssertion_ExplicitAudience_WinsOverRequestAuthorityAsync() + { + // Even for a US Gov authority, an explicit audience supplied to the constructor takes precedence. + MockHttpMessageHandler handler = await AcquireAssertionAsync( + explicitAudience: "api://MyCustomTokenExchange", + authority: "https://login.microsoftonline.us/tenant"); + + AssertResource(handler, "api://MyCustomTokenExchange"); + } + + [Fact] + public async Task GetSignedAssertion_NoAuthority_FallsBackToPublicAudienceAsync() + { + // The MISE managed-identity leg invokes the assertion in isolation (no authority); it must + // fall back to the public-cloud audience rather than throwing. + MockHttpMessageHandler handler = await AcquireAssertionAsync( + explicitAudience: null, + authority: null); + + AssertResource(handler, "api://AzureADTokenExchange"); + } + + private static async Task AcquireAssertionAsync( + string? explicitAudience, string? authority) + { + var mockMiHttp = new MockHttpClientFactory(); + MockHttpMessageHandler handler = mockMiHttp.AddMockHandler( + MockHttpCreator.CreateMsiTokenHandler("mi-assertion-token")); + var miTestFactory = new Microsoft.Identity.Web.Test.TestManagedIdentityHttpFactory(mockMiHttp); + ManagedIdentityClientAssertionTestHook.HttpClientFactoryForTests = miTestFactory.Create(); + + try + { + var assertion = new ManagedIdentityClientAssertion(UamiClientId, explicitAudience); + await assertion.GetSignedAssertionAsync(new AssertionRequestOptions + { + Authority = authority!, + // Non-empty claims force MSAL to bypass the (process-shared) managed-identity token + // cache, so every case issues a fresh IMDS request we can inspect. + Claims = "{\"access_token\":{}}", + CancellationToken = CancellationToken.None, + }).ConfigureAwait(false); + + return handler; + } + finally + { + ManagedIdentityClientAssertionTestHook.HttpClientFactoryForTests = null; + } + } + + private static void AssertResource(MockHttpMessageHandler handler, string expectedAudience) + { + Assert.NotNull(handler.ActualRequestMessage); + string query = handler.ActualRequestMessage.RequestUri!.Query; + Assert.Contains("resource=" + expectedAudience, query, StringComparison.OrdinalIgnoreCase); + } + } +} From 1fe3a6b0d7814078c96cdd814844567d865db458 Mon Sep 17 00:00:00 2001 From: avdunn Date: Mon, 24 Aug 2026 10:57:04 -0700 Subject: [PATCH 07/14] Simplify token exchange resolution --- .../OidcIdpSignedAssertionProvider.cs | 14 +++--- .../CredentialsProvider.cs | 2 +- ...=> FederatedCredentialAudienceResolver.cs} | 44 ++++--------------- .../TokenAcquisition.cs | 4 +- ...deratedCredentialAudienceResolverTests.cs} | 44 +++++++++++-------- 5 files changed, 44 insertions(+), 64 deletions(-) rename src/Microsoft.Identity.Web.TokenAcquisition/{CloudMetadataResolution.cs => FederatedCredentialAudienceResolver.cs} (70%) rename tests/Microsoft.Identity.Web.Test/{CloudMetadataResolutionTests.cs => FederatedCredentialAudienceResolverTests.cs} (68%) diff --git a/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionProvider.cs b/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionProvider.cs index a66537886..46e57d2e2 100644 --- a/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionProvider.cs +++ b/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionProvider.cs @@ -156,14 +156,16 @@ private async Task AcquireOidcAssertionResultAsync( _tokenAcquirer ??= _tokenAcquirerFactory.GetTokenAcquirer(_options); // Precedence: an explicitly configured TokenExchangeUrl wins (as a per-call override); otherwise - // resolve the cloud-specific exchange scope from the layered resolver — a caller's + // resolve the cloud-specific exchange audience from the layered resolver — a caller's // ICloudMetadataProvider (from DI) over MSAL's public baseline — keyed by this application's // instance host (public cloud is unchanged via the documented fallback). The scope form - // (/.default) is used because this is a client-credentials / app-token acquisition. - string effectiveTokenExchangeUrl = CloudMetadataResolution.ResolveTokenExchangeScope( - string.IsNullOrEmpty(_options.Instance) ? _options.Authority : _options.Instance, - perCallOverride: _tokenExchangeUrl, - _cloudMetadataProvider); + // (/.default) is applied via MSAL's TokenExchangeScope because this is a client-credentials / + // app-token acquisition. + string effectiveTokenExchangeUrl = Microsoft.Identity.Client.Instance.Discovery.TokenExchangeScope.FromAudience( + FederatedCredentialAudienceResolver.ResolveTokenExchangeAudience( + string.IsNullOrEmpty(_options.Instance) ? _options.Authority : _options.Instance, + perCallOverride: _tokenExchangeUrl, + _cloudMetadataProvider)); string? fmiPath = assertionRequestOptions?.ClientAssertionFmiPath; diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/CredentialsProvider.cs b/src/Microsoft.Identity.Web.TokenAcquisition/CredentialsProvider.cs index 469bd53ec..5c3ffbbe7 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/CredentialsProvider.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/CredentialsProvider.cs @@ -75,7 +75,7 @@ public CredentialsProvider( && string.IsNullOrEmpty(credential.TokenExchangeUrl) && !string.IsNullOrEmpty(credentialSourceLoaderParameters?.Authority)) { - credential.TokenExchangeUrl = CloudMetadataResolution.ResolveTokenExchangeAudience( + credential.TokenExchangeUrl = FederatedCredentialAudienceResolver.ResolveTokenExchangeAudience( credentialSourceLoaderParameters!.Authority, perCallOverride: null, _cloudMetadataProvider); diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/CloudMetadataResolution.cs b/src/Microsoft.Identity.Web.TokenAcquisition/FederatedCredentialAudienceResolver.cs similarity index 70% rename from src/Microsoft.Identity.Web.TokenAcquisition/CloudMetadataResolution.cs rename to src/Microsoft.Identity.Web.TokenAcquisition/FederatedCredentialAudienceResolver.cs index a7f93cec6..de3f47baf 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/CloudMetadataResolution.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/FederatedCredentialAudienceResolver.cs @@ -10,8 +10,8 @@ namespace Microsoft.Identity.Web { /// - /// Resolves cloud-specific FIC (Federated Identity Credential) token-exchange values, keyed by the - /// request's authority host, by layering (highest precedence first): + /// Resolves the cloud-specific FIC (Federated Identity Credential) token-exchange audience, keyed by + /// the request's authority host, by layering (highest precedence first): /// /// an explicit per-call override supplied by the caller; /// an upstream (from DI) contributed by a caller or an @@ -21,17 +21,11 @@ namespace Microsoft.Identity.Web /// /// /// - /// This is the single place in ID Web where the audience-vs-scope decision is made, so no call site - /// hand-builds either form: - /// - /// Managed-identity / "resource" contexts use the bare audience - /// (). - /// Client-credentials / app-token contexts use the computed scope (audience + /.default) - /// (). - /// - /// The /.default suffix computation itself is not duplicated here: it is delegated to MSAL's - /// , the single cross-stack owner of that rule, so ID - /// Web and MSAL can never diverge on it. + /// The resolved value is always the bare audience (no /.default). Managed-identity / + /// "resource" contexts use it as-is; client-credentials / app-token contexts append the /.default + /// suffix at the call site via MSAL's — the single + /// cross-stack owner of that rule — so ID Web and MSAL can never diverge on it and the suffix decision is + /// never duplicated here. /// /// /// The upstream provider and the MSAL baseline both expose the audience under the same key literal @@ -41,7 +35,7 @@ namespace Microsoft.Identity.Web /// needed. /// /// - internal static class CloudMetadataResolution + internal static class FederatedCredentialAudienceResolver { /// /// Documented public-cloud default, used only when there is no authority host to key a lookup off @@ -106,28 +100,6 @@ internal static string ResolveTokenExchangeAudience( "or set the token-exchange URL explicitly on the credential (TokenExchangeUrl)."); } - /// - /// Resolves the FIC token-exchange scope (bare audience with /.default appended) for the - /// cloud that owns . Suitable for client-credentials / app-token - /// contexts. - /// - /// The request authority or instance (URL or bare host). - /// An optional explicit override that wins over all resolved sources. It - /// may be supplied bare or already suffixed with /.default; the suffix is applied idempotently. - /// An optional upstream provider (from DI) contributed by a caller or - /// an upstream SDK. When null, only MSAL's public baseline is consulted. - internal static string ResolveTokenExchangeScope( - string? authorityOrInstance, - string? perCallOverride, - ICloudMetadataProvider? upstreamProvider) - { - string audience = ResolveTokenExchangeAudience(authorityOrInstance, perCallOverride, upstreamProvider); - - // Delegate the audience→scope (/.default) computation to MSAL's TokenExchangeScope — the single - // cross-stack owner of that rule — so ID Web and MSAL never diverge on the suffix rule. - return TokenExchangeScope.FromAudience(audience); - } - /// /// Extracts the host from an authority or instance URL (e.g. /// https://login.microsoftonline.us/tenant → login.microsoftonline.us). If the value diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs index 161af728d..e30aa1b91 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs @@ -715,7 +715,7 @@ private async Task GetAuthenticationResultForUserInternalA // Resolve the cloud-specific FIC exchange scope from the agent authority host so sovereign // clouds use the correct audience (public cloud is unchanged via the documented fallback). var leg2Builder = agentCca.AcquireTokenForClient( - new[] { CloudMetadataResolution.ResolveTokenExchangeScope(string.IsNullOrEmpty(mergedOptions.Authority) ? mergedOptions.Instance : mergedOptions.Authority, perCallOverride: null, _cloudMetadataProvider) }); + new[] { Microsoft.Identity.Client.Instance.Discovery.TokenExchangeScope.FromAudience(FederatedCredentialAudienceResolver.ResolveTokenExchangeAudience(string.IsNullOrEmpty(mergedOptions.Authority) ? mergedOptions.Instance : mergedOptions.Authority, perCallOverride: null, _cloudMetadataProvider)) }); if (!string.IsNullOrEmpty(tenantId)) { leg2Builder.WithTenantId(tenantId); @@ -800,7 +800,7 @@ private async Task GetOrBuildAgentUserFicCcaAsyn var leg1Builder = blueprintCca .AcquireTokenForClient( - new[] { CloudMetadataResolution.ResolveTokenExchangeScope(string.IsNullOrEmpty(blueprintOptions.Authority) ? blueprintOptions.Instance : blueprintOptions.Authority, perCallOverride: null, _cloudMetadataProvider) }) + new[] { Microsoft.Identity.Client.Instance.Discovery.TokenExchangeScope.FromAudience(FederatedCredentialAudienceResolver.ResolveTokenExchangeAudience(string.IsNullOrEmpty(blueprintOptions.Authority) ? blueprintOptions.Instance : blueprintOptions.Authority, perCallOverride: null, _cloudMetadataProvider)) }) .WithFmiPath(agentAppId) .WithSendX5C(blueprintOptions.SendX5C); diff --git a/tests/Microsoft.Identity.Web.Test/CloudMetadataResolutionTests.cs b/tests/Microsoft.Identity.Web.Test/FederatedCredentialAudienceResolverTests.cs similarity index 68% rename from tests/Microsoft.Identity.Web.Test/CloudMetadataResolutionTests.cs rename to tests/Microsoft.Identity.Web.Test/FederatedCredentialAudienceResolverTests.cs index 460d5208e..99e473ee4 100644 --- a/tests/Microsoft.Identity.Web.Test/CloudMetadataResolutionTests.cs +++ b/tests/Microsoft.Identity.Web.Test/FederatedCredentialAudienceResolverTests.cs @@ -12,11 +12,11 @@ namespace Microsoft.Identity.Web.Test { /// - /// Unit tests for : the ID Web translator that layers a per-call + /// Unit tests for : the ID Web translator that layers a per-call /// override over an upstream (caller / upstream SDK) over MSAL's /// public baseline, keyed by authority host. /// - public class CloudMetadataResolutionTests + public class FederatedCredentialAudienceResolverTests { private const string PublicHost = "login.microsoftonline.com"; private const string UsGovAuthority = "https://login.microsoftonline.us/tenant"; @@ -31,10 +31,10 @@ public void ResolveAudience_NoAuthorityHost_ReturnsDocumentedDefault() // resolves to the documented public-cloud default rather than throwing. Assert.Equal( "api://AzureADTokenExchange", - CloudMetadataResolution.ResolveTokenExchangeAudience(null, perCallOverride: null, upstreamProvider: null)); + FederatedCredentialAudienceResolver.ResolveTokenExchangeAudience(null, perCallOverride: null, upstreamProvider: null)); Assert.Equal( "api://AzureADTokenExchange", - CloudMetadataResolution.ResolveTokenExchangeAudience(string.Empty, perCallOverride: null, upstreamProvider: null)); + FederatedCredentialAudienceResolver.ResolveTokenExchangeAudience(string.Empty, perCallOverride: null, upstreamProvider: null)); } [Fact] @@ -43,7 +43,7 @@ public void ResolveAudience_UnknownHost_NoProvider_Throws() // W5 decision: a non-empty authority host that nothing recognizes fails loudly rather than // silently exchanging against the wrong (public-cloud) audience. InvalidOperationException ex = Assert.Throws(() => - CloudMetadataResolution.ResolveTokenExchangeAudience(NewCloudAuthority, perCallOverride: null, upstreamProvider: null)); + FederatedCredentialAudienceResolver.ResolveTokenExchangeAudience(NewCloudAuthority, perCallOverride: null, upstreamProvider: null)); Assert.Contains(NewCloudHost, ex.Message, StringComparison.Ordinal); } @@ -51,7 +51,7 @@ public void ResolveAudience_UnknownHost_NoProvider_Throws() [Fact] public void ResolveAudience_KnownSovereignHost_NoProvider_ResolvesFromMsalBaseline() { - string audience = CloudMetadataResolution.ResolveTokenExchangeAudience( + string audience = FederatedCredentialAudienceResolver.ResolveTokenExchangeAudience( UsGovAuthority, perCallOverride: null, upstreamProvider: null); Assert.Equal("api://AzureADTokenExchangeUSGov", audience); @@ -68,7 +68,7 @@ public void ResolveAudience_UpstreamProvider_WinsOverMsalBaseline() [CloudMetadataKeyNames.FederatedCredentialAudience] = "api://AzureADTokenExchangeCustomGov", }); - string audience = CloudMetadataResolution.ResolveTokenExchangeAudience( + string audience = FederatedCredentialAudienceResolver.ResolveTokenExchangeAudience( UsGovAuthority, perCallOverride: null, provider); Assert.Equal("api://AzureADTokenExchangeCustomGov", audience); @@ -85,7 +85,7 @@ public void ResolveAudience_UpstreamProvider_ResolvesNewCloudUnknownToMsal() [CloudMetadataKeyNames.FederatedCredentialAudience] = "api://AzureADTokenExchangeMyCloud", }); - string audience = CloudMetadataResolution.ResolveTokenExchangeAudience( + string audience = FederatedCredentialAudienceResolver.ResolveTokenExchangeAudience( NewCloudAuthority, perCallOverride: null, provider); Assert.Equal("api://AzureADTokenExchangeMyCloud", audience); @@ -101,29 +101,34 @@ public void ResolveAudience_PerCallOverride_WinsOverProviderAndBaseline() [CloudMetadataKeyNames.FederatedCredentialAudience] = "api://AzureADTokenExchangeCustomGov", }); - string audience = CloudMetadataResolution.ResolveTokenExchangeAudience( + string audience = FederatedCredentialAudienceResolver.ResolveTokenExchangeAudience( UsGovAuthority, perCallOverride: "api://PerCallAudience", provider); Assert.Equal("api://PerCallAudience", audience); } [Fact] - public void ResolveScope_AppendsDefaultSuffix_Idempotently() + public void ScopeProjection_AppendsDefaultSuffix_Idempotently() { - string fromBare = CloudMetadataResolution.ResolveTokenExchangeScope( - PublicHost, perCallOverride: "api://Aud", upstreamProvider: null); - string fromSuffixed = CloudMetadataResolution.ResolveTokenExchangeScope( - PublicHost, perCallOverride: "api://Aud/.default", upstreamProvider: null); + // Call sites that feed an OAuth2 scope-array API project the resolved bare audience to a scope + // via MSAL's TokenExchangeScope.FromAudience (the single cross-stack owner of the /.default rule). + string fromBare = Microsoft.Identity.Client.Instance.Discovery.TokenExchangeScope.FromAudience( + FederatedCredentialAudienceResolver.ResolveTokenExchangeAudience( + PublicHost, perCallOverride: "api://Aud", upstreamProvider: null)); + string fromSuffixed = Microsoft.Identity.Client.Instance.Discovery.TokenExchangeScope.FromAudience( + FederatedCredentialAudienceResolver.ResolveTokenExchangeAudience( + PublicHost, perCallOverride: "api://Aud/.default", upstreamProvider: null)); Assert.Equal("api://Aud/.default", fromBare); Assert.Equal("api://Aud/.default", fromSuffixed); } [Fact] - public void ResolveScope_KnownSovereignHost_ComputesScopeFromBaseline() + public void ScopeProjection_KnownSovereignHost_ComputesScopeFromBaseline() { - string scope = CloudMetadataResolution.ResolveTokenExchangeScope( - UsGovAuthority, perCallOverride: null, upstreamProvider: null); + string scope = Microsoft.Identity.Client.Instance.Discovery.TokenExchangeScope.FromAudience( + FederatedCredentialAudienceResolver.ResolveTokenExchangeAudience( + UsGovAuthority, perCallOverride: null, upstreamProvider: null)); Assert.Equal("api://AzureADTokenExchangeUSGov/.default", scope); } @@ -152,10 +157,11 @@ public void AddCloudMetadata_FromConfiguration_RegistersResolvableProvider() Assert.Equal( "api://AzureADTokenExchangeMyCloud", - CloudMetadataResolution.ResolveTokenExchangeAudience(NewCloudAuthority, perCallOverride: null, provider)); + FederatedCredentialAudienceResolver.ResolveTokenExchangeAudience(NewCloudAuthority, perCallOverride: null, provider)); Assert.Equal( "api://AzureADTokenExchangeMyCloud/.default", - CloudMetadataResolution.ResolveTokenExchangeScope(NewCloudAuthority, perCallOverride: null, provider)); + Microsoft.Identity.Client.Instance.Discovery.TokenExchangeScope.FromAudience( + FederatedCredentialAudienceResolver.ResolveTokenExchangeAudience(NewCloudAuthority, perCallOverride: null, provider))); } } } From 108cbec6723758b8668dcfa80f6c57a05298c370 Mon Sep 17 00:00:00 2001 From: avdunn Date: Mon, 24 Aug 2026 11:25:19 -0700 Subject: [PATCH 08/14] PR feedback --- .../CredentialsProvider.cs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/CredentialsProvider.cs b/src/Microsoft.Identity.Web.TokenAcquisition/CredentialsProvider.cs index 5c3ffbbe7..2ef105f3b 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/CredentialsProvider.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/CredentialsProvider.cs @@ -60,8 +60,9 @@ public CredentialsProvider( string errorMessage = "\n"; List? exceptions = null; - foreach (CredentialDescription credential in clientCredentials) + foreach (CredentialDescription originalCredential in clientCredentials) { + CredentialDescription credential = originalCredential; LogMessages.AttemptToLoadCredentials(_logger, credential); if (!credential.Skip) @@ -69,16 +70,22 @@ public CredentialsProvider( // For the managed-identity FIC leg, resolve the cloud-specific token-exchange audience from // the request authority host when the caller did not set one explicitly. This lets sovereign // (public) and internal-only (via a registered ICloudMetadataProvider) clouds use the correct - // audience instead of the public-cloud default. Set only when empty so an explicit + // audience instead of the public-cloud default. Resolve onto a per-request copy so we never + // mutate the shared CredentialDescription (which is typically cached and reused across + // requests): mutating it in place is not thread-safe and would leak one cloud's audience into + // a subsequent request for a different cloud. Resolve only when empty so an explicit // TokenExchangeUrl always wins; the resolved value is deterministic per authority. if (credential.SourceType == CredentialSource.SignedAssertionFromManagedIdentity && string.IsNullOrEmpty(credential.TokenExchangeUrl) && !string.IsNullOrEmpty(credentialSourceLoaderParameters?.Authority)) { - credential.TokenExchangeUrl = FederatedCredentialAudienceResolver.ResolveTokenExchangeAudience( - credentialSourceLoaderParameters!.Authority, - perCallOverride: null, - _cloudMetadataProvider); + credential = new CredentialDescription(originalCredential) + { + TokenExchangeUrl = FederatedCredentialAudienceResolver.ResolveTokenExchangeAudience( + credentialSourceLoaderParameters!.Authority, + perCallOverride: null, + _cloudMetadataProvider), + }; } // Load the credentials and record error messages in case we need to fail at the end From 82918e55302fc313a438a59287e2340c13244228 Mon Sep 17 00:00:00 2001 From: Avery-Dunn <62066438+Avery-Dunn@users.noreply.github.com> Date: Tue, 25 Aug 2026 07:46:24 -0700 Subject: [PATCH 09/14] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tests/Microsoft.Identity.Web.Test/FederatedIdentityCaeTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Microsoft.Identity.Web.Test/FederatedIdentityCaeTests.cs b/tests/Microsoft.Identity.Web.Test/FederatedIdentityCaeTests.cs index eb83d7265..75b61dfc2 100644 --- a/tests/Microsoft.Identity.Web.Test/FederatedIdentityCaeTests.cs +++ b/tests/Microsoft.Identity.Web.Test/FederatedIdentityCaeTests.cs @@ -355,7 +355,7 @@ public async Task Fic_CustomSignedAssertion_HonorsInjectedCloudMetadataProvider( // Each test builds the REAL OIDC-CC FIC pipeline (ID Web -> MSAL) and mocks ONLY MSAL's outbound // HTTP, then asserts the credential-exchange request's POST 'scope' equals the expected cloud- // specific token-exchange audience + "/.default". Auto-resolution here flows from MSAL's built-in - // baseline (KnownCloudData) keyed by the SOURCE app's cloud — no injected provider needed for the + // baseline (KnownCloudMetadata) keyed by the SOURCE app's cloud — no injected provider needed for the // clouds MSAL ships (public, US Gov). A caller override (credential TokenExchangeUrl) must win. // The US-Gov-default case is also the regression guard for the Authority-vs-Instance resolution fix. // --------------------------------------------------------------------------------------------- From 535d8e876a6e9d1d23ec250e31a8db4a6ad774bd Mon Sep 17 00:00:00 2001 From: avdunn Date: Thu, 10 Sep 2026 16:16:21 -0700 Subject: [PATCH 10/14] Update MSAL and Abstraction dependencies --- Directory.Build.props | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index d3854d9ab..5857f76c1 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -80,9 +80,9 @@ 8.22.0 - 4.88.0 - 4.88.0 - 12.6.1 + 4.89.0 + 4.89.0 + 12.7.0 3.3.0 4.7.2 4.6.0 From 6bd4096ad02c714ddbdb7a72f39c9b804d249498 Mon Sep 17 00:00:00 2001 From: avdunn Date: Fri, 11 Sep 2026 08:31:45 -0700 Subject: [PATCH 11/14] Fix issue with apps from different clouds --- .../OidcIdpSignedAssertionLoader.cs | 3 ++- .../OidcIdpSignedAssertionProvider.cs | 26 +++++++++++++----- .../OidcIdpSignedAssertionProviderTests.cs | 27 +++++++++++++++++++ 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionLoader.cs b/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionLoader.cs index 001bac836..9f5e82135 100644 --- a/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionLoader.cs +++ b/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionLoader.cs @@ -201,7 +201,8 @@ public async Task LoadIfNeededAsync(CredentialDescription credentialDescription, microsoftIdentityApplicationOptions, credentialDescription.TokenExchangeUrl, _logger, - _serviceProvider.GetService()); + _serviceProvider.GetService(), + parameters?.Authority); if (credentialDescription.CustomSignedAssertionProviderData.TryGetValue("RequiresSignedAssertionFmiPath", out object? requiresSignedAssertionFmiPathObj) && requiresSignedAssertionFmiPathObj is bool requiresSignedAssertionFmiPathBool && requiresSignedAssertionFmiPathBool) { signedAssertion.RequiresSignedAssertionFmiPath = true; diff --git a/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionProvider.cs b/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionProvider.cs index 46e57d2e2..c6ce3a66b 100644 --- a/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionProvider.cs +++ b/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionProvider.cs @@ -35,6 +35,7 @@ internal class OidcIdpSignedAssertionProvider : ClientAssertionProviderBase private readonly string? _tokenExchangeUrl; private readonly ILogger? _logger; private readonly ICloudMetadataProvider? _cloudMetadataProvider; + private readonly string? _relyingApplicationAuthority; public bool RequiresSignedAssertionFmiPath { get; internal set; } @@ -52,13 +53,20 @@ public OidcIdpSignedAssertionProvider(ITokenAcquirerFactory tokenAcquirerFactory { } - internal OidcIdpSignedAssertionProvider(ITokenAcquirerFactory tokenAcquirerFactory, MicrosoftIdentityApplicationOptions options, string? tokenExchangeUrl, ILogger? logger, ICloudMetadataProvider? cloudMetadataProvider) + internal OidcIdpSignedAssertionProvider( + ITokenAcquirerFactory tokenAcquirerFactory, + MicrosoftIdentityApplicationOptions options, + string? tokenExchangeUrl, + ILogger? logger, + ICloudMetadataProvider? cloudMetadataProvider, + string? relyingApplicationAuthority = null) { _tokenAcquirerFactory = tokenAcquirerFactory; _options = options; _tokenExchangeUrl = tokenExchangeUrl; _logger = logger; _cloudMetadataProvider = cloudMetadataProvider; + _relyingApplicationAuthority = relyingApplicationAuthority; } protected override async Task GetClientAssertionAsync(AssertionRequestOptions? assertionRequestOptions) @@ -157,13 +165,19 @@ private async Task AcquireOidcAssertionResultAsync( // Precedence: an explicitly configured TokenExchangeUrl wins (as a per-call override); otherwise // resolve the cloud-specific exchange audience from the layered resolver — a caller's - // ICloudMetadataProvider (from DI) over MSAL's public baseline — keyed by this application's - // instance host (public cloud is unchanged via the documented fallback). The scope form - // (/.default) is applied via MSAL's TokenExchangeScope because this is a client-credentials / - // app-token acquisition. + // ICloudMetadataProvider (from DI) over MSAL's public baseline — keyed by the outer relying + // application's authority. The outer app owns the FIC registration that validates the assertion + // audience; using the inner assertion issuer's cloud would select the wrong audience in a + // cross-cloud flow. Fall back to the inner instance only when direct/legacy callers provide no + // assertion request authority. The scope form (/.default) is applied via MSAL's + // TokenExchangeScope because this is a client-credentials / app-token acquisition. string effectiveTokenExchangeUrl = Microsoft.Identity.Client.Instance.Discovery.TokenExchangeScope.FromAudience( FederatedCredentialAudienceResolver.ResolveTokenExchangeAudience( - string.IsNullOrEmpty(_options.Instance) ? _options.Authority : _options.Instance, + !string.IsNullOrEmpty(assertionRequestOptions?.Authority) + ? assertionRequestOptions.Authority + : !string.IsNullOrEmpty(_relyingApplicationAuthority) + ? _relyingApplicationAuthority + : string.IsNullOrEmpty(_options.Instance) ? _options.Authority : _options.Instance, perCallOverride: _tokenExchangeUrl, _cloudMetadataProvider)); diff --git a/tests/Microsoft.Identity.Web.Test/OidcIdpSignedAssertionProviderTests.cs b/tests/Microsoft.Identity.Web.Test/OidcIdpSignedAssertionProviderTests.cs index 4a6764e23..ca24a6f3b 100644 --- a/tests/Microsoft.Identity.Web.Test/OidcIdpSignedAssertionProviderTests.cs +++ b/tests/Microsoft.Identity.Web.Test/OidcIdpSignedAssertionProviderTests.cs @@ -207,6 +207,33 @@ public void SupportsTokenBinding_ReturnsTrue() Assert.True(provider.SupportsTokenBinding); } + [Theory] + [InlineData( + "https://login.microsoftonline.us/", + "https://login.microsoftonline.com/outer-tenant/", + "api://AzureADTokenExchange/.default")] + [InlineData( + "https://login.microsoftonline.com/", + "https://login.microsoftonline.us/outer-tenant/", + "api://AzureADTokenExchangeUSGov/.default")] + public async Task GetSignedAssertionAsync_CrossCloud_UsesOuterRelyingApplicationAudience( + string innerInstance, + string outerAuthority, + string expectedScope) + { + // Arrange + var (provider, acquirer) = CreateProvider( + new MicrosoftIdentityApplicationOptions { Instance = innerInstance }); + var capture = SetupAcquirer(acquirer, CreateResult("assertion")); + + // Act + await provider.GetSignedAssertionAsync( + new AssertionRequestOptions { Authority = outerAuthority }); + + // Assert + Assert.Equal(expectedScope, capture.Scope); + } + [Fact] public async Task GetSignedAssertionWithBindingAsync_RequestsInnerTokenBinding() { From 4c77019b8bf76f29ce53d2a73877228c0214af53 Mon Sep 17 00:00:00 2001 From: avdunn Date: Fri, 11 Sep 2026 11:12:52 -0700 Subject: [PATCH 12/14] Fix NPE --- .../OidcIdpSignedAssertionProvider.cs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionProvider.cs b/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionProvider.cs index c6ce3a66b..7c64c3ac4 100644 --- a/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionProvider.cs +++ b/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionProvider.cs @@ -168,13 +168,15 @@ private async Task AcquireOidcAssertionResultAsync( // ICloudMetadataProvider (from DI) over MSAL's public baseline — keyed by the outer relying // application's authority. The outer app owns the FIC registration that validates the assertion // audience; using the inner assertion issuer's cloud would select the wrong audience in a - // cross-cloud flow. Fall back to the inner instance only when direct/legacy callers provide no - // assertion request authority. The scope form (/.default) is applied via MSAL's - // TokenExchangeScope because this is a client-credentials / app-token acquisition. + // cross-cloud flow. For eager acquisition, use the authority captured by the credential loader; + // fall back to the inner instance only when direct/legacy callers provide neither source of outer + // request context. The scope form (/.default) is applied via MSAL's TokenExchangeScope because + // this is a client-credentials / app-token acquisition. + string? assertionRequestAuthority = assertionRequestOptions?.Authority; string effectiveTokenExchangeUrl = Microsoft.Identity.Client.Instance.Discovery.TokenExchangeScope.FromAudience( FederatedCredentialAudienceResolver.ResolveTokenExchangeAudience( - !string.IsNullOrEmpty(assertionRequestOptions?.Authority) - ? assertionRequestOptions.Authority + !string.IsNullOrEmpty(assertionRequestAuthority) + ? assertionRequestAuthority : !string.IsNullOrEmpty(_relyingApplicationAuthority) ? _relyingApplicationAuthority : string.IsNullOrEmpty(_options.Instance) ? _options.Authority : _options.Instance, From 8dad6a0da4550ec7768bbed7a3078407a7c1cfbd Mon Sep 17 00:00:00 2001 From: avdunn Date: Fri, 11 Sep 2026 14:41:03 -0700 Subject: [PATCH 13/14] Fix further issues --- .../OidcIdpSignedAssertionProvider.cs | 8 +-- .../FederatedIdentityCaeTests.cs | 60 +++++++------------ .../OidcIdpSignedAssertionProviderTests.cs | 29 ++++++++- 3 files changed, 53 insertions(+), 44 deletions(-) diff --git a/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionProvider.cs b/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionProvider.cs index 7c64c3ac4..ca8a32cef 100644 --- a/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionProvider.cs +++ b/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionProvider.cs @@ -175,10 +175,10 @@ private async Task AcquireOidcAssertionResultAsync( string? assertionRequestAuthority = assertionRequestOptions?.Authority; string effectiveTokenExchangeUrl = Microsoft.Identity.Client.Instance.Discovery.TokenExchangeScope.FromAudience( FederatedCredentialAudienceResolver.ResolveTokenExchangeAudience( - !string.IsNullOrEmpty(assertionRequestAuthority) - ? assertionRequestAuthority - : !string.IsNullOrEmpty(_relyingApplicationAuthority) - ? _relyingApplicationAuthority + !string.IsNullOrEmpty(_relyingApplicationAuthority) + ? _relyingApplicationAuthority + : !string.IsNullOrEmpty(assertionRequestAuthority) + ? assertionRequestAuthority : string.IsNullOrEmpty(_options.Instance) ? _options.Authority : _options.Instance, perCallOverride: _tokenExchangeUrl, _cloudMetadataProvider)); diff --git a/tests/Microsoft.Identity.Web.Test/FederatedIdentityCaeTests.cs b/tests/Microsoft.Identity.Web.Test/FederatedIdentityCaeTests.cs index 75b61dfc2..aaa22e236 100644 --- a/tests/Microsoft.Identity.Web.Test/FederatedIdentityCaeTests.cs +++ b/tests/Microsoft.Identity.Web.Test/FederatedIdentityCaeTests.cs @@ -231,10 +231,9 @@ public async Task Fic_CustomSignedAssertion_ClaimsAndCapabilities_AreSent_OnSeco }); // Assert endpoints, scopes, client IDs - // The source app (c1) is configured on US Gov (login.microsoftonline.us), so the FIC - // token-exchange audience auto-resolves from MSAL's cloud metadata to the US Gov value - // rather than the public-cloud default — the cross-cloud-metadata resolution under test. - Assert.Equal("api://AzureADTokenExchangeUSGov/.default", credentialRequestHttpHandler.ActualRequestPostData["scope"]); + // The target app owns the FIC registration and is configured on the public cloud, so the + // assertion audience uses the public-cloud value even though the source app is in US Gov. + Assert.Equal("api://AzureADTokenExchange/.default", credentialRequestHttpHandler.ActualRequestPostData["scope"]); Assert.Equal(TestConstants.s_scopeForApp, tokenRequestHttpHandler.ActualRequestPostData["scope"]); Assert.Equal("c1", credentialRequestHttpHandler.ActualRequestPostData["client_id"]); Assert.Equal("https://login.microsoftonline.us/t1/oauth2/v2.0/token", @@ -290,12 +289,11 @@ public async Task Fic_CustomSignedAssertion_HonorsInjectedCloudMetadataProvider( tokenAcquirerFactory.Services.AddOidcFic(); tokenAcquirerFactory.Services.AddSingleton(httpFactoryForTest); - // A caller (or MISE) registers an upstream cloud-metadata provider that OVERRIDES the FIC - // audience for the source app's cloud (US Gov). This must win over MSAL's built-in US Gov value, - // proving the OIDC client-credentials FIC leg honors the injected ICloudMetadataProvider end-to-end. + // A caller registers an upstream provider that overrides the FIC audience for the target + // application's public-cloud authority. tokenAcquirerFactory.Services.AddSingleton( new InMemoryCloudMetadataProvider().AddOrUpdate( - "login.microsoftonline.us", + "login.microsoftonline.com", new Dictionary { [CloudMetadataKeyNames.FederatedCredentialAudience] = "api://AzureADTokenExchangeCustomGov" @@ -355,9 +353,8 @@ public async Task Fic_CustomSignedAssertion_HonorsInjectedCloudMetadataProvider( // Each test builds the REAL OIDC-CC FIC pipeline (ID Web -> MSAL) and mocks ONLY MSAL's outbound // HTTP, then asserts the credential-exchange request's POST 'scope' equals the expected cloud- // specific token-exchange audience + "/.default". Auto-resolution here flows from MSAL's built-in - // baseline (KnownCloudMetadata) keyed by the SOURCE app's cloud — no injected provider needed for the + // baseline (KnownCloudMetadata) keyed by the TARGET app's cloud — no injected provider needed for the // clouds MSAL ships (public, US Gov). A caller override (credential TokenExchangeUrl) must win. - // The US-Gov-default case is also the regression guard for the Authority-vs-Instance resolution fix. // --------------------------------------------------------------------------------------------- [Fact] @@ -365,20 +362,16 @@ public Task Fic_Exchange_PublicCloud_DefaultEndpoint_SendsPublicAudienceAsync() => RunFicExchangeScenarioAsync( scenario: "Public cloud, default endpoint (auto-resolve via MSAL baseline)", sourceInstance: "https://login.microsoftonline.com/", - sourceAuthority: null, + targetInstance: "https://login.microsoftonline.com/", customTokenExchangeUrl: null, expectedExchangeScope: "api://AzureADTokenExchange/.default"); [Fact] - public Task Fic_Exchange_UsGovCloud_DefaultEndpoint_AutoResolvesUsGovAudienceAsync() - // Source app configured via AUTHORITY ONLY (Instance empty) — this is the regression guard for - // the OidcIdpSignedAssertionProvider fix: '_options.Instance ?? _options.Authority' silently - // fell back to the public audience because Instance defaults to "" (not null). US Gov is a - // cloud MSAL ships, so its audience auto-resolves from the baseline with no injected provider. + public Task Fic_Exchange_UsGovRelyingApplication_DefaultEndpoint_AutoResolvesUsGovAudienceAsync() => RunFicExchangeScenarioAsync( - scenario: "US Gov cloud (Authority-only source), default endpoint (auto-resolve via MSAL baseline)", - sourceInstance: null, - sourceAuthority: "https://login.microsoftonline.us/t1", + scenario: "US Gov relying application, default endpoint (auto-resolve via MSAL baseline)", + sourceInstance: "https://login.microsoftonline.com/", + targetInstance: "https://login.microsoftonline.us/", customTokenExchangeUrl: null, expectedExchangeScope: "api://AzureADTokenExchangeUSGov/.default"); @@ -387,26 +380,25 @@ public Task Fic_Exchange_PublicCloud_CustomEndpoint_SendsCustomAudienceAsync() => RunFicExchangeScenarioAsync( scenario: "Public cloud, caller-provided custom endpoint (override wins)", sourceInstance: "https://login.microsoftonline.com/", - sourceAuthority: null, + targetInstance: "https://login.microsoftonline.com/", customTokenExchangeUrl: "api://MyCustomTokenExchange", expectedExchangeScope: "api://MyCustomTokenExchange/.default"); /// /// Drives the real OIDC-CC FIC pipeline (ID Web -> MSAL, mocking only MSAL HTTP) and asserts the /// credential-exchange request carried as its POST 'scope'. - /// The source app ("AzureAd2") is configured via EITHER OR - /// (exactly one non-null); cloud resolution keys off it. + /// Cloud resolution keys off the relying application's . /// private async Task RunFicExchangeScenarioAsync( string scenario, - string? sourceInstance, - string? sourceAuthority, + string sourceInstance, + string targetInstance, string? customTokenExchangeUrl, string expectedExchangeScope) { using var httpFactoryForTest = new MockHttpClientFactory(); - // Leg 1: the credential EXCHANGE (client-credentials on the source cloud). Its 'scope' is the - // cloud-specific token-exchange audience under test. + // Leg 1: the source app's credential exchange. Its scope is determined by the target app's + // FIC registration. var credentialRequestHttpHandler = httpFactoryForTest.AddMockHandler( MockHttpCreator.CreateClientCredentialTokenHandler("token-exchange-1")); // Leg 2: the target app's actual token acquisition using the signed assertion. @@ -418,19 +410,11 @@ private async Task RunFicExchangeScenarioAsync( tokenAcquirerFactory.Services.AddOidcFic(); tokenAcquirerFactory.Services.AddSingleton(httpFactoryForTest); - // Source app (provides assertion), configured on the cloud under test — via Instance OR Authority. + // Source app (provides assertion). tokenAcquirerFactory.Services.Configure("AzureAd2", options => { - if (!string.IsNullOrEmpty(sourceInstance)) - { - options.Instance = sourceInstance; - options.TenantId = "t1"; - } - else - { - options.Authority = sourceAuthority; - } - + options.Instance = sourceInstance; + options.TenantId = "t1"; options.ClientId = "c1"; options.ClientCredentials = new[] { @@ -459,7 +443,7 @@ private async Task RunFicExchangeScenarioAsync( tokenAcquirerFactory.Services.Configure(options => { - options.Instance = "https://login.microsoftonline.com/"; + options.Instance = targetInstance; options.TenantId = "t2"; options.ClientId = "c2"; options.ClientCredentials = new[] { customSignedAssertion }; diff --git a/tests/Microsoft.Identity.Web.Test/OidcIdpSignedAssertionProviderTests.cs b/tests/Microsoft.Identity.Web.Test/OidcIdpSignedAssertionProviderTests.cs index ca24a6f3b..1c512867f 100644 --- a/tests/Microsoft.Identity.Web.Test/OidcIdpSignedAssertionProviderTests.cs +++ b/tests/Microsoft.Identity.Web.Test/OidcIdpSignedAssertionProviderTests.cs @@ -162,13 +162,20 @@ private static AcquireTokenResult CreateResult( private static (OidcIdpSignedAssertionProvider provider, ITokenAcquirer acquirer) CreateProvider( MicrosoftIdentityApplicationOptions? options = null, - string? tokenExchangeUrl = null) + string? tokenExchangeUrl = null, + string? relyingApplicationAuthority = null) { var acquirer = Substitute.For(); var factory = Substitute.For(); options ??= new MicrosoftIdentityApplicationOptions { Instance = SameCloudInstance }; factory.GetTokenAcquirer(Arg.Any()).Returns(acquirer); - var provider = new OidcIdpSignedAssertionProvider(factory, options, tokenExchangeUrl, logger: null); + var provider = new OidcIdpSignedAssertionProvider( + factory, + options, + tokenExchangeUrl, + logger: null, + cloudMetadataProvider: null, + relyingApplicationAuthority); return (provider, acquirer); } @@ -234,6 +241,24 @@ await provider.GetSignedAssertionAsync( Assert.Equal(expectedScope, capture.Scope); } + [Fact] + public async Task GetSignedAssertionWithBindingAsync_MtlsCallback_UsesCapturedRelyingApplicationAuthority() + { + // Arrange + var (provider, acquirer) = CreateProvider( + relyingApplicationAuthority: "https://login.microsoftonline.com/outer-tenant/"); + var capture = SetupAcquirer( + acquirer, + CreateResult("assertion", CreateSelfSignedCertificate())); + + // Act + await provider.GetSignedAssertionWithBindingAsync( + new AssertionRequestOptions { Authority = "https://mtlsauth.microsoft.com/outer-tenant/" }); + + // Assert + Assert.Equal("api://AzureADTokenExchange/.default", capture.Scope); + } + [Fact] public async Task GetSignedAssertionWithBindingAsync_RequestsInnerTokenBinding() { From 023948db729bb4b1f5e30cdc77f032bb0d550ae7 Mon Sep 17 00:00:00 2001 From: avdunn Date: Fri, 11 Sep 2026 15:19:26 -0700 Subject: [PATCH 14/14] Fixs cross-cloud audience issues --- .../CredentialsProvider.cs | 18 +++++++ .../WithClientCredentialsTests.cs | 47 +++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/CredentialsProvider.cs b/src/Microsoft.Identity.Web.TokenAcquisition/CredentialsProvider.cs index 2ef105f3b..fae2d1e65 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/CredentialsProvider.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/CredentialsProvider.cs @@ -15,6 +15,8 @@ namespace Microsoft.Identity.Web { internal partial class CredentialsProvider : ICredentialsProvider { + private const string OidcIdpSignedAssertionProviderName = "OidcIdpSignedAssertion"; + private readonly ILogger _logger; private readonly ITokenAcquisitionHost? _tokenHost; private readonly ICredentialsLoader _credentialsLoader; @@ -67,6 +69,22 @@ public CredentialsProvider( if (!credential.Skip) { + // The OIDC provider captures the relying application's authority when a CCA is built. + // Keep that request-specific provider off the shared credential description so one + // cloud's authority cannot leak into a CCA built for another cloud. + if (credential.SourceType == CredentialSource.CustomSignedAssertion + && string.Equals( + credential.CustomSignedAssertionProviderName, + OidcIdpSignedAssertionProviderName, + StringComparison.Ordinal) + && !string.IsNullOrEmpty(credentialSourceLoaderParameters?.Authority)) + { + credential = new CredentialDescription(originalCredential) + { + CachedValue = null, + }; + } + // For the managed-identity FIC leg, resolve the cloud-specific token-exchange audience from // the request authority host when the caller did not set one explicitly. This lets sovereign // (public) and internal-only (via a registered ICloudMetadataProvider) clouds use the correct diff --git a/tests/Microsoft.Identity.Web.Test/Certificates/WithClientCredentialsTests.cs b/tests/Microsoft.Identity.Web.Test/Certificates/WithClientCredentialsTests.cs index 08fc0dbeb..dac553a4e 100644 --- a/tests/Microsoft.Identity.Web.Test/Certificates/WithClientCredentialsTests.cs +++ b/tests/Microsoft.Identity.Web.Test/Certificates/WithClientCredentialsTests.cs @@ -78,6 +78,53 @@ public async Task FicFails_CertificateFallbackAsync() Assert.Equal(credentialDescriptions[1], cd); } + [Fact] + public async Task OidcFicCredential_IsLoadedPerRelyingApplicationAuthorityAsync() + { + // Arrange + var logger = Substitute.For>(); + ICredentialsLoader credLoader = Substitute.For(); + var loadedCredentials = new List(); + credLoader.LoadCredentialsIfNeededAsync( + Arg.Any(), + Arg.Any()) + .Returns(args => + { + var credential = (CredentialDescription)args[0]; + loadedCredentials.Add(credential); + credential.CachedValue = new object(); + return Task.CompletedTask; + }); + + var originalCredential = new CredentialDescription + { + SourceType = CredentialSource.CustomSignedAssertion, + CustomSignedAssertionProviderName = "OidcIdpSignedAssertion", + CachedValue = new object(), + }; + var options = new MergedOptions + { + ClientCredentials = [originalCredential], + }; + var provider = new CredentialsProvider(logger, credLoader, [], null); + + // Act + CredentialDescription? publicCloudCredential = await provider.GetCredentialAsync( + options, + new CredentialSourceLoaderParameters("client", "https://login.microsoftonline.com/tenant")); + CredentialDescription? usGovCredential = await provider.GetCredentialAsync( + options, + new CredentialSourceLoaderParameters("client", "https://login.microsoftonline.us/tenant")); + + // Assert + Assert.NotSame(originalCredential, publicCloudCredential); + Assert.NotSame(originalCredential, usGovCredential); + Assert.NotSame(publicCloudCredential, usGovCredential); + Assert.Equal(2, loadedCredentials.Count); + Assert.Same(publicCloudCredential, loadedCredentials[0]); + Assert.Same(usGovCredential, loadedCredentials[1]); + } + #region Test around failure to load creds [Fact] public async Task FailsForFic_ReturnsMeaningfulMessageAsync()