From 6570811a5cae6471245897bd2312e26effbde309 Mon Sep 17 00:00:00 2001 From: Nilesh Choudhary Date: Mon, 29 Jun 2026 16:48:33 +0100 Subject: [PATCH 1/2] Adopt IAuthorizationHeaderProvider2 from Abstractions 12.3.0 - Bump Microsoft.Identity.Abstractions to 12.3.0 - Implement IAuthorizationHeaderProvider2 on DefaultAuthorizationHeaderProvider and the public BaseAuthorizationHeaderProvider; consolidate all header-creation paths through a single BuildHeaderInformationAsync engine - Propagate TokenAcquisitionMetadata and AdditionalResponseParameters from MSAL onto AuthorizationHeaderInformation via the existing AcquireTokenResultFactory - DownstreamApi and MicrosoftIdentityMessageHandler prefer IAuthorizationHeaderProvider2 for mTLS PoP, with IBoundAuthorizationHeaderProvider kept as a back-compat fallback - Add PublicAPI.Unshipped entries for the new virtuals on BaseAuthorizationHeaderProvider - Add DefaultAuthorizationHeaderProviderV2Tests covering bearer/app/user flows, metadata + additional-response-parameter propagation, and token-binding guard Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Directory.Build.props | 2 +- changelog.md | 8 + .../DownstreamApi.cs | 23 +- .../AcquireTokenResultFactory.cs | 8 + .../BaseAuthorizationHeaderProvider.cs | 68 +++++- .../DefaultAuthorizationHeaderProvider.cs | 228 ++++++++++++------ .../MicrosoftIdentityMessageHandler.cs | 32 ++- .../PublicAPI/NetCore/PublicAPI.Unshipped.txt | 3 + .../NetFramework/PublicAPI.Unshipped.txt | 3 + ...faultAuthorizationHeaderProviderV2Tests.cs | 161 +++++++++++++ 10 files changed, 448 insertions(+), 88 deletions(-) create mode 100644 tests/Microsoft.Identity.Web.Test/DefaultAuthorizationHeaderProviderV2Tests.cs diff --git a/Directory.Build.props b/Directory.Build.props index 99aaf7544..553d052b2 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -82,7 +82,7 @@ 8.19.1 4.85.2 4.85.2 - 12.2.0 + 12.3.0 3.3.0 4.7.2 4.6.0 diff --git a/changelog.md b/changelog.md index b351ed9fc..dc051267b 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,11 @@ +## 4.12.0 + +### New features +- Implement `IAuthorizationHeaderProvider2` (from `Microsoft.Identity.Abstractions` 12.3.0) on `DefaultAuthorizationHeaderProvider` and the public `BaseAuthorizationHeaderProvider`, exposing the metadata-rich `CreateAuthorizationHeaderInformation*` surface (returning `OperationResult`) with binding-certificate propagation. `DownstreamApi` and `MicrosoftIdentityMessageHandler` now prefer `IAuthorizationHeaderProvider2` for mTLS PoP and soft-deprecate the bound-only `IBoundAuthorizationHeaderProvider` path (kept as a fallback for source/binary compatibility). + +### Dependencies updates +- Update `Microsoft.Identity.Abstractions` to 12.3.0. + ## 4.11.0 ### New features diff --git a/src/Microsoft.Identity.Web.DownstreamApi/DownstreamApi.cs b/src/Microsoft.Identity.Web.DownstreamApi/DownstreamApi.cs index d2077e633..fe6319ab6 100644 --- a/src/Microsoft.Identity.Web.DownstreamApi/DownstreamApi.cs +++ b/src/Microsoft.Identity.Web.DownstreamApi/DownstreamApi.cs @@ -713,7 +713,28 @@ public Task CallApiForAppAsync( // Firstly check if it's token binding scenario so authorization header provider returns // a binding certificate along with acquired authorization header. - if (_authorizationHeaderProvider is IBoundAuthorizationHeaderProvider boundAuthorizationHeaderBoundProvider + // Prefer the IAuthorizationHeaderProvider2 surface (Abstractions 12.3.0+); fall back to the + // legacy IBoundAuthorizationHeaderProvider for custom providers that haven't been updated yet. + if (string.Equals(effectiveOptions.ProtocolScheme, Constants.TokenBindingProtocolScheme, StringComparison.OrdinalIgnoreCase) + && _authorizationHeaderProvider is IAuthorizationHeaderProvider2 boundAuthorizationHeaderProviderV2) + { + var authorizationHeaderResult = await boundAuthorizationHeaderProviderV2.CreateAuthorizationHeaderInformationAsync( + effectiveOptions.Scopes, + effectiveOptions, + user, + cancellationToken).ConfigureAwait(false); + + if (!authorizationHeaderResult.Succeeded) + { + // in theory it shouldn't happen because in case of error during token acquisition + // there will be thrown corresponding exception, so it's more a safeguard + throw new InvalidOperationException("Cannot acquire bound authorization header."); + } + + authorizationHeaderInformation = authorizationHeaderResult.Result; + authorizationHeader = authorizationHeaderInformation?.AuthorizationHeaderValue!; + } + else if (_authorizationHeaderProvider is IBoundAuthorizationHeaderProvider boundAuthorizationHeaderBoundProvider && string.Equals(effectiveOptions.ProtocolScheme, Constants.TokenBindingProtocolScheme, StringComparison.OrdinalIgnoreCase)) { var authorizationHeaderResult = await boundAuthorizationHeaderBoundProvider.CreateBoundAuthorizationHeaderAsync( diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/AcquireTokenResultFactory.cs b/src/Microsoft.Identity.Web.TokenAcquisition/AcquireTokenResultFactory.cs index e85815c53..acf98de3b 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/AcquireTokenResultFactory.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/AcquireTokenResultFactory.cs @@ -27,6 +27,14 @@ public static Abstractions.AcquireTokenResult FromMsal(AuthenticationResult resu Metadata = MapMetadata(result), }; + /// + /// Maps the MSAL on an + /// to its abstractions counterpart. + /// Returns when no metadata was captured. + /// + public static Abstractions.TokenAcquisitionMetadata? GetMetadata(AuthenticationResult result) => + MapMetadata(result); + private static Abstractions.TokenAcquisitionMetadata? MapMetadata(AuthenticationResult result) { AuthenticationResultMetadata? source = result.AuthenticationResultMetadata; diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/BaseAuthorizationHeaderProvider.cs b/src/Microsoft.Identity.Web.TokenAcquisition/BaseAuthorizationHeaderProvider.cs index 947a44770..cb7228ceb 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/BaseAuthorizationHeaderProvider.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/BaseAuthorizationHeaderProvider.cs @@ -15,13 +15,19 @@ namespace Microsoft.Identity.Web.Extensibility /// Base class for custom implementations of that /// would still want to leverage the default implementation for the bearer and Pop protocols. /// - public class BaseAuthorizationHeaderProvider : IAuthorizationHeaderProvider + /// + /// Also implements (added in + /// Microsoft.Identity.Abstractions 12.3.0) so subclasses automatically expose the + /// metadata-rich header-creation surface without needing to opt in. Override the + /// CreateAuthorizationHeaderInformation* virtuals to customize that path. + /// + public class BaseAuthorizationHeaderProvider : IAuthorizationHeaderProvider, IAuthorizationHeaderProvider2 { /// /// Constructor from a service provider /// /// - public BaseAuthorizationHeaderProvider(IServiceProvider serviceProvider) + public BaseAuthorizationHeaderProvider(IServiceProvider serviceProvider) { // We, intentionally, use a locator pattern here, because we don't want to expose ITokenAcquisition // in the public API as it's going to be deprecated in future versions of IdWeb. Here this @@ -30,7 +36,7 @@ public BaseAuthorizationHeaderProvider(IServiceProvider serviceProvider) _headerProvider = new DefaultAuthorizationHeaderProvider(_tokenAcquisition); } - private readonly IAuthorizationHeaderProvider _headerProvider; + private readonly DefaultAuthorizationHeaderProvider _headerProvider; /// public virtual Task CreateAuthorizationHeaderForUserAsync(IEnumerable scopes, AuthorizationHeaderProviderOptions? authorizationHeaderProviderOptions = null, ClaimsPrincipal? claimsPrincipal = null, CancellationToken cancellationToken = default) @@ -46,15 +52,59 @@ public virtual Task CreateAuthorizationHeaderForAppAsync(string scopes, /// public virtual Task CreateAuthorizationHeaderAsync( - IEnumerable scopes, - AuthorizationHeaderProviderOptions? authorizationHeaderProviderOptions = null, - ClaimsPrincipal? claimsPrincipal = null, + IEnumerable scopes, + AuthorizationHeaderProviderOptions? authorizationHeaderProviderOptions = null, + ClaimsPrincipal? claimsPrincipal = null, CancellationToken cancellationToken = default) { return _headerProvider.CreateAuthorizationHeaderAsync( - scopes, - authorizationHeaderProviderOptions, - claimsPrincipal, + scopes, + authorizationHeaderProviderOptions, + claimsPrincipal, + cancellationToken); + } + + /// + /// + /// Default implementation delegates to ; override to inject + /// custom logic while still receiving the binding certificate and token-acquisition metadata for free. + /// + public virtual Task> CreateAuthorizationHeaderInformationForUserAsync( + IEnumerable scopes, + AuthorizationHeaderProviderOptions? authorizationHeaderProviderOptions = null, + ClaimsPrincipal? claimsPrincipal = default, + CancellationToken cancellationToken = default) + { + return _headerProvider.CreateAuthorizationHeaderInformationForUserAsync( + scopes, + authorizationHeaderProviderOptions, + claimsPrincipal, + cancellationToken); + } + + /// + public virtual Task> CreateAuthorizationHeaderInformationForAppAsync( + string scopes, + AuthorizationHeaderProviderOptions? downstreamApiOptions = null, + CancellationToken cancellationToken = default) + { + return _headerProvider.CreateAuthorizationHeaderInformationForAppAsync( + scopes, + downstreamApiOptions, + cancellationToken); + } + + /// + public virtual Task> CreateAuthorizationHeaderInformationAsync( + IEnumerable scopes, + AuthorizationHeaderProviderOptions? options = null, + ClaimsPrincipal? claimsPrincipal = null, + CancellationToken cancellationToken = default) + { + return _headerProvider.CreateAuthorizationHeaderInformationAsync( + scopes, + options, + claimsPrincipal, cancellationToken); } } diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/DefaultAuthorizationHeaderProvider.cs b/src/Microsoft.Identity.Web.TokenAcquisition/DefaultAuthorizationHeaderProvider.cs index f34f89040..5a9574a84 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/DefaultAuthorizationHeaderProvider.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/DefaultAuthorizationHeaderProvider.cs @@ -16,7 +16,10 @@ namespace Microsoft.Identity.Web * Any changes to this member (including removal) can cause runtime failures. * Treat as a public member. */ - internal sealed class DefaultAuthorizationHeaderProvider : IAuthorizationHeaderProvider, IBoundAuthorizationHeaderProvider + internal sealed class DefaultAuthorizationHeaderProvider : + IAuthorizationHeaderProvider, + IAuthorizationHeaderProvider2, + IBoundAuthorizationHeaderProvider { private static readonly object s_boxedTrue = true; @@ -30,6 +33,11 @@ public DefaultAuthorizationHeaderProvider(ITokenAcquisition tokenAcquisition) _tokenAcquisition = tokenAcquisition; } + // --------------------------------------------------------------------- + // IAuthorizationHeaderProvider (string-returning) — thin adapters over + // the metadata-rich engine introduced for IAuthorizationHeaderProvider2. + // --------------------------------------------------------------------- + /// public async Task CreateAuthorizationHeaderForUserAsync( IEnumerable scopes, @@ -37,17 +45,13 @@ public async Task CreateAuthorizationHeaderForUserAsync( ClaimsPrincipal? claimsPrincipal = null, CancellationToken cancellationToken = default) { - var newTokenAcquisitionOptions = CreateTokenAcquisitionOptionsFromApiOptions(downstreamApiOptions, cancellationToken); - var result = await _tokenAcquisition.GetAuthenticationResultForUserAsync( + var info = await BuildHeaderInformationAsync( scopes, - downstreamApiOptions?.AcquireTokenOptions.AuthenticationOptionsName, - downstreamApiOptions?.AcquireTokenOptions.Tenant, - downstreamApiOptions?.AcquireTokenOptions.UserFlow, + downstreamApiOptions, claimsPrincipal, - newTokenAcquisitionOptions).ConfigureAwait(false); - - UpdateOriginalTokenAcquisitionOptions(downstreamApiOptions?.AcquireTokenOptions, newTokenAcquisitionOptions); - return result.CreateAuthorizationHeader(); + forceAppToken: false, + cancellationToken).ConfigureAwait(false); + return info.AuthorizationHeaderValue!; } /// @@ -56,13 +60,13 @@ public async Task CreateAuthorizationHeaderForAppAsync( AuthorizationHeaderProviderOptions? downstreamApiOptions = null, CancellationToken cancellationToken = default) { - var result = await _tokenAcquisition.GetAuthenticationResultForAppAsync( - scopes, - downstreamApiOptions?.AcquireTokenOptions.AuthenticationOptionsName, - downstreamApiOptions?.AcquireTokenOptions.Tenant, - CreateTokenAcquisitionOptionsFromApiOptions(downstreamApiOptions, cancellationToken)).ConfigureAwait(false); - - return result.CreateAuthorizationHeader(); + var info = await BuildHeaderInformationAsync( + new[] { scopes }, + downstreamApiOptions, + claimsPrincipal: null, + forceAppToken: true, + cancellationToken).ConfigureAwait(false); + return info.AuthorizationHeaderValue!; } /// @@ -72,88 +76,162 @@ public async Task CreateAuthorizationHeaderAsync( ClaimsPrincipal? claimsPrincipal = null, CancellationToken cancellationToken = default) { - Client.AuthenticationResult result; - var newTokenAcquisitionOptions = CreateTokenAcquisitionOptionsFromApiOptions(downstreamApiOptions, cancellationToken); + var info = await BuildHeaderInformationAsync( + scopes, + downstreamApiOptions, + claimsPrincipal, + forceAppToken: IsAppTokenRequest(downstreamApiOptions), + cancellationToken).ConfigureAwait(false); + return info.AuthorizationHeaderValue!; + } + + // --------------------------------------------------------------------- + // IAuthorizationHeaderProvider2 (Abstractions 12.3.0+) — preferred surface. + // Returns the full AuthorizationHeaderInformation (header value, binding + // certificate, metadata) wrapped in an OperationResult. + // --------------------------------------------------------------------- + + /// + public async Task> CreateAuthorizationHeaderInformationForUserAsync( + IEnumerable scopes, + AuthorizationHeaderProviderOptions? authorizationHeaderProviderOptions = null, + ClaimsPrincipal? claimsPrincipal = default, + CancellationToken cancellationToken = default) + { + var info = await BuildHeaderInformationAsync( + scopes, + authorizationHeaderProviderOptions, + claimsPrincipal, + forceAppToken: false, + cancellationToken).ConfigureAwait(false); + return new OperationResult(info); + } + + /// + public async Task> CreateAuthorizationHeaderInformationForAppAsync( + string scopes, + AuthorizationHeaderProviderOptions? downstreamApiOptions = null, + CancellationToken cancellationToken = default) + { + var info = await BuildHeaderInformationAsync( + new[] { scopes }, + downstreamApiOptions, + claimsPrincipal: null, + forceAppToken: true, + cancellationToken).ConfigureAwait(false); + return new OperationResult(info); + } + + /// + public async Task> CreateAuthorizationHeaderInformationAsync( + IEnumerable scopes, + AuthorizationHeaderProviderOptions? options = null, + ClaimsPrincipal? claimsPrincipal = null, + CancellationToken cancellationToken = default) + { + var info = await BuildHeaderInformationAsync( + scopes, + options, + claimsPrincipal, + forceAppToken: IsAppTokenRequest(options), + cancellationToken).ConfigureAwait(false); + return new OperationResult(info); + } + + // --------------------------------------------------------------------- + // IBoundAuthorizationHeaderProvider — kept for source/binary compat. + // New code should call IAuthorizationHeaderProvider2 instead. The body + // is now a thin adapter over the same engine so behavior is identical. + // --------------------------------------------------------------------- + + /// + /// + /// Retained for backward compatibility. Prefer + /// + /// for new code; both paths share the same implementation. + /// + public async Task> CreateBoundAuthorizationHeaderAsync( + DownstreamApiOptions downstreamApiOptions, + ClaimsPrincipal? claimsPrincipal = null, + CancellationToken cancellationToken = default) + { + var info = await BuildHeaderInformationAsync( + downstreamApiOptions?.Scopes ?? Enumerable.Empty(), + downstreamApiOptions, + claimsPrincipal, + forceAppToken: IsAppTokenRequest(downstreamApiOptions), + cancellationToken).ConfigureAwait(false); + return new OperationResult(info); + } + + // --------------------------------------------------------------------- + // Engine: single code path used by every public method above. + // --------------------------------------------------------------------- - // Previously, with the API name we were able to distinguish between app and user token acquisition - // This context is missing in the new API, so can we enforce that downstreamApiOptions.RequestAppToken - // needs to be set to true to acquire a token for the app. We cannot rely on ClaimsPrincipal as it can be null for user token acquisition. + private async Task BuildHeaderInformationAsync( + IEnumerable scopes, + AuthorizationHeaderProviderOptions? options, + ClaimsPrincipal? claimsPrincipal, + bool forceAppToken, + CancellationToken cancellationToken) + { + bool isTokenBinding = string.Equals(options?.ProtocolScheme, TokenBindingProtocolScheme, StringComparison.OrdinalIgnoreCase); + + // Token binding (mTLS PoP) currently supports app tokens only. + if (isTokenBinding && !forceAppToken) + { + throw new ArgumentException( + IDWebErrorMessage.TokenBindingRequiresEnabledAppTokenAcquisition, + nameof(options.RequestAppToken)); + } + + var newTokenAcquisitionOptions = CreateTokenAcquisitionOptionsFromApiOptions(options, cancellationToken); + + // Previously, with the API name we were able to distinguish between app and user token acquisition. + // This context is missing in the new API, so we rely on AuthorizationHeaderProviderOptions.RequestAppToken + // (or a ManagedIdentity binding) to switch into the app flow. We cannot rely on ClaimsPrincipal as it can be + // null for user token acquisition. // DevEx Before: // await authorizationHeaderProvider.CreateAuthorizationHeaderForAppAsync("https://graph.microsoft.com/.default").ConfigureAwait(false); // DevEx with the new API: // await authorizationHeaderProvider.CreateAuthorizationHeaderAsync( // new [] { "https://graph.microsoft.com/.default" }, // new AuthorizationHeaderProviderOptions { RequestAppToken = true }).ConfigureAwait(false); - if (downstreamApiOptions != null && (downstreamApiOptions.RequestAppToken || downstreamApiOptions.AcquireTokenOptions?.ManagedIdentity != null)) + Client.AuthenticationResult result; + if (forceAppToken) { result = await _tokenAcquisition.GetAuthenticationResultForAppAsync( scopes.FirstOrDefault()!, - downstreamApiOptions?.AcquireTokenOptions.AuthenticationOptionsName, - downstreamApiOptions?.AcquireTokenOptions.Tenant, + options?.AcquireTokenOptions.AuthenticationOptionsName, + options?.AcquireTokenOptions.Tenant, newTokenAcquisitionOptions).ConfigureAwait(false); } else { result = await _tokenAcquisition.GetAuthenticationResultForUserAsync( scopes, - downstreamApiOptions?.AcquireTokenOptions?.AuthenticationOptionsName, - downstreamApiOptions?.AcquireTokenOptions?.Tenant, - downstreamApiOptions?.AcquireTokenOptions?.UserFlow, + options?.AcquireTokenOptions?.AuthenticationOptionsName, + options?.AcquireTokenOptions?.Tenant, + options?.AcquireTokenOptions?.UserFlow, claimsPrincipal, newTokenAcquisitionOptions).ConfigureAwait(false); } - UpdateOriginalTokenAcquisitionOptions(downstreamApiOptions?.AcquireTokenOptions, newTokenAcquisitionOptions); - return result.CreateAuthorizationHeader(); - } + UpdateOriginalTokenAcquisitionOptions(options?.AcquireTokenOptions, newTokenAcquisitionOptions); - /// - public async Task> CreateBoundAuthorizationHeaderAsync( - DownstreamApiOptions downstreamApiOptions, - ClaimsPrincipal? claimsPrincipal = null, - CancellationToken cancellationToken = default) - { - if (!string.Equals(downstreamApiOptions?.ProtocolScheme, TokenBindingProtocolScheme, StringComparison.OrdinalIgnoreCase)) - { - var authorizationHeaderValue = await CreateAuthorizationHeaderAsync( - downstreamApiOptions?.Scopes ?? Enumerable.Empty(), - downstreamApiOptions, - claimsPrincipal, - cancellationToken).ConfigureAwait(false); - - var result = new AuthorizationHeaderInformation() - { - AuthorizationHeaderValue = authorizationHeaderValue, - BindingCertificate = null, - }; - - return new(result); - } - - // Token binding flow currently supports only app tokens. - if (!(downstreamApiOptions?.RequestAppToken ?? false)) + return new AuthorizationHeaderInformation { - throw new ArgumentException(IDWebErrorMessage.TokenBindingRequiresEnabledAppTokenAcquisition, nameof(downstreamApiOptions.RequestAppToken)); - } - - var newTokenAcquisitionOptions = CreateTokenAcquisitionOptionsFromApiOptions(downstreamApiOptions, cancellationToken); - - var tokenAcquisitionResult = await _tokenAcquisition.GetAuthenticationResultForAppAsync( - downstreamApiOptions?.Scopes?.FirstOrDefault()!, - downstreamApiOptions?.AcquireTokenOptions.AuthenticationOptionsName, - downstreamApiOptions?.AcquireTokenOptions.Tenant, - newTokenAcquisitionOptions).ConfigureAwait(false); - - UpdateOriginalTokenAcquisitionOptions(downstreamApiOptions?.AcquireTokenOptions, newTokenAcquisitionOptions); - - var authorizationHeader = tokenAcquisitionResult.CreateAuthorizationHeader(); - var authorizationHeaderInformation = new AuthorizationHeaderInformation() - { - AuthorizationHeaderValue = authorizationHeader, - BindingCertificate = tokenAcquisitionResult.BindingCertificate + AuthorizationHeaderValue = result.CreateAuthorizationHeader(), + BindingCertificate = isTokenBinding ? result.BindingCertificate : null, + Metadata = AcquireTokenResultFactory.GetMetadata(result), + AdditionalResponseParameters = result.AdditionalResponseParameters, }; + } - return new(authorizationHeaderInformation); + private static bool IsAppTokenRequest(AuthorizationHeaderProviderOptions? options) + { + return options != null + && (options.RequestAppToken || options.AcquireTokenOptions?.ManagedIdentity != null); } private static TokenAcquisitionOptions CreateTokenAcquisitionOptionsFromApiOptions( diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/MicrosoftIdentityMessageHandler.cs b/src/Microsoft.Identity.Web.TokenAcquisition/MicrosoftIdentityMessageHandler.cs index bcdcc9fa4..3d558651e 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/MicrosoftIdentityMessageHandler.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/MicrosoftIdentityMessageHandler.cs @@ -484,6 +484,31 @@ private async Task AcquireAuthArtifactsAsync( try { // mTLS PoP (Proof-of-Possession) token binding: bound bearer header + binding certificate. + // Prefer the IAuthorizationHeaderProvider2 surface (Abstractions 12.3.0+); fall back to the + // legacy IBoundAuthorizationHeaderProvider for custom providers that haven't been updated yet. + if (string.Equals(options.ProtocolScheme, Constants.TokenBindingProtocolScheme, StringComparison.OrdinalIgnoreCase) + && _headerProvider is IAuthorizationHeaderProvider2 boundProviderV2) + { + var downstreamApiOptions = CreateDownstreamApiOptions(options, scopes); + var boundResult = await boundProviderV2.CreateAuthorizationHeaderInformationAsync( + downstreamApiOptions.Scopes ?? Enumerable.Empty(), + downstreamApiOptions, + claimsPrincipal: null, + cancellationToken).ConfigureAwait(false); + + if (!boundResult.Succeeded) + { + throw new MicrosoftIdentityAuthenticationException( + "Failed to acquire bound authorization header for mTLS PoP."); + } + + return new AuthArtifacts( + authHeader: boundResult.Result?.AuthorizationHeaderValue!, + bindingCertificate: boundResult.Result?.BindingCertificate, + loaderParameters: null, + credentialDescription: null); + } + if (string.Equals(options.ProtocolScheme, Constants.TokenBindingProtocolScheme, StringComparison.OrdinalIgnoreCase) && _headerProvider is IBoundAuthorizationHeaderProvider boundProvider) { @@ -628,7 +653,8 @@ private async Task SendOnceAsync( /// /// /// When token binding (mTLS PoP) is configured via , - /// uses to acquire a bound token and sends the request + /// uses (or, as a fallback, the legacy + /// ) to acquire a bound token and sends the request /// through an mTLS-configured HTTP client with the binding certificate. /// /// @@ -748,7 +774,9 @@ private static MicrosoftIdentityMessageHandlerOptions CreateOptionsWithChallenge /// Creates a from /// (the common base class shared by both and /// ) for use with - /// . + /// + /// (or, as a fallback, the legacy + /// ). /// /// The authorization header provider options (common base class). /// The scopes for token acquisition. 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..23ba60722 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,4 @@ #nullable enable +virtual Microsoft.Identity.Web.Extensibility.BaseAuthorizationHeaderProvider.CreateAuthorizationHeaderInformationAsync(System.Collections.Generic.IEnumerable! scopes, Microsoft.Identity.Abstractions.AuthorizationHeaderProviderOptions? options = null, System.Security.Claims.ClaimsPrincipal? claimsPrincipal = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task>! +virtual Microsoft.Identity.Web.Extensibility.BaseAuthorizationHeaderProvider.CreateAuthorizationHeaderInformationForAppAsync(string! scopes, Microsoft.Identity.Abstractions.AuthorizationHeaderProviderOptions? downstreamApiOptions = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task>! +virtual Microsoft.Identity.Web.Extensibility.BaseAuthorizationHeaderProvider.CreateAuthorizationHeaderInformationForUserAsync(System.Collections.Generic.IEnumerable! scopes, Microsoft.Identity.Abstractions.AuthorizationHeaderProviderOptions? authorizationHeaderProviderOptions = null, System.Security.Claims.ClaimsPrincipal? claimsPrincipal = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task>! 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..23ba60722 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,4 @@ #nullable enable +virtual Microsoft.Identity.Web.Extensibility.BaseAuthorizationHeaderProvider.CreateAuthorizationHeaderInformationAsync(System.Collections.Generic.IEnumerable! scopes, Microsoft.Identity.Abstractions.AuthorizationHeaderProviderOptions? options = null, System.Security.Claims.ClaimsPrincipal? claimsPrincipal = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task>! +virtual Microsoft.Identity.Web.Extensibility.BaseAuthorizationHeaderProvider.CreateAuthorizationHeaderInformationForAppAsync(string! scopes, Microsoft.Identity.Abstractions.AuthorizationHeaderProviderOptions? downstreamApiOptions = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task>! +virtual Microsoft.Identity.Web.Extensibility.BaseAuthorizationHeaderProvider.CreateAuthorizationHeaderInformationForUserAsync(System.Collections.Generic.IEnumerable! scopes, Microsoft.Identity.Abstractions.AuthorizationHeaderProviderOptions? authorizationHeaderProviderOptions = null, System.Security.Claims.ClaimsPrincipal? claimsPrincipal = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task>! diff --git a/tests/Microsoft.Identity.Web.Test/DefaultAuthorizationHeaderProviderV2Tests.cs b/tests/Microsoft.Identity.Web.Test/DefaultAuthorizationHeaderProviderV2Tests.cs new file mode 100644 index 000000000..338e2f7b5 --- /dev/null +++ b/tests/Microsoft.Identity.Web.Test/DefaultAuthorizationHeaderProviderV2Tests.cs @@ -0,0 +1,161 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Client; +using Microsoft.Identity.Client.Cache; +using Microsoft.Identity.Web.Test.Common; +using NSubstitute; +using Xunit; + +namespace Microsoft.Identity.Web.Test +{ + [Collection(nameof(TokenAcquirerFactorySingletonProtection))] + public class DefaultAuthorizationHeaderProviderV2Tests + { + private readonly ITokenAcquisition _tokenAcquisition; + private readonly IAuthorizationHeaderProvider2 _provider; + + public DefaultAuthorizationHeaderProviderV2Tests() + { + _tokenAcquisition = Substitute.For(); + _provider = new DefaultAuthorizationHeaderProvider(_tokenAcquisition); + } + + private static AuthenticationResult NewAuthResult(string token = "test_access_token") + => new( + token, + isExtendedLifeTimeToken: false, + uniqueId: null, + expiresOn: DateTimeOffset.UtcNow.AddHours(1), + extendedExpiresOn: DateTimeOffset.UtcNow.AddHours(1), + tenantId: "test_tenant_id", + account: null, + idToken: null, + scopes: new[] { "scope1" }, + correlationId: Guid.NewGuid()); + + [Fact] + public async Task CreateAuthorizationHeaderInformationForUserAsync_ReturnsBearerHeaderAsync() + { + // Arrange + _tokenAcquisition + .GetAuthenticationResultForUserAsync( + Arg.Any>(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(NewAuthResult())); + + // Act + var result = await _provider.CreateAuthorizationHeaderInformationForUserAsync( + new[] { "scope" }, authorizationHeaderProviderOptions: null, new ClaimsPrincipal(), CancellationToken.None); + + // Assert + Assert.True(result.Succeeded); + Assert.Equal("Bearer test_access_token", result.Result!.AuthorizationHeaderValue); + Assert.Null(result.Result.BindingCertificate); + } + + [Fact] + public async Task CreateAuthorizationHeaderInformationForAppAsync_ReturnsBearerHeaderAsync() + { + // Arrange + _tokenAcquisition + .GetAuthenticationResultForAppAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(NewAuthResult())); + + // Act + var result = await _provider.CreateAuthorizationHeaderInformationForAppAsync( + "https://graph.microsoft.com/.default", downstreamApiOptions: null, CancellationToken.None); + + // Assert + Assert.True(result.Succeeded); + Assert.Equal("Bearer test_access_token", result.Result!.AuthorizationHeaderValue); + Assert.Null(result.Result.BindingCertificate); + } + + [Fact] + public async Task CreateAuthorizationHeaderInformationAsync_WithRequestAppToken_TakesAppFlowAsync() + { + // Arrange + _tokenAcquisition + .GetAuthenticationResultForAppAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(NewAuthResult())); + + var options = new AuthorizationHeaderProviderOptions { RequestAppToken = true }; + + // Act + var result = await _provider.CreateAuthorizationHeaderInformationAsync( + new[] { "https://graph.microsoft.com/.default" }, options, claimsPrincipal: null, CancellationToken.None); + + // Assert + Assert.True(result.Succeeded); + Assert.Equal("Bearer test_access_token", result.Result!.AuthorizationHeaderValue); + await _tokenAcquisition.Received(1).GetAuthenticationResultForAppAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task CreateAuthorizationHeaderInformationAsync_TokenBindingWithoutAppToken_ThrowsAsync() + { + // Arrange + var options = new DownstreamApiOptions + { + Scopes = new[] { "scope" }, + ProtocolScheme = "MTLS_POP", + RequestAppToken = false + }; + + // Act / Assert + await Assert.ThrowsAsync(() => + _provider.CreateAuthorizationHeaderInformationAsync( + options.Scopes!, options, claimsPrincipal: null, CancellationToken.None)); + } + + [Fact] + public async Task CreateAuthorizationHeaderInformationForUserAsync_PropagatesMetadataAsync() + { + // Arrange — inject AdditionalResponseParameters via the MSAL ctor; metadata is set via + // reflection because MSAL's AuthenticationResultMetadata property has no public setter. + var auth = NewAuthResult(); + var metadata = new AuthenticationResultMetadata(TokenSource.Cache) + { + CacheLevel = CacheLevel.L1Cache, + DurationTotalInMs = 42, + }; + typeof(AuthenticationResult).GetProperty("AuthenticationResultMetadata")! + .SetValue(auth, metadata); + + var addl = new Dictionary { ["extra"] = "value" }; + typeof(AuthenticationResult).GetProperty("AdditionalResponseParameters")! + .SetValue(auth, addl); + + _tokenAcquisition + .GetAuthenticationResultForUserAsync( + Arg.Any>(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(auth)); + + // Act + var result = await _provider.CreateAuthorizationHeaderInformationForUserAsync( + new[] { "scope" }, authorizationHeaderProviderOptions: null, new ClaimsPrincipal(), CancellationToken.None); + + // Assert + Assert.True(result.Succeeded); + Assert.NotNull(result.Result!.Metadata); + Assert.Equal(AcquiredTokenSource.Cache, result.Result.Metadata!.TokenSource); + Assert.Equal(AcquiredTokenCacheLevel.L1Cache, result.Result.Metadata.CacheLevel); + Assert.Equal(42, result.Result.Metadata.DurationTotalInMs); + Assert.NotNull(result.Result.AdditionalResponseParameters); + Assert.Equal("value", result.Result.AdditionalResponseParameters!["extra"]); + } + } +} From 7878c2c74a7ba243feb3d86b9ef95c8bf43d5916 Mon Sep 17 00:00:00 2001 From: Nilesh Choudhary Date: Tue, 30 Jun 2026 12:27:33 +0100 Subject: [PATCH 2/2] Address PR review comments: hoist isTokenBinding, add compat comment - DownstreamApi.cs: hoist isTokenBinding once, reuse in both v2 and legacy branches for consistent operand order; add '// for backwards compatibility.' comment on the legacy IBoundAuthorizationHeaderProvider branch (bgavrilMS, gladjohn). - MicrosoftIdentityMessageHandler.cs: convert the second legacy 'if' into 'else if' of the v2 block and hoist isTokenBinding so mutual exclusivity is structural rather than depending on the early return (gladjohn). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Microsoft.Identity.Web.DownstreamApi/DownstreamApi.cs | 8 +++++--- .../MicrosoftIdentityMessageHandler.cs | 6 ++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/Microsoft.Identity.Web.DownstreamApi/DownstreamApi.cs b/src/Microsoft.Identity.Web.DownstreamApi/DownstreamApi.cs index fe6319ab6..cd53cfec0 100644 --- a/src/Microsoft.Identity.Web.DownstreamApi/DownstreamApi.cs +++ b/src/Microsoft.Identity.Web.DownstreamApi/DownstreamApi.cs @@ -715,7 +715,8 @@ public Task CallApiForAppAsync( // a binding certificate along with acquired authorization header. // Prefer the IAuthorizationHeaderProvider2 surface (Abstractions 12.3.0+); fall back to the // legacy IBoundAuthorizationHeaderProvider for custom providers that haven't been updated yet. - if (string.Equals(effectiveOptions.ProtocolScheme, Constants.TokenBindingProtocolScheme, StringComparison.OrdinalIgnoreCase) + bool isTokenBinding = string.Equals(effectiveOptions.ProtocolScheme, Constants.TokenBindingProtocolScheme, StringComparison.OrdinalIgnoreCase); + if (isTokenBinding && _authorizationHeaderProvider is IAuthorizationHeaderProvider2 boundAuthorizationHeaderProviderV2) { var authorizationHeaderResult = await boundAuthorizationHeaderProviderV2.CreateAuthorizationHeaderInformationAsync( @@ -734,8 +735,9 @@ public Task CallApiForAppAsync( authorizationHeaderInformation = authorizationHeaderResult.Result; authorizationHeader = authorizationHeaderInformation?.AuthorizationHeaderValue!; } - else if (_authorizationHeaderProvider is IBoundAuthorizationHeaderProvider boundAuthorizationHeaderBoundProvider - && string.Equals(effectiveOptions.ProtocolScheme, Constants.TokenBindingProtocolScheme, StringComparison.OrdinalIgnoreCase)) + // for backwards compatibility. + else if (isTokenBinding + && _authorizationHeaderProvider is IBoundAuthorizationHeaderProvider boundAuthorizationHeaderBoundProvider) { var authorizationHeaderResult = await boundAuthorizationHeaderBoundProvider.CreateBoundAuthorizationHeaderAsync( effectiveOptions, diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/MicrosoftIdentityMessageHandler.cs b/src/Microsoft.Identity.Web.TokenAcquisition/MicrosoftIdentityMessageHandler.cs index 3d558651e..7da700833 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/MicrosoftIdentityMessageHandler.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/MicrosoftIdentityMessageHandler.cs @@ -486,7 +486,8 @@ private async Task AcquireAuthArtifactsAsync( // mTLS PoP (Proof-of-Possession) token binding: bound bearer header + binding certificate. // Prefer the IAuthorizationHeaderProvider2 surface (Abstractions 12.3.0+); fall back to the // legacy IBoundAuthorizationHeaderProvider for custom providers that haven't been updated yet. - if (string.Equals(options.ProtocolScheme, Constants.TokenBindingProtocolScheme, StringComparison.OrdinalIgnoreCase) + bool isTokenBinding = string.Equals(options.ProtocolScheme, Constants.TokenBindingProtocolScheme, StringComparison.OrdinalIgnoreCase); + if (isTokenBinding && _headerProvider is IAuthorizationHeaderProvider2 boundProviderV2) { var downstreamApiOptions = CreateDownstreamApiOptions(options, scopes); @@ -509,7 +510,8 @@ private async Task AcquireAuthArtifactsAsync( credentialDescription: null); } - if (string.Equals(options.ProtocolScheme, Constants.TokenBindingProtocolScheme, StringComparison.OrdinalIgnoreCase) + // for backwards compatibility. + else if (isTokenBinding && _headerProvider is IBoundAuthorizationHeaderProvider boundProvider) { var downstreamApiOptions = CreateDownstreamApiOptions(options, scopes);