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 diff --git a/src/Microsoft.Identity.Web.Certificateless/CertificatelessConstants.cs b/src/Microsoft.Identity.Web.Certificateless/CertificatelessConstants.cs index 15b0e749b..c7b50608b 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 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..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; @@ -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; @@ -21,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; @@ -40,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) { @@ -51,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, @@ -94,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( @@ -119,7 +118,20 @@ private ManagedIdentityClientAssertion( IKeyAttestationProvider? keyAttestationProvider, IMsalHttpClientFactory? testHttpClientFactory) { - _tokenExchangeUrl = tokenExchangeUrl ?? 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; @@ -139,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 @@ -210,7 +222,7 @@ private async Task AcquireManagedIdentityTokenAsync( CancellationToken cancellationToken) { var miBuilder = _managedIdentityApplication - .AcquireTokenForManagedIdentity(_tokenExchangeUrl); + .AcquireTokenForManagedIdentity(ResolveTokenExchangeUrl(assertionRequestOptions)); if (bindToCertificate) { @@ -250,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 7b5a99421..9f5e82135 100644 --- a/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionLoader.cs +++ b/src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionLoader.cs @@ -196,7 +196,13 @@ 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, + _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 f3493d7e4..ca8a32cef 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,8 @@ internal class OidcIdpSignedAssertionProvider : ClientAssertionProviderBase private readonly MicrosoftIdentityApplicationOptions _options; private readonly string? _tokenExchangeUrl; private readonly ILogger? _logger; + private readonly ICloudMetadataProvider? _cloudMetadataProvider; + private readonly string? _relyingApplicationAuthority; public bool RequiresSignedAssertionFmiPath { get; internal set; } @@ -50,11 +49,24 @@ internal class OidcIdpSignedAssertionProvider : ClientAssertionProviderBase public override bool SupportsTokenBinding => true; public OidcIdpSignedAssertionProvider(ITokenAcquirerFactory tokenAcquirerFactory, MicrosoftIdentityApplicationOptions options, string? tokenExchangeUrl, ILogger? logger) + : this(tokenAcquirerFactory, options, tokenExchangeUrl, logger, cloudMetadataProvider: null) + { + } + + 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) @@ -151,10 +163,25 @@ 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 audience from the layered resolver — a caller's + // 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. 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(_relyingApplicationAuthority) + ? _relyingApplicationAuthority + : !string.IsNullOrEmpty(assertionRequestAuthority) + ? assertionRequestAuthority + : string.IsNullOrEmpty(_options.Instance) ? _options.Authority : _options.Instance, + perCallOverride: _tokenExchangeUrl, + _cloudMetadataProvider)); string? fmiPath = assertionRequestOptions?.ClientAssertionFmiPath; @@ -212,7 +239,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/CloudMetadataServiceCollectionExtensions.cs b/src/Microsoft.Identity.Web.TokenAcquisition/CloudMetadataServiceCollectionExtensions.cs new file mode 100644 index 000000000..a83e074c2 --- /dev/null +++ b/src/Microsoft.Identity.Web.TokenAcquisition/CloudMetadataServiceCollectionExtensions.cs @@ -0,0 +1,78 @@ +// 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, 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": { "federated_credential_audience": "api://AzureADTokenExchangeUSGov" }, + /// "login.mynewcloud.example": { "federated_credential_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) 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/CredentialsProvider.cs b/src/Microsoft.Identity.Web.TokenAcquisition/CredentialsProvider.cs index d5a1771d2..fae2d1e65 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/CredentialsProvider.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/CredentialsProvider.cs @@ -15,21 +15,26 @@ 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; private readonly IReadOnlyList _certificatesObservers; + private readonly ICloudMetadataProvider? _cloudMetadataProvider; public CredentialsProvider( ILogger logger, ICredentialsLoader credentialsLoader, IEnumerable certificatesObservers, - ITokenAcquisitionHost? tokenHost = null) + ITokenAcquisitionHost? tokenHost = null, + ICloudMetadataProvider? cloudMetadataProvider = null) { _logger = logger; _tokenHost = tokenHost; _credentialsLoader = credentialsLoader; _certificatesObservers = [.. certificatesObservers]; + _cloudMetadataProvider = cloudMetadataProvider; } public Task GetCredentialAsync( @@ -57,12 +62,50 @@ 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) { + // 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 + // 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 = 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 try { diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/FederatedCredentialAudienceResolver.cs b/src/Microsoft.Identity.Web.TokenAcquisition/FederatedCredentialAudienceResolver.cs new file mode 100644 index 000000000..de3f47baf --- /dev/null +++ b/src/Microsoft.Identity.Web.TokenAcquisition/FederatedCredentialAudienceResolver.cs @@ -0,0 +1,123 @@ +// 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 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 + /// upstream SDK — this is how internal-only sovereign clouds become resolvable; + /// MSAL's built-in public baseline (). + /// + /// + /// + /// + /// 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 + /// ( 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 FederatedCredentialAudienceResolver + { + /// + /// 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)."); + } + + /// + /// 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/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/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/TokenAcquisition.cs b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs index 906b94161..e30aa1b91 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs @@ -87,7 +87,13 @@ 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. 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 ICloudMetadataProvider? _cloudMetadataProvider; private const string TokenBindingParameterName = "IsTokenBinding"; private const int MaxCertificateRetries = 1; @@ -151,10 +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; + _cloudMetadataProvider = serviceProvider.GetService(); } #if NET6_0_OR_GREATER @@ -705,7 +712,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[] { 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); @@ -789,7 +799,8 @@ private async Task GetOrBuildAgentUserFicCcaAsyn blueprintOptions, isTokenBinding: false).ConfigureAwait(false); var leg1Builder = blueprintCca - .AcquireTokenForClient(s_ficScopes) + .AcquireTokenForClient( + 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/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/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() diff --git a/tests/Microsoft.Identity.Web.Test/FederatedCredentialAudienceResolverTests.cs b/tests/Microsoft.Identity.Web.Test/FederatedCredentialAudienceResolverTests.cs new file mode 100644 index 000000000..99e473ee4 --- /dev/null +++ b/tests/Microsoft.Identity.Web.Test/FederatedCredentialAudienceResolverTests.cs @@ -0,0 +1,167 @@ +// 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 FederatedCredentialAudienceResolverTests + { + 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", + FederatedCredentialAudienceResolver.ResolveTokenExchangeAudience(null, perCallOverride: null, upstreamProvider: null)); + Assert.Equal( + "api://AzureADTokenExchange", + FederatedCredentialAudienceResolver.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(() => + FederatedCredentialAudienceResolver.ResolveTokenExchangeAudience(NewCloudAuthority, perCallOverride: null, upstreamProvider: null)); + + Assert.Contains(NewCloudHost, ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void ResolveAudience_KnownSovereignHost_NoProvider_ResolvesFromMsalBaseline() + { + string audience = FederatedCredentialAudienceResolver.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 = FederatedCredentialAudienceResolver.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 = FederatedCredentialAudienceResolver.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 = FederatedCredentialAudienceResolver.ResolveTokenExchangeAudience( + UsGovAuthority, perCallOverride: "api://PerCallAudience", provider); + + Assert.Equal("api://PerCallAudience", audience); + } + + [Fact] + public void ScopeProjection_AppendsDefaultSuffix_Idempotently() + { + // 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 ScopeProjection_KnownSovereignHost_ComputesScopeFromBaseline() + { + string scope = Microsoft.Identity.Client.Instance.Discovery.TokenExchangeScope.FromAudience( + FederatedCredentialAudienceResolver.ResolveTokenExchangeAudience( + 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", + FederatedCredentialAudienceResolver.ResolveTokenExchangeAudience(NewCloudAuthority, perCallOverride: null, provider)); + Assert.Equal( + "api://AzureADTokenExchangeMyCloud/.default", + Microsoft.Identity.Client.Instance.Discovery.TokenExchangeScope.FromAudience( + FederatedCredentialAudienceResolver.ResolveTokenExchangeAudience(NewCloudAuthority, perCallOverride: null, provider))); + } + } +} diff --git a/tests/Microsoft.Identity.Web.Test/FederatedIdentityCaeTests.cs b/tests/Microsoft.Identity.Web.Test/FederatedIdentityCaeTests.cs index 9542b2436..aaa22e236 100644 --- a/tests/Microsoft.Identity.Web.Test/FederatedIdentityCaeTests.cs +++ b/tests/Microsoft.Identity.Web.Test/FederatedIdentityCaeTests.cs @@ -231,6 +231,8 @@ public async Task Fic_CustomSignedAssertion_ClaimsAndCapabilities_AreSent_OnSeco }); // Assert endpoints, scopes, client IDs + // 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"]); @@ -269,5 +271,193 @@ 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). 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(); + var tokenAcquirerFactory = TokenAcquirerFactory.GetDefaultInstance(); + tokenAcquirerFactory.Services.AddOidcFic(); + tokenAcquirerFactory.Services.AddSingleton(httpFactoryForTest); + + // 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.com", + new Dictionary + { + [CloudMetadataKeyNames.FederatedCredentialAudience] = "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 (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. + // --------------------------------------------------------------------------------------------- + + [Fact] + public Task Fic_Exchange_PublicCloud_DefaultEndpoint_SendsPublicAudienceAsync() + => RunFicExchangeScenarioAsync( + scenario: "Public cloud, default endpoint (auto-resolve via MSAL baseline)", + sourceInstance: "https://login.microsoftonline.com/", + targetInstance: "https://login.microsoftonline.com/", + customTokenExchangeUrl: null, + expectedExchangeScope: "api://AzureADTokenExchange/.default"); + + [Fact] + public Task Fic_Exchange_UsGovRelyingApplication_DefaultEndpoint_AutoResolvesUsGovAudienceAsync() + => RunFicExchangeScenarioAsync( + 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"); + + [Fact] + public Task Fic_Exchange_PublicCloud_CustomEndpoint_SendsCustomAudienceAsync() + => RunFicExchangeScenarioAsync( + scenario: "Public cloud, caller-provided custom endpoint (override wins)", + sourceInstance: "https://login.microsoftonline.com/", + 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'. + /// Cloud resolution keys off the relying application's . + /// + private async Task RunFicExchangeScenarioAsync( + string scenario, + string sourceInstance, + string targetInstance, + string? customTokenExchangeUrl, + string expectedExchangeScope) + { + using var httpFactoryForTest = new MockHttpClientFactory(); + // 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. + httpFactoryForTest.AddMockHandler( + MockHttpCreator.CreateClientCredentialTokenHandler("final-access-token")); + + TokenAcquirerFactoryTesting.ResetTokenAcquirerFactoryInTest(); + var tokenAcquirerFactory = TokenAcquirerFactory.GetDefaultInstance(); + tokenAcquirerFactory.Services.AddOidcFic(); + tokenAcquirerFactory.Services.AddSingleton(httpFactoryForTest); + + // Source app (provides assertion). + tokenAcquirerFactory.Services.Configure("AzureAd2", options => + { + options.Instance = sourceInstance; + options.TenantId = "t1"; + 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 = targetInstance; + 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); + } } } 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); + } + } +} diff --git a/tests/Microsoft.Identity.Web.Test/OidcIdpSignedAssertionProviderTests.cs b/tests/Microsoft.Identity.Web.Test/OidcIdpSignedAssertionProviderTests.cs index 4a6764e23..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); } @@ -207,6 +214,51 @@ 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_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() {