Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,9 @@

<PropertyGroup Label="Common dependency versions">
<MicrosoftIdentityModelVersion Condition="'$(MicrosoftIdentityModelVersion)' == ''">8.22.0</MicrosoftIdentityModelVersion>
<MicrosoftIdentityClientVersion Condition="'$(MicrosoftIdentityClientVersion)' == ''">4.88.0</MicrosoftIdentityClientVersion>
<MicrosoftIdentityClientKeyAttestationVersion Condition="'$(MicrosoftIdentityClientKeyAttestationVersion)' == ''">4.88.0</MicrosoftIdentityClientKeyAttestationVersion>
<MicrosoftIdentityAbstractionsVersion Condition="'$(MicrosoftIdentityAbstractionsVersion)' == ''">12.6.1</MicrosoftIdentityAbstractionsVersion>
<MicrosoftIdentityClientVersion Condition="'$(MicrosoftIdentityClientVersion)' == ''">4.89.0</MicrosoftIdentityClientVersion>
<MicrosoftIdentityClientKeyAttestationVersion Condition="'$(MicrosoftIdentityClientKeyAttestationVersion)' == ''">4.89.0</MicrosoftIdentityClientKeyAttestationVersion>
<MicrosoftIdentityAbstractionsVersion Condition="'$(MicrosoftIdentityAbstractionsVersion)' == ''">12.7.0</MicrosoftIdentityAbstractionsVersion>
<FxCopAnalyzersVersion>3.3.0</FxCopAnalyzersVersion>
<SystemTextEncodingsWebVersion>4.7.2</SystemTextEncodingsWebVersion>
<AzureSecurityKeyVaultSecretsVersion>4.6.0</AzureSecurityKeyVaultSecretsVersion>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -40,8 +42,7 @@ public ManagedIdentityClientAssertion(string? managedIdentityClientId) :
/// See https://aka.ms/ms-id-web/certificateless.
/// </summary>
/// <param name="managedIdentityClientId">Optional ClientId of the Managed Identity</param>
/// <param name="tokenExchangeUrl">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</param>
/// <param name="tokenExchangeUrl">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.</param>
public ManagedIdentityClientAssertion(string? managedIdentityClientId, string? tokenExchangeUrl) :
this(managedIdentityClientId, tokenExchangeUrl, null)
{
Expand All @@ -51,8 +52,7 @@ public ManagedIdentityClientAssertion(string? managedIdentityClientId, string? t
/// See https://aka.ms/ms-id-web/certificateless.
/// </summary>
/// <param name="managedIdentityClientId">Optional ClientId of the Managed Identity</param>
/// <param name="tokenExchangeUrl">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</param>
/// <param name="tokenExchangeUrl">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.</param>
/// <param name="logger">A logger</param>
public ManagedIdentityClientAssertion(
string? managedIdentityClientId,
Expand Down Expand Up @@ -94,8 +94,7 @@ public ManagedIdentityClientAssertion(
/// but allows injecting a custom MSAL HttpClient factory (used by tests).
/// </summary>
/// <param name="managedIdentityClientId">Optional ClientId of the Managed Identity</param>
/// <param name="tokenExchangeUrl">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</param>
/// <param name="tokenExchangeUrl">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.</param>
/// <param name="logger">A logger.</param>
/// <param name="testHttpClientFactory">Optional MSAL HttpClient factory.</param>
internal ManagedIdentityClientAssertion(
Expand All @@ -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;

Expand All @@ -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
Expand Down Expand Up @@ -210,7 +222,7 @@ private async Task<AuthenticationResult> AcquireManagedIdentityTokenAsync(
CancellationToken cancellationToken)
{
var miBuilder = _managedIdentityApplication
.AcquireTokenForManagedIdentity(_tokenExchangeUrl);
.AcquireTokenForManagedIdentity(ResolveTokenExchangeUrl(assertionRequestOptions));

if (bindToCertificate)
{
Expand Down Expand Up @@ -250,5 +262,49 @@ private async Task<AuthenticationResult> AcquireManagedIdentityTokenAsync(
.ConfigureAwait(false);
}

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

/// <summary>
/// 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.
/// </summary>
private static string? TryGetHost(string? authority)
{
if (string.IsNullOrEmpty(authority))
{
return null;
}

return Uri.TryCreate(authority, UriKind.Absolute, out Uri? uri)
? uri.Host
: authority;
}

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<ICloudMetadataProvider>(),
parameters?.Authority);
if (credentialDescription.CustomSignedAssertionProviderData.TryGetValue("RequiresSignedAssertionFmiPath", out object? requiresSignedAssertionFmiPathObj) && requiresSignedAssertionFmiPathObj is bool requiresSignedAssertionFmiPathBool && requiresSignedAssertionFmiPathBool)
{
signedAssertion.RequiresSignedAssertionFmiPath = true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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; }

Expand All @@ -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<ClientAssertion> GetClientAssertionAsync(AssertionRequestOptions? assertionRequestOptions)
Expand Down Expand Up @@ -151,10 +163,25 @@ private async Task<AcquireTokenResult> 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;

Expand Down Expand Up @@ -212,7 +239,7 @@ private async Task<AcquireTokenResult> AcquireOidcAssertionResultAsync(

if (_logger != null)
{
_logger.AcquiringToken(tokenExchangeUrl, acquireTokenOptions?.FmiPath);
_logger.AcquiringToken(effectiveTokenExchangeUrl, acquireTokenOptions?.FmiPath);
}

CancellationToken effectiveCancellationToken = cancellationToken != default
Expand Down
Loading
Loading