diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/Microsoft.Identity.Web.TokenAcquisition.csproj b/src/Microsoft.Identity.Web.TokenAcquisition/Microsoft.Identity.Web.TokenAcquisition.csproj index 556755b08..ebf5a85dd 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/Microsoft.Identity.Web.TokenAcquisition.csproj +++ b/src/Microsoft.Identity.Web.TokenAcquisition/Microsoft.Identity.Web.TokenAcquisition.csproj @@ -8,15 +8,10 @@ README.md - + true - - - @@ -39,7 +34,7 @@ \ - + @@ -66,5 +61,5 @@ - + diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net10.0/InternalAPI.Unshipped.txt b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net10.0/InternalAPI.Unshipped.txt index b16d79cf0..ac3d3ed16 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net10.0/InternalAPI.Unshipped.txt +++ b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net10.0/InternalAPI.Unshipped.txt @@ -6,6 +6,11 @@ const Microsoft.Identity.Web.Constants.InvalidClientSecret = "AADSTS7000215" -> Microsoft.Identity.Web.TokenAcquisitionExtensionOptions.InvokeOnBeforeTokenAcquisitionForOnBehalfOf(Microsoft.Identity.Client.AcquireTokenOnBehalfOfParameterBuilder! builder, Microsoft.Identity.Abstractions.AcquireTokenOptions? acquireTokenOptions, Microsoft.Identity.Web.OnBehalfOfEventArgs! eventArgs) -> void Microsoft.Identity.Web.TokenAcquisitionExtensionOptions.InvokeOnBeforeTokenAcquisitionForOnBehalfOfAsync(Microsoft.Identity.Client.AcquireTokenOnBehalfOfParameterBuilder! builder, Microsoft.Identity.Abstractions.AcquireTokenOptions? acquireTokenOptions, Microsoft.Identity.Web.OnBehalfOfEventArgs! eventArgs) -> System.Threading.Tasks.Task! Microsoft.Identity.Web.TokenAcquisitionExtensionOptions.InvokeOnBeforeOnBehalfOfInitializedAsync(Microsoft.Identity.Web.OnBehalfOfEventArgs! eventArgs) -> System.Threading.Tasks.Task! +Microsoft.Identity.Web.MicrosoftIdentityApplicationOptionsToMergedOptionsMerger +Microsoft.Identity.Web.MicrosoftIdentityApplicationOptionsToMergedOptionsMerger.MicrosoftIdentityApplicationOptionsToMergedOptionsMerger(Microsoft.Identity.Web.IMergedOptionsStore! mergedOptionsStore) -> void +Microsoft.Identity.Web.MicrosoftIdentityApplicationOptionsToMergedOptionsMerger.PostConfigure(string? name, Microsoft.Identity.Abstractions.MicrosoftIdentityApplicationOptions! options) -> void +Microsoft.Identity.Web.TokenAcquisitionExtensionOptions.InvokeOnBeforeTokenAcquisitionForOnBehalfOf(Microsoft.Identity.Client.AcquireTokenOnBehalfOfParameterBuilder! builder, Microsoft.Identity.Abstractions.AcquireTokenOptions? acquireTokenOptions, System.Security.Claims.ClaimsPrincipal! user) -> void +Microsoft.Identity.Web.TokenAcquisitionExtensionOptions.InvokeOnBeforeTokenAcquisitionForOnBehalfOfAsync(Microsoft.Identity.Client.AcquireTokenOnBehalfOfParameterBuilder! builder, Microsoft.Identity.Abstractions.AcquireTokenOptions? acquireTokenOptions, System.Security.Claims.ClaimsPrincipal! user) -> System.Threading.Tasks.Task! static Microsoft.Identity.Web.ConfidentialClientApplicationBuilderExtension.Logger.UsingCertThumbprint(Microsoft.Extensions.Logging.ILogger! logger, string? certThumbprint) -> void static readonly Microsoft.Identity.Web.Constants.s_certificateRelatedErrorCodes -> System.Collections.Generic.HashSet! static readonly Microsoft.Identity.Web.Constants.s_nonRetryableConfigErrorCodes -> System.Collections.Generic.HashSet! diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net9.0/InternalAPI.Unshipped.txt b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net9.0/InternalAPI.Unshipped.txt index b16d79cf0..4bcd2595f 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net9.0/InternalAPI.Unshipped.txt +++ b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net9.0/InternalAPI.Unshipped.txt @@ -6,6 +6,9 @@ const Microsoft.Identity.Web.Constants.InvalidClientSecret = "AADSTS7000215" -> Microsoft.Identity.Web.TokenAcquisitionExtensionOptions.InvokeOnBeforeTokenAcquisitionForOnBehalfOf(Microsoft.Identity.Client.AcquireTokenOnBehalfOfParameterBuilder! builder, Microsoft.Identity.Abstractions.AcquireTokenOptions? acquireTokenOptions, Microsoft.Identity.Web.OnBehalfOfEventArgs! eventArgs) -> void Microsoft.Identity.Web.TokenAcquisitionExtensionOptions.InvokeOnBeforeTokenAcquisitionForOnBehalfOfAsync(Microsoft.Identity.Client.AcquireTokenOnBehalfOfParameterBuilder! builder, Microsoft.Identity.Abstractions.AcquireTokenOptions? acquireTokenOptions, Microsoft.Identity.Web.OnBehalfOfEventArgs! eventArgs) -> System.Threading.Tasks.Task! Microsoft.Identity.Web.TokenAcquisitionExtensionOptions.InvokeOnBeforeOnBehalfOfInitializedAsync(Microsoft.Identity.Web.OnBehalfOfEventArgs! eventArgs) -> System.Threading.Tasks.Task! +Microsoft.Identity.Web.Internal.MicrosoftIdentityOptionsBinder +Microsoft.Identity.Web.TokenAcquisitionExtensionOptions.InvokeOnBeforeTokenAcquisitionForOnBehalfOf(Microsoft.Identity.Client.AcquireTokenOnBehalfOfParameterBuilder! builder, Microsoft.Identity.Abstractions.AcquireTokenOptions? acquireTokenOptions, System.Security.Claims.ClaimsPrincipal! user) -> void +Microsoft.Identity.Web.TokenAcquisitionExtensionOptions.InvokeOnBeforeTokenAcquisitionForOnBehalfOfAsync(Microsoft.Identity.Client.AcquireTokenOnBehalfOfParameterBuilder! builder, Microsoft.Identity.Abstractions.AcquireTokenOptions? acquireTokenOptions, System.Security.Claims.ClaimsPrincipal! user) -> System.Threading.Tasks.Task! static Microsoft.Identity.Web.ConfidentialClientApplicationBuilderExtension.Logger.UsingCertThumbprint(Microsoft.Extensions.Logging.ILogger! logger, string? certThumbprint) -> void static readonly Microsoft.Identity.Web.Constants.s_certificateRelatedErrorCodes -> System.Collections.Generic.HashSet! static readonly Microsoft.Identity.Web.Constants.s_nonRetryableConfigErrorCodes -> System.Collections.Generic.HashSet! diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/WebApiBuilders.cs b/src/Microsoft.Identity.Web.TokenAcquisition/WebApiBuilders.cs index c482a62f5..eab099a5a 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/WebApiBuilders.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/WebApiBuilders.cs @@ -36,8 +36,6 @@ public static MicrosoftIdentityAppCallsWebApiAuthenticationBuilder EnableTokenAc { if (configuration != null) { - // TODO: This never was right. And the configureConfidentialClientApplicationOptions delegate is not used - // services.Configure(authenticationScheme, configuration); services.Configure(authenticationScheme, options => { configuration.Bind(options); }); diff --git a/src/Microsoft.Identity.Web/Internal/IdentityOptionsHelpers.cs b/src/Microsoft.Identity.Web/Internal/IdentityOptionsHelpers.cs new file mode 100644 index 000000000..6623fc91b --- /dev/null +++ b/src/Microsoft.Identity.Web/Internal/IdentityOptionsHelpers.cs @@ -0,0 +1,193 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Identity.Abstractions; +using Microsoft.IdentityModel.Tokens; + +#if !NETSTANDARD2_0 && !NET462 && !NET472 +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Identity.Web.Resource; +#endif + +namespace Microsoft.Identity.Web.Internal +{ + /// + /// Shared helper methods for identity options configuration and validation. + /// Used by both traditional (MergedOptions) and AOT-compatible paths. + /// + internal static class IdentityOptionsHelpers + { + /// + /// Builds the authority URL from the given application options. + /// Handles AAD, B2C, and CIAM scenarios. + /// + /// The application options containing instance, tenant, and domain information. + /// The constructed authority URL. + internal static string BuildAuthority(MicrosoftIdentityApplicationOptions options) + { + if (string.IsNullOrEmpty(options.Instance)) + { + throw new ArgumentNullException(nameof(options.Instance)); + } + +#if !NETSTANDARD2_0 && !NET462 && !NET472 + Uri baseUri = new Uri(options.Instance); + var domain = options.Domain; + var tenantId = options.TenantId; + + // B2C is detected by presence of SignUpSignInPolicyId + bool isB2C = !string.IsNullOrWhiteSpace(options.SignUpSignInPolicyId); + + if (isB2C) + { + var userFlow = options.SignUpSignInPolicyId; + return new Uri(baseUri, new PathString($"{baseUri.PathAndQuery}{domain}/{userFlow}/v2.0")).ToString(); + } + + return new Uri(baseUri, new PathString($"{baseUri.PathAndQuery}{tenantId}/v2.0")).ToString(); +#else + // For non-ASP.NET Core, use simple string concatenation + // options.Instance is guaranteed to be non-null because we check it at the start of the method + var instance = options.Instance!.TrimEnd('/'); + bool isB2C = !string.IsNullOrWhiteSpace(options.SignUpSignInPolicyId); + + if (isB2C) + { + return $"{instance}/{options.Domain}/{options.SignUpSignInPolicyId}/v2.0"; + } + + return $"{instance}/{options.TenantId}/v2.0"; +#endif + } + + +#if !NETSTANDARD2_0 && !NET462 && !NET472 + /// + /// Configures issuer validation on the JWT bearer options. + /// Sets up multi-tenant issuer validation logic that accepts both v1.0 and v2.0 tokens. + /// If the developer has already registered an IssuerValidator, it will not be overwritten. + /// + /// The JWT bearer options containing token validation parameters and authority. + /// The service provider to resolve the issuer validator factory. + internal static void ConfigureIssuerValidation( + JwtBearerOptions options, + IServiceProvider serviceProvider) + { + if (options.TokenValidationParameters.ValidateIssuer && + options.TokenValidationParameters.IssuerValidator == null) + { + var microsoftIdentityIssuerValidatorFactory = + serviceProvider.GetRequiredService(); + + options.TokenValidationParameters.IssuerValidator = + microsoftIdentityIssuerValidatorFactory.GetAadIssuerValidator(options.Authority!).Validate; + } + } + + /// + /// Ensures the JwtBearerOptions.Events object exists and wires up the + /// ConfigurationManager on the OnMessageReceived event. + /// + /// The JWT bearer options to configure. + internal static void InitializeJwtBearerEvents(JwtBearerOptions options) + { + if (options.Events == null) + { + options.Events = new JwtBearerEvents(); + } + + var existingOnMessageReceived = options.Events.OnMessageReceived; + options.Events.OnMessageReceived = async context => + { + context.Options.TokenValidationParameters.ConfigurationManager ??= + options.ConfigurationManager as BaseConfigurationManager; + + if (existingOnMessageReceived != null) + { + await existingOnMessageReceived(context).ConfigureAwait(false); + } + }; + } + + /// + /// Configures audience validation on the token validation parameters if not already configured. + /// Sets up custom validator for handling v1.0/v2.0 and B2C tokens correctly. + /// This is AOT-compatible as it directly sets up the validator without using reflection or MicrosoftIdentityOptions. + /// + /// The token validation parameters to configure. + /// The application (client) ID. + /// Whether the application targets Azure AD B2C. + internal static void ConfigureAudienceValidation( + TokenValidationParameters validationParameters, + string? clientId, + bool isB2C) + { + // Skip if audience validation is already configured by the caller + if (validationParameters.AudienceValidator != null || + validationParameters.ValidAudience != null || + validationParameters.ValidAudiences != null) + { + return; + } + + // Set up the audience validator directly without converting to MicrosoftIdentityOptions + validationParameters.AudienceValidator = (audiences, securityToken, validationParams) => + { + var claims = securityToken switch + { + System.IdentityModel.Tokens.Jwt.JwtSecurityToken jwtSecurityToken => jwtSecurityToken.Claims, + Microsoft.IdentityModel.JsonWebTokens.JsonWebToken jwtWebToken => jwtWebToken.Claims, + _ => throw new SecurityTokenValidationException(IDWebErrorMessage.TokenIsNotJwtToken), + }; + + validationParams.AudienceValidator = null; + + // Case of a default App ID URI (the developer did not provide explicit valid audience(s)) + if (string.IsNullOrEmpty(validationParams.ValidAudience) && + validationParams.ValidAudiences == null) + { + // handle v2.0 access token or Azure AD B2C tokens (even if v1.0) + if (isB2C || claims.Any(c => c.Type == Constants.Version && c.Value == Constants.V2)) + { + validationParams.ValidAudience = $"{clientId}"; + } + // handle v1.0 access token + else if (claims.Any(c => c.Type == Constants.Version && c.Value == Constants.V1)) + { + validationParams.ValidAudience = $"api://{clientId}"; + } + } + + Validators.ValidateAudience(audiences, securityToken, validationParams); + return true; + }; + } + + /// + /// Chains a handler onto the OnTokenValidated event to store the token for OBO scenarios. + /// + /// The existing OnTokenValidated handler, if any. + /// A new handler that stores the token and then calls the existing handler. + internal static Func ChainTokenStorageHandler(Func? existingHandler) + { + return async context => + { + // Only pass through a token if it is of an expected type + context.HttpContext.StoreTokenUsedToCallWebAPI( + context.SecurityToken is System.IdentityModel.Tokens.Jwt.JwtSecurityToken or + Microsoft.IdentityModel.JsonWebTokens.JsonWebToken ? context.SecurityToken : null); + + if (existingHandler != null) + { + await existingHandler(context).ConfigureAwait(false); + } + }; + } +#endif + } +} diff --git a/src/Microsoft.Identity.Web/Policy/RequiredScopeOrAppPermissionExtensions.cs b/src/Microsoft.Identity.Web/Policy/RequiredScopeOrAppPermissionExtensions.cs index fc8e8b278..fc81ba553 100644 --- a/src/Microsoft.Identity.Web/Policy/RequiredScopeOrAppPermissionExtensions.cs +++ b/src/Microsoft.Identity.Web/Policy/RequiredScopeOrAppPermissionExtensions.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; @@ -22,8 +21,6 @@ public static class RequiredScopeOrAppPermissionExtensions /// /// The services being configured. /// Services. - [RequiresUnreferencedCode("Calls Microsoft.Extensions.Configuration.ConfigurationBinder.GetValue")] - [RequiresDynamicCode("Calls Microsoft.Extensions.Configuration.ConfigurationBinder.GetValue")] public static IServiceCollection AddRequiredScopeOrAppPermissionAuthorization(this IServiceCollection services) { services.AddAuthorization(); diff --git a/src/Microsoft.Identity.Web/Policy/ScopeOrAppPermissionAuthorizationHandler.cs b/src/Microsoft.Identity.Web/Policy/ScopeOrAppPermissionAuthorizationHandler.cs index 82740ddff..0cbb1f275 100644 --- a/src/Microsoft.Identity.Web/Policy/ScopeOrAppPermissionAuthorizationHandler.cs +++ b/src/Microsoft.Identity.Web/Policy/ScopeOrAppPermissionAuthorizationHandler.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Security.Claims; using System.Threading.Tasks; @@ -18,8 +17,6 @@ namespace Microsoft.Identity.Web /// Scope or app permission authorization handler that needs to be called for a specific requirement type. /// In this case, . /// - [RequiresUnreferencedCode("Calls Microsoft.Extensions.Configuration.ConfigurationBinder.GetValue")] - [RequiresDynamicCode("Calls Microsoft.Extensions.Configuration.ConfigurationBinder.GetValue")] internal class ScopeOrAppPermissionAuthorizationHandler : AuthorizationHandler { private readonly IConfiguration _configuration; @@ -77,7 +74,7 @@ protected override Task HandleRequirementAsync( if (appPermissionConfigurationKey != null) { - appPermissions = _configuration.GetValue(appPermissionConfigurationKey)?.Split(' '); + appPermissions = _configuration[appPermissionConfigurationKey]?.Split(' '); } if (appPermissions is null) diff --git a/src/Microsoft.Identity.Web/PostConfigureOptions/MicrosoftIdentityJwtBearerOptionsPostConfigurator.cs b/src/Microsoft.Identity.Web/PostConfigureOptions/MicrosoftIdentityJwtBearerOptionsPostConfigurator.cs new file mode 100644 index 000000000..470daa442 --- /dev/null +++ b/src/Microsoft.Identity.Web/PostConfigureOptions/MicrosoftIdentityJwtBearerOptionsPostConfigurator.cs @@ -0,0 +1,166 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#if NET10_0_OR_GREATER + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography.X509Certificates; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Web.Internal; +using Microsoft.Identity.Web.Resource; +using Microsoft.IdentityModel.Tokens; +using Microsoft.IdentityModel.Validators; + +namespace Microsoft.Identity.Web.PostConfigureOptions +{ + /// + /// Post-configures JwtBearerOptions for AOT-compatible path using MicrosoftIdentityApplicationOptions. + /// Performs validation, configuration, and OBO token storage setup. + /// + internal sealed class MicrosoftIdentityJwtBearerOptionsPostConfigurator : IPostConfigureOptions + { + private readonly IOptionsMonitor _appOptionsMonitor; + private readonly IServiceProvider _serviceProvider; + + public MicrosoftIdentityJwtBearerOptionsPostConfigurator( + IOptionsMonitor appOptionsMonitor, + IServiceProvider serviceProvider) + { + _appOptionsMonitor = appOptionsMonitor; + _serviceProvider = serviceProvider; + } + + public void PostConfigure(string? name, JwtBearerOptions options) + { + var appOptions = _appOptionsMonitor.Get(name ?? string.Empty); + + // Skip if not configured via our AOT path (no ClientId means not configured) + if (string.IsNullOrEmpty(appOptions.ClientId)) + { + return; + } + + // 1. VALIDATE (fail-fast with complete configuration) + ValidateRequiredOptions(appOptions); + + // 2. CONFIGURE (respect customer overrides) + + // Note: 'options.Authority' is set during the Configure phase in AddMicrosoftIdentityWebApiAot, + // before any PostConfigure runs to ensure ASP.NET's built-in JwtBearerPostConfigureOptions can + // create the ConfigurationManager from it. + + // Configure audience validation if not already set + IdentityOptionsHelpers.ConfigureAudienceValidation( + options.TokenValidationParameters, + appOptions.ClientId, + !string.IsNullOrWhiteSpace(appOptions.SignUpSignInPolicyId)); + + // Configure issuer validation + IdentityOptionsHelpers.ConfigureIssuerValidation(options, _serviceProvider); + + // Configure token decryption if credentials provided + if (appOptions.TokenDecryptionCredentials != null && appOptions.TokenDecryptionCredentials.Any()) + { + // Extract user assigned identity client ID from credentials if present + var managedIdentityCredential = appOptions.TokenDecryptionCredentials + .OfType() + .FirstOrDefault(c => !string.IsNullOrEmpty(c.ManagedIdentityClientId)); + + if (managedIdentityCredential != null) + { + DefaultCertificateLoader.UserAssignedManagedIdentityClientId = managedIdentityCredential.ManagedIdentityClientId; + } + + IEnumerable certificates = DefaultCertificateLoader.LoadAllCertificates( + appOptions.TokenDecryptionCredentials.OfType()); + IEnumerable keys = certificates.Select(c => new X509SecurityKey(c)); + options.TokenValidationParameters.TokenDecryptionKeys = keys; + } + + // Enable AAD signing key issuer validation + options.TokenValidationParameters.EnableAadSigningKeyIssuerValidation(); + + // Ensure events and wire up ConfigurationManager on message received + IdentityOptionsHelpers.InitializeJwtBearerEvents(options); + + // Add claims validation if not allowing ACL authorization + if (!appOptions.AllowWebApiToBeAuthorizedByACL) + { + MicrosoftIdentityWebApiAuthenticationBuilderExtensions.ChainOnTokenValidatedEventForClaimsValidation( + options.Events, name ?? JwtBearerDefaults.AuthenticationScheme); + } + + // ========================================================= + // 3. CHAIN OnTokenValidated (always - required for OBO) + // ========================================================= + options.Events.OnTokenValidated = IdentityOptionsHelpers.ChainTokenStorageHandler( + options.Events.OnTokenValidated); + } + + /// + /// Validates that required options are present based on the configuration scenario. + /// + /// The application options to validate. + /// Thrown when required options are missing. + internal static void ValidateRequiredOptions(MicrosoftIdentityApplicationOptions options) + { + if (string.IsNullOrEmpty(options.ClientId)) + { + throw new ArgumentNullException( + nameof(options.ClientId), + string.Format( + System.Globalization.CultureInfo.InvariantCulture, + IDWebErrorMessage.ConfigurationOptionRequired, + nameof(options.ClientId))); + } + + // B2C is detected by presence of SignUpSignInPolicyId + bool isB2C = !string.IsNullOrWhiteSpace(options.SignUpSignInPolicyId); + + if (string.IsNullOrEmpty(options.Authority)) + { + if (string.IsNullOrEmpty(options.Instance)) + { + throw new ArgumentNullException( + nameof(options.Instance), + string.Format( + System.Globalization.CultureInfo.InvariantCulture, + IDWebErrorMessage.ConfigurationOptionRequired, + nameof(options.Instance))); + } + + if (isB2C) + { + if (string.IsNullOrEmpty(options.Domain)) + { + throw new ArgumentNullException( + nameof(options.Domain), + string.Format( + System.Globalization.CultureInfo.InvariantCulture, + IDWebErrorMessage.ConfigurationOptionRequired, + nameof(options.Domain))); + } + } + else + { + if (string.IsNullOrEmpty(options.TenantId)) + { + throw new ArgumentNullException( + nameof(options.TenantId), + string.Format( + System.Globalization.CultureInfo.InvariantCulture, + IDWebErrorMessage.ConfigurationOptionRequired, + nameof(options.TenantId))); + } + } + } + } + } +} + +#endif diff --git a/src/Microsoft.Identity.Web/PublicAPI/net10.0/InternalAPI.Unshipped.txt b/src/Microsoft.Identity.Web/PublicAPI/net10.0/InternalAPI.Unshipped.txt index 7dc5c5811..4dee6ecc3 100644 --- a/src/Microsoft.Identity.Web/PublicAPI/net10.0/InternalAPI.Unshipped.txt +++ b/src/Microsoft.Identity.Web/PublicAPI/net10.0/InternalAPI.Unshipped.txt @@ -1 +1,12 @@ #nullable enable +Microsoft.Identity.Web.Internal.IdentityOptionsHelpers +Microsoft.Identity.Web.PostConfigureOptions.MicrosoftIdentityJwtBearerOptionsPostConfigurator +Microsoft.Identity.Web.PostConfigureOptions.MicrosoftIdentityJwtBearerOptionsPostConfigurator.MicrosoftIdentityJwtBearerOptionsPostConfigurator(Microsoft.Extensions.Options.IOptionsMonitor! appOptionsMonitor, System.IServiceProvider! serviceProvider) -> void +Microsoft.Identity.Web.PostConfigureOptions.MicrosoftIdentityJwtBearerOptionsPostConfigurator.PostConfigure(string? name, Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerOptions! options) -> void +static Microsoft.Identity.Web.Internal.IdentityOptionsHelpers.BuildAuthority(Microsoft.Identity.Abstractions.MicrosoftIdentityApplicationOptions! options) -> string! +static Microsoft.Identity.Web.Internal.IdentityOptionsHelpers.ChainTokenStorageHandler(System.Func? existingHandler) -> System.Func! +static Microsoft.Identity.Web.Internal.IdentityOptionsHelpers.ConfigureAudienceValidation(Microsoft.IdentityModel.Tokens.TokenValidationParameters! validationParameters, string? clientId, bool isB2C) -> void +static Microsoft.Identity.Web.Internal.IdentityOptionsHelpers.ConfigureIssuerValidation(Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerOptions! options, System.IServiceProvider! serviceProvider) -> void +static Microsoft.Identity.Web.Internal.IdentityOptionsHelpers.InitializeJwtBearerEvents(Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerOptions! options) -> void +static Microsoft.Identity.Web.PostConfigureOptions.MicrosoftIdentityJwtBearerOptionsPostConfigurator.ValidateRequiredOptions(Microsoft.Identity.Abstractions.MicrosoftIdentityApplicationOptions! options) -> void + diff --git a/src/Microsoft.Identity.Web/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/src/Microsoft.Identity.Web/PublicAPI/net10.0/PublicAPI.Unshipped.txt index 7dc5c5811..b1b6c4017 100644 --- a/src/Microsoft.Identity.Web/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/src/Microsoft.Identity.Web/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +static Microsoft.Identity.Web.MicrosoftIdentityWebApiAuthenticationBuilderExtensions.AddMicrosoftIdentityWebApiAot(this Microsoft.AspNetCore.Authentication.AuthenticationBuilder! builder, System.Action! configureOptions, string! jwtBearerScheme, System.Action? configureJwtBearerOptions) -> Microsoft.AspNetCore.Authentication.AuthenticationBuilder! + diff --git a/src/Microsoft.Identity.Web/PublicAPI/net462/InternalAPI.Unshipped.txt b/src/Microsoft.Identity.Web/PublicAPI/net462/InternalAPI.Unshipped.txt index 7dc5c5811..704f2a961 100644 --- a/src/Microsoft.Identity.Web/PublicAPI/net462/InternalAPI.Unshipped.txt +++ b/src/Microsoft.Identity.Web/PublicAPI/net462/InternalAPI.Unshipped.txt @@ -1 +1,8 @@ #nullable enable +Microsoft.Identity.Web.Internal.IdentityOptionsHelpers +Microsoft.Identity.Web.PostConfigureOptions.MicrosoftIdentityJwtBearerOptionsPostConfigurator +Microsoft.Identity.Web.PostConfigureOptions.MicrosoftIdentityJwtBearerOptionsPostConfigurator.MicrosoftIdentityJwtBearerOptionsPostConfigurator(Microsoft.Extensions.Options.IOptionsMonitor! appOptionsMonitor, System.IServiceProvider! serviceProvider) -> void +Microsoft.Identity.Web.PostConfigureOptions.MicrosoftIdentityJwtBearerOptionsPostConfigurator.PostConfigure(string? name, Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerOptions! options) -> void +static Microsoft.Identity.Web.Internal.IdentityOptionsHelpers.BuildAuthority(Microsoft.Identity.Abstractions.MicrosoftIdentityApplicationOptions! options) -> string! +static Microsoft.Identity.Web.Internal.IdentityOptionsHelpers.ChainTokenStorageHandler(System.Func? existingHandler) -> System.Func! + diff --git a/src/Microsoft.Identity.Web/PublicAPI/net472/InternalAPI.Unshipped.txt b/src/Microsoft.Identity.Web/PublicAPI/net472/InternalAPI.Unshipped.txt index 7dc5c5811..704f2a961 100644 --- a/src/Microsoft.Identity.Web/PublicAPI/net472/InternalAPI.Unshipped.txt +++ b/src/Microsoft.Identity.Web/PublicAPI/net472/InternalAPI.Unshipped.txt @@ -1 +1,8 @@ #nullable enable +Microsoft.Identity.Web.Internal.IdentityOptionsHelpers +Microsoft.Identity.Web.PostConfigureOptions.MicrosoftIdentityJwtBearerOptionsPostConfigurator +Microsoft.Identity.Web.PostConfigureOptions.MicrosoftIdentityJwtBearerOptionsPostConfigurator.MicrosoftIdentityJwtBearerOptionsPostConfigurator(Microsoft.Extensions.Options.IOptionsMonitor! appOptionsMonitor, System.IServiceProvider! serviceProvider) -> void +Microsoft.Identity.Web.PostConfigureOptions.MicrosoftIdentityJwtBearerOptionsPostConfigurator.PostConfigure(string? name, Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerOptions! options) -> void +static Microsoft.Identity.Web.Internal.IdentityOptionsHelpers.BuildAuthority(Microsoft.Identity.Abstractions.MicrosoftIdentityApplicationOptions! options) -> string! +static Microsoft.Identity.Web.Internal.IdentityOptionsHelpers.ChainTokenStorageHandler(System.Func? existingHandler) -> System.Func! + diff --git a/src/Microsoft.Identity.Web/PublicAPI/net8.0/InternalAPI.Unshipped.txt b/src/Microsoft.Identity.Web/PublicAPI/net8.0/InternalAPI.Unshipped.txt index 7dc5c5811..5a9101eb1 100644 --- a/src/Microsoft.Identity.Web/PublicAPI/net8.0/InternalAPI.Unshipped.txt +++ b/src/Microsoft.Identity.Web/PublicAPI/net8.0/InternalAPI.Unshipped.txt @@ -1 +1,11 @@ #nullable enable +Microsoft.Identity.Web.Internal.IdentityOptionsHelpers +Microsoft.Identity.Web.PostConfigureOptions.MicrosoftIdentityJwtBearerOptionsPostConfigurator +Microsoft.Identity.Web.PostConfigureOptions.MicrosoftIdentityJwtBearerOptionsPostConfigurator.MicrosoftIdentityJwtBearerOptionsPostConfigurator(Microsoft.Extensions.Options.IOptionsMonitor! appOptionsMonitor, System.IServiceProvider! serviceProvider) -> void +Microsoft.Identity.Web.PostConfigureOptions.MicrosoftIdentityJwtBearerOptionsPostConfigurator.PostConfigure(string? name, Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerOptions! options) -> void +static Microsoft.Identity.Web.Internal.IdentityOptionsHelpers.BuildAuthority(Microsoft.Identity.Abstractions.MicrosoftIdentityApplicationOptions! options) -> string! +static Microsoft.Identity.Web.Internal.IdentityOptionsHelpers.ChainTokenStorageHandler(System.Func? existingHandler) -> System.Func! +static Microsoft.Identity.Web.Internal.IdentityOptionsHelpers.ConfigureAudienceValidation(Microsoft.IdentityModel.Tokens.TokenValidationParameters! validationParameters, string? clientId, bool isB2C) -> void +static Microsoft.Identity.Web.Internal.IdentityOptionsHelpers.ConfigureIssuerValidation(Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerOptions! options, System.IServiceProvider! serviceProvider) -> void +static Microsoft.Identity.Web.Internal.IdentityOptionsHelpers.InitializeJwtBearerEvents(Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerOptions! options) -> void + diff --git a/src/Microsoft.Identity.Web/PublicAPI/net9.0/InternalAPI.Unshipped.txt b/src/Microsoft.Identity.Web/PublicAPI/net9.0/InternalAPI.Unshipped.txt index 7dc5c5811..5a9101eb1 100644 --- a/src/Microsoft.Identity.Web/PublicAPI/net9.0/InternalAPI.Unshipped.txt +++ b/src/Microsoft.Identity.Web/PublicAPI/net9.0/InternalAPI.Unshipped.txt @@ -1 +1,11 @@ #nullable enable +Microsoft.Identity.Web.Internal.IdentityOptionsHelpers +Microsoft.Identity.Web.PostConfigureOptions.MicrosoftIdentityJwtBearerOptionsPostConfigurator +Microsoft.Identity.Web.PostConfigureOptions.MicrosoftIdentityJwtBearerOptionsPostConfigurator.MicrosoftIdentityJwtBearerOptionsPostConfigurator(Microsoft.Extensions.Options.IOptionsMonitor! appOptionsMonitor, System.IServiceProvider! serviceProvider) -> void +Microsoft.Identity.Web.PostConfigureOptions.MicrosoftIdentityJwtBearerOptionsPostConfigurator.PostConfigure(string? name, Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerOptions! options) -> void +static Microsoft.Identity.Web.Internal.IdentityOptionsHelpers.BuildAuthority(Microsoft.Identity.Abstractions.MicrosoftIdentityApplicationOptions! options) -> string! +static Microsoft.Identity.Web.Internal.IdentityOptionsHelpers.ChainTokenStorageHandler(System.Func? existingHandler) -> System.Func! +static Microsoft.Identity.Web.Internal.IdentityOptionsHelpers.ConfigureAudienceValidation(Microsoft.IdentityModel.Tokens.TokenValidationParameters! validationParameters, string? clientId, bool isB2C) -> void +static Microsoft.Identity.Web.Internal.IdentityOptionsHelpers.ConfigureIssuerValidation(Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerOptions! options, System.IServiceProvider! serviceProvider) -> void +static Microsoft.Identity.Web.Internal.IdentityOptionsHelpers.InitializeJwtBearerEvents(Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerOptions! options) -> void + diff --git a/src/Microsoft.Identity.Web/PublicAPI/netstandard2.0/InternalAPI.Unshipped.txt b/src/Microsoft.Identity.Web/PublicAPI/netstandard2.0/InternalAPI.Unshipped.txt index 7dc5c5811..704f2a961 100644 --- a/src/Microsoft.Identity.Web/PublicAPI/netstandard2.0/InternalAPI.Unshipped.txt +++ b/src/Microsoft.Identity.Web/PublicAPI/netstandard2.0/InternalAPI.Unshipped.txt @@ -1 +1,8 @@ #nullable enable +Microsoft.Identity.Web.Internal.IdentityOptionsHelpers +Microsoft.Identity.Web.PostConfigureOptions.MicrosoftIdentityJwtBearerOptionsPostConfigurator +Microsoft.Identity.Web.PostConfigureOptions.MicrosoftIdentityJwtBearerOptionsPostConfigurator.MicrosoftIdentityJwtBearerOptionsPostConfigurator(Microsoft.Extensions.Options.IOptionsMonitor! appOptionsMonitor, System.IServiceProvider! serviceProvider) -> void +Microsoft.Identity.Web.PostConfigureOptions.MicrosoftIdentityJwtBearerOptionsPostConfigurator.PostConfigure(string? name, Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerOptions! options) -> void +static Microsoft.Identity.Web.Internal.IdentityOptionsHelpers.BuildAuthority(Microsoft.Identity.Abstractions.MicrosoftIdentityApplicationOptions! options) -> string! +static Microsoft.Identity.Web.Internal.IdentityOptionsHelpers.ChainTokenStorageHandler(System.Func? existingHandler) -> System.Func! + diff --git a/src/Microsoft.Identity.Web/WebApiExtensions/MicrosoftIdentityWebApiAuthenticationBuilder.cs b/src/Microsoft.Identity.Web/WebApiExtensions/MicrosoftIdentityWebApiAuthenticationBuilder.cs index b321d7424..f93ee1cf5 100644 --- a/src/Microsoft.Identity.Web/WebApiExtensions/MicrosoftIdentityWebApiAuthenticationBuilder.cs +++ b/src/Microsoft.Identity.Web/WebApiExtensions/MicrosoftIdentityWebApiAuthenticationBuilder.cs @@ -93,14 +93,8 @@ internal static void CallsWebApiImplementation( { options.Events ??= new JwtBearerEvents(); - var onTokenValidatedHandler = options.Events.OnTokenValidated; - - options.Events.OnTokenValidated = async context => - { - // Only pass through a token if it is of an expected type - context.HttpContext.StoreTokenUsedToCallWebAPI(context.SecurityToken is JwtSecurityToken or JsonWebToken ? context.SecurityToken : null); - await onTokenValidatedHandler(context).ConfigureAwait(false); - }; + // Chain token storage handler using shared helper + options.Events.OnTokenValidated = Internal.IdentityOptionsHelpers.ChainTokenStorageHandler(options.Events.OnTokenValidated); }); } } diff --git a/src/Microsoft.Identity.Web/WebApiExtensions/MicrosoftIdentityWebApiAuthenticationBuilderExtensions.Aot.cs b/src/Microsoft.Identity.Web/WebApiExtensions/MicrosoftIdentityWebApiAuthenticationBuilderExtensions.Aot.cs new file mode 100644 index 000000000..ed3092b17 --- /dev/null +++ b/src/Microsoft.Identity.Web/WebApiExtensions/MicrosoftIdentityWebApiAuthenticationBuilderExtensions.Aot.cs @@ -0,0 +1,141 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#if NET10_0_OR_GREATER + +using System; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Web.Internal; +using Microsoft.Identity.Web.PostConfigureOptions; +using Microsoft.Identity.Web.Resource; + +namespace Microsoft.Identity.Web +{ + /// + /// Extensions for for startup initialization of web APIs (AOT-compatible). + /// + public static partial class MicrosoftIdentityWebApiAuthenticationBuilderExtensions + { + /// + /// Protects the web API with Microsoft identity platform (AOT-compatible). + /// This is the AOT-safe alternative to + /// and does not rely on reflection-based configuration binding. + /// + /// The to which to add this configuration. + /// The action to configure . + /// The JWT bearer scheme name to be used. By default it uses "Bearer". + /// Optional action to configure . + /// The authentication builder to chain. + /// + /// + /// This method takes an delegate that + /// the caller uses to bind configuration values. + /// + /// + /// To get AOT-safe configuration binding, enable the configuration binding source generator + /// in your project file: + /// + /// + /// <EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator> + /// + /// + /// The source generator produces compile-time binding code for + /// + /// calls, eliminating the need for reflection at runtime. + /// + /// + /// + /// The following example shows how to protect a web API using AOT-compatible configuration binding: + /// + /// var azureAdSection = builder.Configuration.GetSection("AzureAd"); + /// + /// builder.Services + /// .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + /// .AddMicrosoftIdentityWebApiAot( + /// options => azureAdSection.Bind(options), + /// JwtBearerDefaults.AuthenticationScheme, + /// configureJwtBearerOptions: null); + /// + /// + public static AuthenticationBuilder AddMicrosoftIdentityWebApiAot( + this AuthenticationBuilder builder, + Action configureOptions, + string jwtBearerScheme, + Action? configureJwtBearerOptions) + { + _ = Throws.IfNull(builder); + _ = Throws.IfNull(configureOptions); + + // Register MicrosoftIdentityApplicationOptions + builder.Services.Configure(jwtBearerScheme, configureOptions); + + // Add JWT Bearer with optional custom configuration + if (configureJwtBearerOptions != null) + { + builder.AddJwtBearer(jwtBearerScheme, configureJwtBearerOptions); + } + else + { + builder.AddJwtBearer(jwtBearerScheme); + } + + // Set Authority during Configure phase so that ASP.NET's built-in JwtBearerPostConfigureOptions can create the ConfigurationManager from it. + builder.Services.AddOptions(jwtBearerScheme) + .Configure>((jwtOptions, appOptionsMonitor) => + { + if (!string.IsNullOrEmpty(jwtOptions.Authority)) + { + return; + } + + var appOptions = appOptionsMonitor.Get(jwtBearerScheme); + if (string.IsNullOrEmpty(appOptions.ClientId)) + { + // Skip if not configured via AOT path (no ClientId means not configured) + return; + } + + if (!string.IsNullOrEmpty(appOptions.Authority)) + { + var authority = AuthorityHelpers.BuildCiamAuthorityIfNeeded(appOptions.Authority, out _); + jwtOptions.Authority = AuthorityHelpers.EnsureAuthorityIsV2(authority ?? appOptions.Authority); + } + else + { + jwtOptions.Authority = AuthorityHelpers.EnsureAuthorityIsV2( + IdentityOptionsHelpers.BuildAuthority(appOptions)); + } + }); + + // Register core services + builder.Services.AddSingleton(); + builder.Services.AddHttpContextAccessor(); + builder.Services.AddHttpClient(); + builder.Services.TryAddSingleton(); + builder.Services.AddRequiredScopeAuthorization(); + builder.Services.AddRequiredScopeOrAppPermissionAuthorization(); + + builder.Services.AddOptions(); + + // Register the post-configurator for AOT path + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, MicrosoftIdentityJwtBearerOptionsPostConfigurator>()); + + // Register the merger to bridge MicrosoftIdentityApplicationOptions to MergedOptions + // This ensures TokenAcquisition works without modification + if (!HasImplementationType(builder.Services, typeof(MicrosoftIdentityApplicationOptionsMerger))) + { + builder.Services.TryAddSingleton, + MicrosoftIdentityApplicationOptionsMerger>(); + } + + return builder; + } + } +} + +#endif diff --git a/src/Microsoft.Identity.Web/WebApiExtensions/MicrosoftIdentityWebApiAuthenticationBuilderExtensions.cs b/src/Microsoft.Identity.Web/WebApiExtensions/MicrosoftIdentityWebApiAuthenticationBuilderExtensions.cs index a92b9dd90..0e1d0ac3e 100644 --- a/src/Microsoft.Identity.Web/WebApiExtensions/MicrosoftIdentityWebApiAuthenticationBuilderExtensions.cs +++ b/src/Microsoft.Identity.Web/WebApiExtensions/MicrosoftIdentityWebApiAuthenticationBuilderExtensions.cs @@ -14,6 +14,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; +using Microsoft.Identity.Web.Internal; using Microsoft.Identity.Web.Resource; using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Validators; @@ -23,7 +24,7 @@ namespace Microsoft.Identity.Web /// /// Extensions for for startup initialization of web APIs. /// - public static class MicrosoftIdentityWebApiAuthenticationBuilderExtensions + public static partial class MicrosoftIdentityWebApiAuthenticationBuilderExtensions { /// /// Protects the web API with Microsoft identity platform (formerly Azure AD v2.0). @@ -203,20 +204,10 @@ private static void AddMicrosoftIdentityWebApiImplementation( mergedOptions); } - // If the developer registered an IssuerValidator, do not overwrite it - if (options.TokenValidationParameters.ValidateIssuer && options.TokenValidationParameters.IssuerValidator == null) - { - // Instead of using the default validation (validating against a single tenant, as we do in line of business apps), - // we inject our own multi-tenant validation logic (which even accepts both v1.0 and v2.0 tokens) - MicrosoftIdentityIssuerValidatorFactory microsoftIdentityIssuerValidatorFactory = - serviceProvider.GetRequiredService(); - - options.TokenValidationParameters.IssuerValidator = - microsoftIdentityIssuerValidatorFactory.GetAadIssuerValidator(options.Authority).Validate; - } + // Configure issuer validation + IdentityOptionsHelpers.ConfigureIssuerValidation(options, serviceProvider); // If you provide a token decryption certificate, it will be used to decrypt the token - // TODO use the credential loader if (mergedOptions.TokenDecryptionCredentials != null) { DefaultCertificateLoader.UserAssignedManagedIdentityClientId = mergedOptions.UserAssignedManagedIdentityClientId; @@ -225,17 +216,8 @@ private static void AddMicrosoftIdentityWebApiImplementation( options.TokenValidationParameters.TokenDecryptionKeys = keys; } - if (options.Events == null) - { - options.Events = new JwtBearerEvents(); - } - options.TokenValidationParameters.EnableAadSigningKeyIssuerValidation(); - options.Events.OnMessageReceived += async context => - { - context.Options.TokenValidationParameters.ConfigurationManager ??= options.ConfigurationManager as BaseConfigurationManager; - await Task.CompletedTask.ConfigureAwait(false); - }; + IdentityOptionsHelpers.InitializeJwtBearerEvents(options); // When an access token for our own web API is validated, we add it to MSAL.NET's cache so that it can // be used from the controllers. diff --git a/tests/Microsoft.Identity.Web.AotCompatibility.TestApp/Microsoft.Identity.Web.AotCompatibility.TestApp.csproj b/tests/Microsoft.Identity.Web.AotCompatibility.TestApp/Microsoft.Identity.Web.AotCompatibility.TestApp.csproj index cd3c7e41b..202d22346 100644 --- a/tests/Microsoft.Identity.Web.AotCompatibility.TestApp/Microsoft.Identity.Web.AotCompatibility.TestApp.csproj +++ b/tests/Microsoft.Identity.Web.AotCompatibility.TestApp/Microsoft.Identity.Web.AotCompatibility.TestApp.csproj @@ -1,7 +1,7 @@ - + - net9.0 + net10.0 $(TargetFrameworks); net10.0 Exe true @@ -9,6 +9,7 @@ false false false + true diff --git a/tests/Microsoft.Identity.Web.AotCompatibility.TestApp/Program.cs b/tests/Microsoft.Identity.Web.AotCompatibility.TestApp/Program.cs index eadd92b4e..1fe2d2b60 100644 --- a/tests/Microsoft.Identity.Web.AotCompatibility.TestApp/Program.cs +++ b/tests/Microsoft.Identity.Web.AotCompatibility.TestApp/Program.cs @@ -1,11 +1,31 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Identity.Web; +using Microsoft.Identity.Web.TokenCacheProviders.InMemory; + internal sealed class Program { // The code in this program is expected to be trim and AOT compatible private static int Main() { - return 100; + var builder = WebApplication.CreateSlimBuilder(); + + var azureAdSection = builder.Configuration.GetSection("AzureAd"); + builder.Services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddMicrosoftIdentityWebApiAot( + options => azureAdSection.Bind(options), + JwtBearerDefaults.AuthenticationScheme, + null); + + builder.Services.AddTokenAcquisition() + .AddInMemoryTokenCaches(); + + return 0; } } diff --git a/tests/Microsoft.Identity.Web.Test/WebApiExtensionsAotTests.cs b/tests/Microsoft.Identity.Web.Test/WebApiExtensionsAotTests.cs new file mode 100644 index 000000000..274aa1600 --- /dev/null +++ b/tests/Microsoft.Identity.Web.Test/WebApiExtensionsAotTests.cs @@ -0,0 +1,503 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#if NET10_0_OR_GREATER + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Web.PostConfigureOptions; +using Microsoft.Identity.Web.Resource; +using Microsoft.Identity.Web.Test.Common; +using Microsoft.Identity.Web.Test.Common.TestHelpers; +using NSubstitute; +using Xunit; + +namespace Microsoft.Identity.Web.Test +{ + public class WebApiExtensionsAotTests + { + private const string ConfigSectionName = "AzureAd"; + private const string JwtBearerScheme = JwtBearerDefaults.AuthenticationScheme; + + [Fact] + public void AddMicrosoftIdentityWebApiAot_WithConfigSection_RegistersServices() + { + // Arrange + var services = new ServiceCollection().AddLogging(); + + // Act + services.AddAuthentication() + .AddMicrosoftIdentityWebApiAot( + options => + { + options.Instance = TestConstants.AadInstance; + options.TenantId = TestConstants.TenantIdAsGuid; + options.ClientId = TestConstants.ClientId; + }, + JwtBearerScheme, + null); + + // Assert + var provider = services.BuildServiceProvider(); + + // Verify core services are registered + Assert.Contains(services, s => s.ServiceType == typeof(IHttpContextAccessor)); + Assert.Contains(services, s => s.ServiceType == typeof(IMergedOptionsStore)); + Assert.Contains(services, s => s.ServiceType == typeof(MicrosoftIdentityIssuerValidatorFactory)); + + // Verify MicrosoftIdentityApplicationOptions can be retrieved + var appOptions = provider.GetRequiredService>().Get(JwtBearerScheme); + Assert.NotNull(appOptions); + + // Verify JWT bearer options can be retrieved + var jwtOptions = provider.GetRequiredService>().Get(JwtBearerScheme); + Assert.NotNull(jwtOptions); + + // Verify post-configurator is registered + var postConfigurators = services.Where(s => + s.ServiceType == typeof(IPostConfigureOptions) && + (s.ImplementationFactory != null || s.ImplementationType != null)).ToList(); + Assert.NotEmpty(postConfigurators); + } + + [Fact] + public void AddMicrosoftIdentityWebApiAot_WithAction_RegistersServices() + { + // Arrange + var services = new ServiceCollection().AddLogging(); + + // Act + services.AddAuthentication() + .AddMicrosoftIdentityWebApiAot(options => + { + options.Instance = TestConstants.AadInstance; + options.TenantId = TestConstants.TenantIdAsGuid; + options.ClientId = TestConstants.ClientId; + }, JwtBearerScheme, null); + + // Assert + var provider = services.BuildServiceProvider(); + + // Verify MicrosoftIdentityApplicationOptions are configured + var appOptions = provider.GetRequiredService>().Get(JwtBearerScheme); + Assert.Equal(TestConstants.AadInstance, appOptions.Instance); + Assert.Equal(TestConstants.TenantIdAsGuid, appOptions.TenantId); + Assert.Equal(TestConstants.ClientId, appOptions.ClientId); + + // Verify core services are registered + Assert.Contains(services, s => s.ServiceType == typeof(IHttpContextAccessor)); + Assert.Contains(services, s => s.ServiceType == typeof(IMergedOptionsStore)); + Assert.Contains(services, s => s.ServiceType == typeof(MicrosoftIdentityIssuerValidatorFactory)); + } + + [Fact] + public void AddMicrosoftIdentityWebApiAot_WithCustomJwtBearerOptions_AppliesConfiguration() + { + // Arrange + var services = new ServiceCollection().AddLogging(); + bool customOptionsApplied = false; + + // Act + services.AddAuthentication() + .AddMicrosoftIdentityWebApiAot( + options => + { + options.Instance = TestConstants.AadInstance; + options.TenantId = TestConstants.TenantIdAsGuid; + options.ClientId = TestConstants.ClientId; + }, + JwtBearerScheme, + jwtOptions => + { + customOptionsApplied = true; + jwtOptions.TokenValidationParameters.ClockSkew = TimeSpan.FromMinutes(5); + }); + + // Assert + var provider = services.BuildServiceProvider(); + var jwtOptions = provider.GetRequiredService>().Get(JwtBearerScheme); + + Assert.True(customOptionsApplied); + Assert.Equal(TimeSpan.FromMinutes(5), jwtOptions.TokenValidationParameters.ClockSkew); + } + + [Fact] + public void PostConfigurator_ConfiguresAuthority() + { + // Arrange + var services = new ServiceCollection().AddLogging(); + services.AddAuthentication() + .AddMicrosoftIdentityWebApiAot(options => + { + options.Instance = TestConstants.AadInstance; + options.TenantId = TestConstants.TenantIdAsGuid; + options.ClientId = TestConstants.ClientId; + }, JwtBearerScheme, null); + + var provider = services.BuildServiceProvider(); + + // Act + var jwtOptions = provider.GetRequiredService>().Get(JwtBearerScheme); + + // Assert + Assert.NotNull(jwtOptions.Authority); + Assert.Contains(TestConstants.TenantIdAsGuid, jwtOptions.Authority, StringComparison.Ordinal); + Assert.EndsWith("/v2.0", jwtOptions.Authority, StringComparison.Ordinal); + } + + [Fact] + public void PostConfigurator_ConfiguresAudienceValidation() + { + // Arrange + var services = new ServiceCollection().AddLogging(); + services.AddAuthentication() + .AddMicrosoftIdentityWebApiAot(options => + { + options.Instance = TestConstants.AadInstance; + options.TenantId = TestConstants.TenantIdAsGuid; + options.ClientId = TestConstants.ClientId; + }, JwtBearerScheme, null); + + var provider = services.BuildServiceProvider(); + + // Act + var jwtOptions = provider.GetRequiredService>().Get(JwtBearerScheme); + + // Assert + Assert.NotNull(jwtOptions.TokenValidationParameters); + Assert.NotNull(jwtOptions.TokenValidationParameters.AudienceValidator); + } + + [Fact] + public void PostConfigurator_ChainsOnTokenValidated_ForOboSupport() + { + // Arrange + var services = new ServiceCollection().AddLogging(); + services.AddAuthentication() + .AddMicrosoftIdentityWebApiAot(options => + { + options.Instance = TestConstants.AadInstance; + options.TenantId = TestConstants.TenantIdAsGuid; + options.ClientId = TestConstants.ClientId; + }, JwtBearerScheme, null); + + var provider = services.BuildServiceProvider(); + + // Act + var jwtOptions = provider.GetRequiredService>().Get(JwtBearerScheme); + + // Assert + Assert.NotNull(jwtOptions.Events); + Assert.NotNull(jwtOptions.Events.OnTokenValidated); + } + + [Fact] + public void PostConfigurator_RespectsCustomerAuthority() + { + // Arrange + var customAuthority = "https://custom.authority.com/tenant-id/v2.0"; + var services = new ServiceCollection().AddLogging(); + + services.AddAuthentication() + .AddMicrosoftIdentityWebApiAot(options => + { + options.Instance = TestConstants.AadInstance; + options.TenantId = TestConstants.TenantIdAsGuid; + options.ClientId = TestConstants.ClientId; + }, JwtBearerScheme, null); + + // Customer configures their own authority AFTER our call + services.Configure(JwtBearerScheme, options => + { + options.Authority = customAuthority; + }); + + var provider = services.BuildServiceProvider(); + + // Act + var jwtOptions = provider.GetRequiredService>().Get(JwtBearerScheme); + + // Assert - our PostConfigure should respect the customer's authority + Assert.Equal(customAuthority, jwtOptions.Authority); + } + + [Fact] + public void PostConfigurator_SkipsWhenNotConfigured() + { + // Arrange + var services = new ServiceCollection().AddLogging(); + + // Add JWT bearer without using our AOT method + services.AddAuthentication() + .AddJwtBearer(JwtBearerScheme); + + // Manually register the post-configurator + services.AddSingleton>( + sp => new TestOptionsMonitor( + new MicrosoftIdentityApplicationOptions())); + + services.AddSingleton>( + sp => new MicrosoftIdentityJwtBearerOptionsPostConfigurator( + sp.GetRequiredService>(), + sp)); + + var provider = services.BuildServiceProvider(); + + // Act - PostConfigure should skip because ClientId is not set + var jwtOptions = provider.GetRequiredService>().Get(JwtBearerScheme); + + // Assert - Should not throw, and authority should remain null + Assert.Null(jwtOptions.Authority); + } + + [Fact] + public void ValidateRequiredOptions_ThrowsForMissingClientId() + { + // Arrange + var options = new MicrosoftIdentityApplicationOptions + { + Instance = TestConstants.AadInstance, + TenantId = TestConstants.TenantIdAsGuid, + }; + + // Act & Assert + var exception = Assert.Throws(() => + MicrosoftIdentityJwtBearerOptionsPostConfigurator.ValidateRequiredOptions(options)); + + Assert.Contains("ClientId", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ValidateRequiredOptions_ThrowsForMissingInstance() + { + // Arrange + var options = new MicrosoftIdentityApplicationOptions + { + ClientId = TestConstants.ClientId, + TenantId = TestConstants.TenantIdAsGuid, + Authority = "", + }; + + // Act & Assert + var exception = Assert.Throws(() => + MicrosoftIdentityJwtBearerOptionsPostConfigurator.ValidateRequiredOptions(options)); + + Assert.Contains("Instance", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ValidateRequiredOptions_ThrowsForMissingTenantId() + { + // Arrange + var options = new MicrosoftIdentityApplicationOptions + { + ClientId = TestConstants.ClientId, + Instance = TestConstants.AadInstance, + Authority = "", + }; + + // Act & Assert + var exception = Assert.Throws(() => + MicrosoftIdentityJwtBearerOptionsPostConfigurator.ValidateRequiredOptions(options)); + + Assert.Contains("TenantId", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ValidateRequiredOptions_ThrowsForMissingDomain_B2C() + { + // Arrange + var options = new MicrosoftIdentityApplicationOptions + { + ClientId = TestConstants.ClientId, + Instance = TestConstants.B2CInstance, + SignUpSignInPolicyId = TestConstants.B2CSignUpSignInUserFlow, + Authority = "", + }; + + // Act & Assert + var exception = Assert.Throws(() => + MicrosoftIdentityJwtBearerOptionsPostConfigurator.ValidateRequiredOptions(options)); + + Assert.Contains("Domain", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ValidateRequiredOptions_PassesWithValidOptions() + { + // Arrange + var options = new MicrosoftIdentityApplicationOptions + { + ClientId = TestConstants.ClientId, + Instance = TestConstants.AadInstance, + TenantId = TestConstants.TenantIdAsGuid, + }; + + // Act & Assert - should not throw + MicrosoftIdentityJwtBearerOptionsPostConfigurator.ValidateRequiredOptions(options); + } + + [Fact] + public void BuildAuthority_AAD_BuildsCorrectAuthority() + { + // Arrange + var options = new MicrosoftIdentityApplicationOptions + { + Instance = TestConstants.AadInstance, + TenantId = TestConstants.TenantIdAsGuid, + }; + + // Act + var authority = Internal.IdentityOptionsHelpers.BuildAuthority(options); + + // Assert + Assert.Contains(TestConstants.AadInstance.TrimEnd('/'), authority, StringComparison.Ordinal); + Assert.Contains(TestConstants.TenantIdAsGuid, authority, StringComparison.Ordinal); + Assert.EndsWith("/v2.0", authority, StringComparison.Ordinal); + } + + [Fact] + public void BuildAuthority_B2C_BuildsCorrectAuthority() + { + // Arrange + var options = new MicrosoftIdentityApplicationOptions + { + Instance = TestConstants.B2CInstance, + Domain = TestConstants.B2CTenant, + SignUpSignInPolicyId = TestConstants.B2CSignUpSignInUserFlow, + }; + + // Act + var authority = Internal.IdentityOptionsHelpers.BuildAuthority(options); + + // Assert + Assert.Contains(TestConstants.B2CTenant, authority, StringComparison.Ordinal); + Assert.Contains(TestConstants.B2CSignUpSignInUserFlow, authority, StringComparison.Ordinal); + Assert.EndsWith("/v2.0", authority, StringComparison.Ordinal); + } + + [Fact] + public async Task ChainTokenStorageHandler_ChainsExistingHandler() + { + // Arrange + bool existingHandlerCalled = false; + Func existingHandler = context => + { + existingHandlerCalled = true; + return Task.CompletedTask; + }; + + // Act + var chainedHandler = Internal.IdentityOptionsHelpers.ChainTokenStorageHandler(existingHandler); + + // Assert + Assert.NotNull(chainedHandler); + + // Create a mock context + var httpContext = new DefaultHttpContext(); + var tokenValidatedContext = new TokenValidatedContext( + httpContext, + new AuthenticationScheme(JwtBearerScheme, null, typeof(JwtBearerHandler)), + new JwtBearerOptions()) + { + SecurityToken = new System.IdentityModel.Tokens.Jwt.JwtSecurityToken() + }; + + // Execute the chained handler + await chainedHandler(tokenValidatedContext); + + // Verify existing handler was called + Assert.True(existingHandlerCalled); + } + + [Fact] + public async Task ChainTokenStorageHandler_WorksWithNullExistingHandler() + { + // Arrange & Act + var chainedHandler = Internal.IdentityOptionsHelpers.ChainTokenStorageHandler(null); + + // Assert + Assert.NotNull(chainedHandler); + + // Create a mock context + var httpContext = new DefaultHttpContext(); + var tokenValidatedContext = new TokenValidatedContext( + httpContext, + new AuthenticationScheme(JwtBearerScheme, null, typeof(JwtBearerHandler)), + new JwtBearerOptions()) + { + SecurityToken = new System.IdentityModel.Tokens.Jwt.JwtSecurityToken() + }; + + // Execute the handler - should not throw + await chainedHandler(tokenValidatedContext); + } + + [Fact] + public void ConfigureAudienceValidation_SetsAudienceValidator() + { + // Arrange + var options = new MicrosoftIdentityApplicationOptions + { + ClientId = TestConstants.ClientId, + Instance = TestConstants.AadInstance, + TenantId = TestConstants.TenantIdAsGuid, + }; + var tokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters(); + + // Act + Internal.IdentityOptionsHelpers.ConfigureAudienceValidation( + tokenValidationParameters, + options.ClientId, + !string.IsNullOrWhiteSpace(options.SignUpSignInPolicyId)); + + // Assert + Assert.NotNull(tokenValidationParameters.AudienceValidator); + } + + [Fact] + public void AddMicrosoftIdentityWebApiAot_WithTokenAcquisition_EnablesObo() + { + // Arrange + var services = new ServiceCollection().AddLogging(); + + // Act - The key test: OBO should work without calling EnableTokenAcquisitionToCallDownstreamApi + services.AddAuthentication() + .AddMicrosoftIdentityWebApiAot(options => + { + options.Instance = TestConstants.AadInstance; + options.TenantId = TestConstants.TenantIdAsGuid; + options.ClientId = TestConstants.ClientId; + }, JwtBearerScheme, null); + + services.AddTokenAcquisition(); + + var provider = services.BuildServiceProvider(); + + // Assert + var jwtOptions = provider.GetRequiredService>().Get(JwtBearerScheme); + + // Verify OnTokenValidated is set up for OBO + Assert.NotNull(jwtOptions.Events); + Assert.NotNull(jwtOptions.Events.OnTokenValidated); + + // Verify MergedOptions are populated via the merger + var mergedOptionsStore = provider.GetRequiredService(); + var mergedOptions = mergedOptionsStore.Get(JwtBearerScheme); + Assert.NotNull(mergedOptions); + Assert.Equal(TestConstants.ClientId, mergedOptions.ClientId); + } + } +} + +#endif