diff --git a/src/Microsoft.Identity.Web.AgentIdentities/README.AgentIdentities.md b/src/Microsoft.Identity.Web.AgentIdentities/README.AgentIdentities.md index 9a07ee349..f2e188da6 100644 --- a/src/Microsoft.Identity.Web.AgentIdentities/README.AgentIdentities.md +++ b/src/Microsoft.Identity.Web.AgentIdentities/README.AgentIdentities.md @@ -317,13 +317,13 @@ var userResponseByOid = await downstreamApi.GetForUserAsync( To call Azure SDKs, use the MicrosoftIdentityAzureCredential class from the Microsoft.Identity.Web.Azure NuGet package. -Install the Microsoft.Identity.Web.GraphServiceClient which handles authentication for the Graph SDK +Install the Microsoft.Identity.Web.Azure package: ```bash -dotnet dotnet add package Microsoft.Identity.Web.Azure +dotnet add package Microsoft.Identity.Web.Azure ``` -Add the support for Microsoft Graph in your service collection. +Add the support for Azure token credential in your service collection: ```bash services.AddMicrosoftIdentityAzureTokenCredential(); @@ -334,6 +334,106 @@ You can now get a `MicrosoftIdentityTokenCredential` from the service provider. See [Readme-azure](../../README-Azure.md) +### 7. HttpClient with MicrosoftIdentityMessageHandler Integration + +For scenarios where you want to use HttpClient directly with flexible authentication options, you can use the `MicrosoftIdentityMessageHandler` from the Microsoft.Identity.Web.TokenAcquisition package. + +Note: The Microsoft.Identity.Web.TokenAcquisition package is already referenced by Microsoft.Identity.Web.AgentIdentities. + +#### Using Agent Identity with MicrosoftIdentityMessageHandler: + +```csharp +// Configure HttpClient with MicrosoftIdentityMessageHandler in DI +services.AddHttpClient("MyApiClient", client => +{ + client.BaseAddress = new Uri("https://myapi.domain.com"); +}) +.AddHttpMessageHandler(serviceProvider => new MicrosoftIdentityMessageHandler( + serviceProvider.GetRequiredService(), + new MicrosoftIdentityMessageHandlerOptions + { + Scopes = { "https://myapi.domain.com/.default" } + })); + +// Usage in your service or controller +public class MyService +{ + private readonly HttpClient _httpClient; + + public MyService(IHttpClientFactory httpClientFactory) + { + _httpClient = httpClientFactory.CreateClient("MyApiClient"); + } + + public async Task CallApiWithAgentIdentity(string agentIdentity) + { + // Create request with agent identity authentication + var request = new HttpRequestMessage(HttpMethod.Get, "/api/data") + .WithAuthenticationOptions(options => + { + options.WithAgentIdentity(agentIdentity); + options.RequestAppToken = true; + }); + + var response = await _httpClient.SendAsync(request); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStringAsync(); + } +} +``` + +#### Using Agent User Identity with MicrosoftIdentityMessageHandler: + +```csharp +public async Task CallApiWithAgentUserIdentity(string agentIdentity, string userUpn) +{ + // Create request with agent user identity authentication + var request = new HttpRequestMessage(HttpMethod.Get, "/api/userdata") + .WithAuthenticationOptions(options => + { + options.WithAgentUserIdentity(agentIdentity, userUpn); + options.Scopes.Add("https://myapi.domain.com/user.read"); + }); + + var response = await _httpClient.SendAsync(request); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStringAsync(); +} +``` + +#### Manual HttpClient Configuration: + +You can also configure the handler manually for more control: + +```csharp +// Get the authorization header provider +IAuthorizationHeaderProvider headerProvider = + serviceProvider.GetRequiredService(); + +// Create the handler with default options +var handler = new MicrosoftIdentityMessageHandler( + headerProvider, + new MicrosoftIdentityMessageHandlerOptions + { + Scopes = { "https://graph.microsoft.com/.default" } + }); + +// Create HttpClient with the handler +using var httpClient = new HttpClient(handler); + +// Make requests with per-request authentication options +var request = new HttpRequestMessage(HttpMethod.Get, "https://graph.microsoft.com/v1.0/applications") + .WithAuthenticationOptions(options => + { + options.WithAgentIdentity(agentIdentity); + options.RequestAppToken = true; + }); + +var response = await httpClient.SendAsync(request); +``` + +The `MicrosoftIdentityMessageHandler` provides a flexible, composable way to add authentication to your HttpClient-based code while maintaining full compatibility with existing Microsoft Identity Web extension methods for agent identities. + ## Prerequisites ### Microsoft Entra ID Configuration diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/GlobalSuppressions.cs b/src/Microsoft.Identity.Web.TokenAcquisition/GlobalSuppressions.cs index 7ad2ef561..f271524a9 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/GlobalSuppressions.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/GlobalSuppressions.cs @@ -17,3 +17,4 @@ [assembly: SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Existing public API", Scope = "member", Target = "~M:Microsoft.Identity.Web.ITokenAcquisition.ReplyForbiddenWithWwwAuthenticateHeader(System.Collections.Generic.IEnumerable{System.String},Microsoft.Identity.Client.MsalUiRequiredException,System.String,Microsoft.AspNetCore.Http.HttpResponse)")] [assembly: SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Existing public API", Scope = "member", Target = "~M:Microsoft.Identity.Web.TokenAcquirerFactory.GetTokenAcquirer(System.String)~Microsoft.Identity.Abstractions.ITokenAcquirer")] [assembly: SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Existing public API", Scope = "member", Target = "~M:Microsoft.Identity.Web.TokenAcquirerFactory.GetTokenAcquirer(System.String,System.String,System.Collections.Generic.IEnumerable{Microsoft.Identity.Abstractions.CredentialDescription},System.String)~Microsoft.Identity.Abstractions.ITokenAcquirer")] +[assembly: SuppressMessage("ApiDesign", "RS0016:Symbol is not part of the declared API", Justification = "Protected serialization constructor for .NET Framework/Standard 2.0 compatibility", Scope = "member", Target = "~M:Microsoft.Identity.Web.MicrosoftIdentityAuthenticationException.#ctor(System.Runtime.Serialization.SerializationInfo,System.Runtime.Serialization.StreamingContext)")] diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/HttpRequestMessageAuthenticationExtensions.cs b/src/Microsoft.Identity.Web.TokenAcquisition/HttpRequestMessageAuthenticationExtensions.cs new file mode 100644 index 000000000..9b0a64c2d --- /dev/null +++ b/src/Microsoft.Identity.Web.TokenAcquisition/HttpRequestMessageAuthenticationExtensions.cs @@ -0,0 +1,180 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Net.Http; +using Microsoft.Identity.Abstractions; + +namespace Microsoft.Identity.Web +{ + /// + /// Extension methods for to configure per-request authentication options + /// when using . + /// + /// + /// These extension methods enable flexible per-request authentication configuration that can override + /// or supplement the default options configured in the message handler. The methods support both + /// modern .NET (using HttpRequestMessage.Options) and legacy frameworks + /// (using HttpRequestMessage.Properties). + /// + /// + /// Setting authentication options with an object: + /// + /// var request = new HttpRequestMessage(HttpMethod.Get, "/api/data") + /// .WithAuthenticationOptions(new MicrosoftIdentityMessageHandlerOptions + /// { + /// Scopes = { "custom.scope" } + /// }); + /// + /// + /// Configuring authentication options with a delegate: + /// + /// var request = new HttpRequestMessage(HttpMethod.Get, "/api/data") + /// .WithAuthenticationOptions(options => + /// { + /// options.Scopes.Add("https://graph.microsoft.com/.default"); + /// options.WithAgentIdentity("agent-guid"); + /// options.RequestAppToken = true; + /// }); + /// + /// + public static class HttpRequestMessageAuthenticationExtensions + { + private const string AuthOptionsKey = "Microsoft.Identity.AuthenticationOptions"; + + /// + /// Sets authentication options for the HTTP request. + /// + /// The HTTP request message to configure. + /// The authentication options to apply to this request. + /// The same request message for method chaining. + /// + /// Thrown when or is . + /// + /// + /// + /// var options = new MicrosoftIdentityMessageHandlerOptions + /// { + /// Scopes = { "https://graph.microsoft.com/.default" } + /// }; + /// options.WithAgentIdentity("my-agent-guid"); + /// + /// var request = new HttpRequestMessage(HttpMethod.Get, "/me") + /// .WithAuthenticationOptions(options); + /// + /// + /// + /// This method will override any existing authentication options set on the request. + /// The options object can be further configured with extension methods from other Microsoft Identity Web packages. + /// + public static HttpRequestMessage WithAuthenticationOptions( + this HttpRequestMessage request, MicrosoftIdentityMessageHandlerOptions options) + { + if (request == null) throw new ArgumentNullException(nameof(request)); + if (options == null) throw new ArgumentNullException(nameof(options)); + +#if NET5_0_OR_GREATER + request.Options.Set(new HttpRequestOptionsKey(AuthOptionsKey), options); +#else + // Use Properties dictionary for older frameworks + request.Properties[AuthOptionsKey] = options; +#endif + return request; + } + + /// + /// Configures authentication options for the HTTP request using a delegate. + /// + /// The HTTP request message to configure. + /// A delegate that configures the authentication options. + /// The same request message for method chaining. + /// + /// Thrown when or is . + /// + /// + /// + /// var request = new HttpRequestMessage(HttpMethod.Get, "/api/users") + /// .WithAuthenticationOptions(options => + /// { + /// options.Scopes.Add("https://myapi.domain.com/user.read"); + /// options.WithAgentIdentity("agent-application-id"); + /// options.RequestAppToken = true; + /// }); + /// + /// + /// + /// + /// If the request already has authentication options configured, the delegate will receive + /// the existing options object to modify. Otherwise, a new + /// instance will be created and passed to the delegate. + /// + /// + /// This method is particularly useful when you need to apply extension methods from other + /// Microsoft Identity Web packages, such as agent identity methods. + /// + /// + public static HttpRequestMessage WithAuthenticationOptions( + this HttpRequestMessage request, Action configure) + { + if (request == null) throw new ArgumentNullException(nameof(request)); + if (configure == null) throw new ArgumentNullException(nameof(configure)); + + var options = request.GetAuthenticationOptions() ?? new MicrosoftIdentityMessageHandlerOptions(); + + configure(options); + +#if NET5_0_OR_GREATER + request.Options.Set(new HttpRequestOptionsKey(AuthOptionsKey), options); +#else + // Use Properties dictionary for older frameworks + request.Properties[AuthOptionsKey] = options; +#endif + return request; + } + + /// + /// Gets the authentication options that have been set for the HTTP request. + /// + /// The HTTP request message to examine. + /// + /// The if previously set using + /// + /// or , + /// otherwise . + /// + /// + /// Thrown when is . + /// + /// + /// + /// var request = new HttpRequestMessage(HttpMethod.Get, "/api/data") + /// .WithAuthenticationOptions(options => options.Scopes.Add("custom.scope")); + /// + /// var options = request.GetAuthenticationOptions(); + /// if (options != null) + /// { + /// Console.WriteLine($"Request has {options.Scopes.Count} scopes configured."); + /// } + /// + /// + /// + /// This method is primarily used internally by + /// but can also be useful for debugging or conditional logic based on authentication configuration. + /// + public static MicrosoftIdentityMessageHandlerOptions? GetAuthenticationOptions(this HttpRequestMessage request) + { + if (request == null) throw new ArgumentNullException(nameof(request)); + +#if NET5_0_OR_GREATER + request.Options.TryGetValue(new HttpRequestOptionsKey(AuthOptionsKey), out var options); + return options; +#else + // Use Properties dictionary for older frameworks + return request.Properties.TryGetValue(AuthOptionsKey, out var options) + ? options as MicrosoftIdentityMessageHandlerOptions + : null; +#endif + } + } +} \ No newline at end of file diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/MicrosoftIdentityAuthenticationException.cs b/src/Microsoft.Identity.Web.TokenAcquisition/MicrosoftIdentityAuthenticationException.cs new file mode 100644 index 000000000..f9addb18d --- /dev/null +++ b/src/Microsoft.Identity.Web.TokenAcquisition/MicrosoftIdentityAuthenticationException.cs @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; + +namespace Microsoft.Identity.Web +{ + /// + /// Exception thrown when authentication fails during HTTP message handling by . + /// + /// + /// + /// This exception is typically thrown in the following scenarios: + /// + /// + /// No authentication options are configured (neither default nor per-request) + /// No scopes are specified in the authentication options + /// Token acquisition fails due to authentication provider issues + /// + /// + /// Note on WWW-Authenticate Challenge Handling: + /// When a downstream API returns a 401 Unauthorized response with a WWW-Authenticate header containing + /// additional claims (e.g., for Conditional Access), the handler automatically extracts these claims using + /// and attempts to acquire a new token + /// with the requested claims. If this automatic retry succeeds, no exception is thrown. If the retry also + /// fails with a 401, the response is returned to the caller without throwing an exception - the caller + /// should check the status code. Exceptions are only thrown for token acquisition failures, not for + /// HTTP 401 responses themselves. + /// + /// + /// When handling this exception, examine the property for specific details + /// about what caused the authentication failure. If an inner exception is present, it may contain + /// additional information from the underlying authentication provider. + /// + /// + /// + /// Typical exception handling pattern: + /// + /// try + /// { + /// var response = await httpClient.SendAsync(request, cancellationToken); + /// response.EnsureSuccessStatusCode(); + /// } + /// catch (MicrosoftIdentityAuthenticationException authEx) + /// { + /// // Handle authentication-specific failures + /// logger.LogError(authEx, "Authentication failed: {Message}", authEx.Message); + /// throw; // Re-throw or handle as appropriate + /// } + /// catch (HttpRequestException httpEx) + /// { + /// // Handle other HTTP-related failures + /// logger.LogError(httpEx, "HTTP request failed: {Message}", httpEx.Message); + /// } + /// + /// + /// + /// + public class MicrosoftIdentityAuthenticationException : Exception + { + /// + /// Initializes a new instance of the class + /// with a specified error message. + /// + /// The error message that explains the reason for the exception. + /// + /// + /// throw new MicrosoftIdentityAuthenticationException( + /// "Authentication options must be configured either in default options or per-request using WithAuthenticationOptions()."); + /// + /// + public MicrosoftIdentityAuthenticationException(string message) : base(message) + { + } + + /// + /// Initializes a new instance of the class + /// with a specified error message and a reference to the inner exception that is the cause of this exception. + /// + /// The error message that explains the reason for the exception. + /// + /// The exception that is the cause of the current exception, or + /// if no inner exception is specified. + /// + /// + /// + /// try + /// { + /// var authHeader = await headerProvider.CreateAuthorizationHeaderAsync(scopes, options); + /// } + /// catch (Exception ex) + /// { + /// throw new MicrosoftIdentityAuthenticationException("Failed to acquire authorization header.", ex); + /// } + /// + /// + /// + /// Use this constructor when you want to preserve the original exception that caused the authentication failure, + /// such as exceptions from the underlying token acquisition provider. + /// + public MicrosoftIdentityAuthenticationException(string message, Exception innerException) : base(message, innerException) + { + } + +#if NETFRAMEWORK || NETSTANDARD2_0 + /// + /// Initializes a new instance of the class + /// with serialized data. + /// + /// + /// The that holds the serialized object data + /// about the exception being thrown. + /// + /// + /// The that contains contextual information + /// about the source or destination. + /// + /// + /// This constructor is called during deserialization to reconstitute the exception object transmitted over a stream. + /// This constructor is only available in .NET Framework and .NET Standard 2.0 where binary serialization is supported. + /// + protected MicrosoftIdentityAuthenticationException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) + { + } +#endif + } +} \ No newline at end of file diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/MicrosoftIdentityMessageHandler.cs b/src/Microsoft.Identity.Web.TokenAcquisition/MicrosoftIdentityMessageHandler.cs new file mode 100644 index 000000000..1577ea19a --- /dev/null +++ b/src/Microsoft.Identity.Web.TokenAcquisition/MicrosoftIdentityMessageHandler.cs @@ -0,0 +1,459 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Client; + +namespace Microsoft.Identity.Web +{ + /// + /// A implementation that automatically adds authorization headers + /// to outgoing HTTP requests using and + /// . + /// + /// + /// + /// This message handler provides a flexible, composable way to add Microsoft Identity authentication + /// to HttpClient-based code. It serves as an alternative to IDownstreamApi for scenarios where + /// developers want to maintain direct control over HTTP request handling while still benefiting from + /// Microsoft Identity Web's authentication capabilities. + /// + /// + /// Key Features: + /// + /// Automatic authorization header injection for all outgoing requests + /// Per-request authentication options using extension methods + /// Automatic WWW-Authenticate challenge handling with token refresh + /// Support for agent identity and managed identity scenarios + /// Comprehensive logging and error handling + /// Multi-framework compatibility (.NET Framework 4.6.2+, .NET Standard 2.0+, .NET 5+) + /// + /// + /// WWW-Authenticate Challenge Handling: + /// + /// When a downstream API returns a 401 Unauthorized response with a WWW-Authenticate header containing + /// Bearer challenges with additional claims, this handler will automatically attempt to acquire a new token + /// with the requested claims and retry the request. This is particularly useful for Conditional Access + /// scenarios where additional claims are required. + /// + /// + /// + /// + /// Basic setup with dependency injection: + /// + /// // In Program.cs or Startup.cs + /// services.AddHttpClient("MyApiClient", client => + /// { + /// client.BaseAddress = new Uri("https://api.example.com"); + /// }) + /// .AddHttpMessageHandler(serviceProvider => new MicrosoftIdentityMessageHandler( + /// serviceProvider.GetRequiredService<IAuthorizationHeaderProvider>(), + /// new MicrosoftIdentityMessageHandlerOptions + /// { + /// Scopes = { "https://api.example.com/.default" } + /// })); + /// + /// // In a controller or service + /// public class ApiService + /// { + /// private readonly HttpClient _httpClient; + /// + /// public ApiService(IHttpClientFactory httpClientFactory) + /// { + /// _httpClient = httpClientFactory.CreateClient("MyApiClient"); + /// } + /// + /// public async Task<string> GetDataAsync() + /// { + /// var response = await _httpClient.GetAsync("/api/data"); + /// response.EnsureSuccessStatusCode(); + /// return await response.Content.ReadAsStringAsync(); + /// } + /// } + /// + /// + /// Per-request authentication options: + /// + /// // Override scopes for a specific request + /// var request = new HttpRequestMessage(HttpMethod.Get, "/api/sensitive-data") + /// .WithAuthenticationOptions(options => + /// { + /// options.Scopes.Add("https://api.example.com/sensitive.read"); + /// options.RequestAppToken = true; + /// }); + /// + /// var response = await _httpClient.SendAsync(request); + /// + /// + /// Agent identity usage: + /// + /// var request = new HttpRequestMessage(HttpMethod.Get, "/api/agent-data") + /// .WithAuthenticationOptions(options => + /// { + /// options.Scopes.Add("https://graph.microsoft.com/.default"); + /// options.WithAgentIdentity("agent-application-id"); + /// options.RequestAppToken = true; + /// }); + /// + /// var response = await _httpClient.SendAsync(request); + /// + /// + /// Manual instantiation: + /// + /// var headerProvider = serviceProvider.GetRequiredService<IAuthorizationHeaderProvider>(); + /// var logger = serviceProvider.GetService<ILogger<MicrosoftIdentityMessageHandler>>(); + /// + /// var handler = new MicrosoftIdentityMessageHandler( + /// headerProvider, + /// new MicrosoftIdentityMessageHandlerOptions + /// { + /// Scopes = { "https://graph.microsoft.com/.default" } + /// }, + /// logger); + /// + /// using var httpClient = new HttpClient(handler); + /// var response = await httpClient.GetAsync("https://graph.microsoft.com/v1.0/me"); + /// + /// + /// Error handling: + /// + /// try + /// { + /// var response = await _httpClient.SendAsync(request, cancellationToken); + /// response.EnsureSuccessStatusCode(); + /// return await response.Content.ReadAsStringAsync(); + /// } + /// catch (MicrosoftIdentityAuthenticationException authEx) + /// { + /// // Handle authentication-specific failures + /// _logger.LogError(authEx, "Authentication failed: {Message}", authEx.Message); + /// throw; + /// } + /// catch (HttpRequestException httpEx) + /// { + /// // Handle other HTTP failures + /// _logger.LogError(httpEx, "HTTP request failed: {Message}", httpEx.Message); + /// throw; + /// } + /// + /// + /// + /// + /// + /// + /// + public class MicrosoftIdentityMessageHandler : DelegatingHandler + { + private readonly IAuthorizationHeaderProvider _headerProvider; + private readonly MicrosoftIdentityMessageHandlerOptions? _defaultOptions; + private readonly ILogger? _logger; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The used to acquire authorization headers for outgoing requests. + /// This is typically obtained from the dependency injection container. + /// + /// + /// Default authentication options that will be used for all requests unless overridden per-request + /// using . + /// If , each request must specify its own authentication options or an exception will be thrown. + /// + /// + /// Optional logger for debugging and monitoring authentication operations. + /// If provided, the handler will log information about token acquisition, challenges, and errors. + /// + /// + /// Thrown when is . + /// + /// + /// Basic usage with default options: + /// + /// var handler = new MicrosoftIdentityMessageHandler( + /// headerProvider, + /// new MicrosoftIdentityMessageHandlerOptions + /// { + /// Scopes = { "https://api.example.com/.default" } + /// }); + /// + /// + /// Usage without default options (per-request configuration required): + /// + /// var handler = new MicrosoftIdentityMessageHandler(headerProvider); + /// + /// // Each request must specify options + /// var request = new HttpRequestMessage(HttpMethod.Get, "/api/data") + /// .WithAuthenticationOptions(options => + /// options.Scopes.Add("custom.scope")); + /// + /// + /// Usage with logging: + /// + /// var logger = serviceProvider.GetService<ILogger<MicrosoftIdentityMessageHandler>>(); + /// var handler = new MicrosoftIdentityMessageHandler(headerProvider, defaultOptions, logger); + /// + /// + /// + /// + /// The parameter provides a convenient way to set authentication + /// options that apply to all requests made through this handler instance. Individual requests can + /// still override these defaults using the extension methods. + /// + /// + /// When is provided, the handler will log at various levels: + /// + /// + /// Debug: Successful authorization header addition + /// Information: WWW-Authenticate challenge detection and handling + /// Warning: Challenge handling failures + /// Error: Token acquisition failures + /// + /// + public MicrosoftIdentityMessageHandler( + IAuthorizationHeaderProvider headerProvider, + MicrosoftIdentityMessageHandlerOptions? defaultOptions = null, + ILogger? logger = null) + { + _headerProvider = headerProvider ?? throw new ArgumentNullException(nameof(headerProvider)); + _defaultOptions = defaultOptions; + _logger = logger; + } + + /// + /// Sends an HTTP request with automatic authentication header injection. + /// Handles WWW-Authenticate challenges by attempting token refresh with additional claims if needed. + /// + /// The HTTP request message to send. + /// A cancellation token to cancel operation. + /// The HTTP response message. + /// + /// Thrown when authentication fails, including scenarios where: + /// - No authentication options are configured + /// - No scopes are specified in the options + /// - Token acquisition fails + /// - WWW-Authenticate challenge handling fails + /// + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + // Get per-request options or use default + var options = request.GetAuthenticationOptions() ?? _defaultOptions; + + if (options == null) + { + throw new MicrosoftIdentityAuthenticationException( + "Authentication options must be configured either in default options or per-request using WithAuthenticationOptions()."); + } + + // Get scopes from options + var scopes = options.Scopes; + + if (scopes == null || !scopes.Any()) + { + throw new MicrosoftIdentityAuthenticationException( + "Authentication scopes must be configured in the options.Scopes property."); + } + + // Send the request with authentication + var response = await SendWithAuthenticationAsync(request, options, scopes, cancellationToken).ConfigureAwait(false); + + // Handle WWW-Authenticate challenge if present + if (response.StatusCode == HttpStatusCode.Unauthorized) + { + // Use MSAL's WWW-Authenticate parser to extract claims from challenge headers + string? challengeClaims = WwwAuthenticateParameters.GetClaimChallengeFromResponseHeaders(response.Headers); + + if (!string.IsNullOrEmpty(challengeClaims)) + { + _logger?.LogInformation( + "Received WWW-Authenticate challenge with claims. Attempting token refresh."); + + // Create a new options instance with the challenge claims + var challengeOptions = CreateOptionsWithChallengeClaims(options, challengeClaims); + + // Clone the original request for retry + using var retryRequest = await CloneHttpRequestMessageAsync(request).ConfigureAwait(false); + + // Attempt to get a new token with the challenge claims + var retryResponse = await SendWithAuthenticationAsync(retryRequest, challengeOptions, scopes, cancellationToken).ConfigureAwait(false); + + // Log information about the retry response + if (retryResponse.StatusCode == HttpStatusCode.Unauthorized) + { + _logger?.LogWarning( + "Retry after WWW-Authenticate challenge still returned 401 Unauthorized. WWW-Authenticate header present: {HasWwwAuthenticate}", + retryResponse.Headers.WwwAuthenticate?.Any() == true); + } + else + { + _logger?.LogInformation("Successfully handled WWW-Authenticate challenge. Status code: {StatusCode}", retryResponse.StatusCode); + } + + // Dispose the original response and return the retry response + response.Dispose(); + return retryResponse; + } + else + { + _logger?.LogWarning("Received 401 Unauthorized but no WWW-Authenticate challenge with claims found."); + } + } + + return response; + } + + /// + /// Sends an HTTP request with authentication header injection. + /// + /// The HTTP request message. + /// The authentication options to use. + /// The scopes for token acquisition. + /// A cancellation token to cancel operation. + /// The HTTP response message. + /// Thrown when token acquisition fails. + private async Task SendWithAuthenticationAsync( + HttpRequestMessage request, + MicrosoftIdentityMessageHandlerOptions options, + IList scopes, + CancellationToken cancellationToken) + { + // Acquire authorization header + try + { + var authHeader = await _headerProvider.CreateAuthorizationHeaderAsync( + scopes, options, cancellationToken: cancellationToken).ConfigureAwait(false); + + // Remove existing authorization header if present + if (request.Headers.Contains("Authorization")) + { + request.Headers.Remove("Authorization"); + } + + // Add the authorization header + request.Headers.Add("Authorization", authHeader); + + _logger?.LogDebug( + "Added Authorization header for scopes: {Scopes}", + string.Join(", ", scopes)); + } + catch (Exception ex) + { + var message = "Failed to acquire authorization header."; + _logger?.LogError(ex, message); + throw new MicrosoftIdentityAuthenticationException(message, ex); + } + + // Send the request + return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + + /// + /// Creates a new options instance with challenge claims added. + /// + /// The original authentication options. + /// The claims from the WWW-Authenticate challenge. + /// A new options instance with challenge claims configured. + private static MicrosoftIdentityMessageHandlerOptions CreateOptionsWithChallengeClaims( + MicrosoftIdentityMessageHandlerOptions originalOptions, + string challengeClaims) + { + var challengeOptions = new MicrosoftIdentityMessageHandlerOptions + { + Scopes = originalOptions.Scopes + }; + + // Copy properties from the base AuthorizationHeaderProviderOptions + if (originalOptions.AcquireTokenOptions != null) + { + challengeOptions.AcquireTokenOptions = new AcquireTokenOptions + { + AuthenticationOptionsName = originalOptions.AcquireTokenOptions.AuthenticationOptionsName, + Claims = challengeClaims, // Set the challenge claims + CorrelationId = originalOptions.AcquireTokenOptions.CorrelationId, + ExtraHeadersParameters = originalOptions.AcquireTokenOptions.ExtraHeadersParameters, + ExtraQueryParameters = originalOptions.AcquireTokenOptions.ExtraQueryParameters, + ExtraParameters = originalOptions.AcquireTokenOptions.ExtraParameters, + ForceRefresh = true, // Force refresh when handling challenges + ManagedIdentity = originalOptions.AcquireTokenOptions.ManagedIdentity, + PopPublicKey = originalOptions.AcquireTokenOptions.PopPublicKey, + Tenant = originalOptions.AcquireTokenOptions.Tenant, + UserFlow = originalOptions.AcquireTokenOptions.UserFlow + }; + } + else + { + challengeOptions.AcquireTokenOptions = new AcquireTokenOptions + { + Claims = challengeClaims, + ForceRefresh = true + }; + } + + // Copy other inherited properties + challengeOptions.RequestAppToken = originalOptions.RequestAppToken; + challengeOptions.BaseUrl = originalOptions.BaseUrl; + challengeOptions.HttpMethod = originalOptions.HttpMethod; + challengeOptions.RelativePath = originalOptions.RelativePath; + + return challengeOptions; + } + + /// + /// Clones an HttpRequestMessage for retry scenarios. + /// + /// The original request to clone. + /// A cloned HttpRequestMessage. + private static async Task CloneHttpRequestMessageAsync(HttpRequestMessage originalRequest) + { + var clonedRequest = new HttpRequestMessage(originalRequest.Method, originalRequest.RequestUri); + + // Copy headers + foreach (var header in originalRequest.Headers) + { + // Skip Authorization header as it will be set by the handler + if (header.Key != "Authorization") + { + clonedRequest.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + } + + // Copy content if present + if (originalRequest.Content != null) + { + var contentBytes = await originalRequest.Content.ReadAsByteArrayAsync().ConfigureAwait(false); + clonedRequest.Content = new ByteArrayContent(contentBytes); + + // Copy content headers + foreach (var header in originalRequest.Content.Headers) + { + clonedRequest.Content.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + } + + // Copy properties/options (excluding authentication options which will be set separately) + // Note: We don't copy options to avoid complications with typed keys. + // Most HttpClient scenarios don't rely on copying all options to retry requests. +#if !NET5_0_OR_GREATER + foreach (var property in originalRequest.Properties) + { + // Skip our authentication options as they will be set separately + if (!property.Key.Equals("Microsoft.Identity.AuthenticationOptions", StringComparison.Ordinal)) + { + clonedRequest.Properties[property.Key] = property.Value; + } + } +#endif + + return clonedRequest; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/MicrosoftIdentityMessageHandlerOptions.cs b/src/Microsoft.Identity.Web.TokenAcquisition/MicrosoftIdentityMessageHandlerOptions.cs new file mode 100644 index 000000000..a52c75279 --- /dev/null +++ b/src/Microsoft.Identity.Web.TokenAcquisition/MicrosoftIdentityMessageHandlerOptions.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Generic; +using Microsoft.Identity.Abstractions; + +namespace Microsoft.Identity.Web +{ + /// + /// Configuration options for authentication. + /// Inherits from to enable compatibility + /// with existing extension methods such as WithAgentIdentity() and WithUserAgentIdentity(). + /// + /// + /// Basic usage with scopes: + /// + /// var options = new MicrosoftIdentityMessageHandlerOptions + /// { + /// Scopes = { "https://graph.microsoft.com/.default" } + /// }; + /// + /// + /// Usage with extension methods: + /// + /// var options = new MicrosoftIdentityMessageHandlerOptions + /// { + /// Scopes = { "api://myapi/.default" } + /// }; + /// options.WithAgentIdentity("agent-guid"); + /// + /// + /// + /// + public class MicrosoftIdentityMessageHandlerOptions : AuthorizationHeaderProviderOptions + { + /// + /// Gets or sets the scopes to request for the token. + /// + /// + /// A list of scopes required to access the target API. + /// For instance, "user.read mail.read" for Microsoft Graph user permissions. + /// For Microsoft Identity, in the case of application tokens (requested by the app on behalf of itself), + /// there should be only one scope, and it should end with ".default" (e.g., "https://graph.microsoft.com/.default"). + /// + /// + /// + /// var options = new MicrosoftIdentityMessageHandlerOptions(); + /// options.Scopes.Add("https://graph.microsoft.com/.default"); + /// options.Scopes.Add("https://myapi.domain.com/access"); + /// + /// + /// + /// This property must contain at least one scope, or the + /// will throw a when processing requests. + /// + public IList Scopes { get; set; } = new List(); + } +} \ No newline at end of file diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net462/PublicAPI.Unshipped.txt b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net462/PublicAPI.Unshipped.txt index 4afdc65b8..b820088d6 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net462/PublicAPI.Unshipped.txt +++ b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net462/PublicAPI.Unshipped.txt @@ -1,2 +1,16 @@ #nullable enable -Microsoft.Identity.Web.Experimental.CerticateObserverAction.SuccessfullyUsed = 2 -> Microsoft.Identity.Web.Experimental.CerticateObserverAction +Microsoft.Identity.Web.HttpRequestMessageAuthenticationExtensions +Microsoft.Identity.Web.MicrosoftIdentityAuthenticationException +Microsoft.Identity.Web.MicrosoftIdentityAuthenticationException.MicrosoftIdentityAuthenticationException(string! message) -> void +Microsoft.Identity.Web.MicrosoftIdentityAuthenticationException.MicrosoftIdentityAuthenticationException(string! message, System.Exception! innerException) -> void +Microsoft.Identity.Web.MicrosoftIdentityMessageHandler +Microsoft.Identity.Web.MicrosoftIdentityMessageHandler.MicrosoftIdentityMessageHandler(Microsoft.Identity.Abstractions.IAuthorizationHeaderProvider! headerProvider, Microsoft.Identity.Web.MicrosoftIdentityMessageHandlerOptions? defaultOptions = null, Microsoft.Extensions.Logging.ILogger? logger = null) -> void +Microsoft.Identity.Web.MicrosoftIdentityMessageHandlerOptions +Microsoft.Identity.Web.MicrosoftIdentityMessageHandlerOptions.MicrosoftIdentityMessageHandlerOptions() -> void +Microsoft.Identity.Web.MicrosoftIdentityMessageHandlerOptions.Scopes.get -> System.Collections.Generic.IList! +Microsoft.Identity.Web.MicrosoftIdentityMessageHandlerOptions.Scopes.set -> void +override Microsoft.Identity.Web.MicrosoftIdentityMessageHandler.SendAsync(System.Net.Http.HttpRequestMessage! request, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +static Microsoft.Identity.Web.HttpRequestMessageAuthenticationExtensions.GetAuthenticationOptions(this System.Net.Http.HttpRequestMessage! request) -> Microsoft.Identity.Web.MicrosoftIdentityMessageHandlerOptions? +static Microsoft.Identity.Web.HttpRequestMessageAuthenticationExtensions.WithAuthenticationOptions(this System.Net.Http.HttpRequestMessage! request, Microsoft.Identity.Web.MicrosoftIdentityMessageHandlerOptions! options) -> System.Net.Http.HttpRequestMessage! +static Microsoft.Identity.Web.HttpRequestMessageAuthenticationExtensions.WithAuthenticationOptions(this System.Net.Http.HttpRequestMessage! request, System.Action! configure) -> System.Net.Http.HttpRequestMessage! +Microsoft.Identity.Web.Experimental.CerticateObserverAction.SuccessfullyUsed = 2 -> Microsoft.Identity.Web.Experimental.CerticateObserverAction \ No newline at end of file diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net472/PublicAPI.Unshipped.txt b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net472/PublicAPI.Unshipped.txt index 4afdc65b8..413c49d7f 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net472/PublicAPI.Unshipped.txt +++ b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net472/PublicAPI.Unshipped.txt @@ -1,2 +1,16 @@ #nullable enable +Microsoft.Identity.Web.HttpRequestMessageAuthenticationExtensions +Microsoft.Identity.Web.MicrosoftIdentityAuthenticationException +Microsoft.Identity.Web.MicrosoftIdentityAuthenticationException.MicrosoftIdentityAuthenticationException(string! message) -> void +Microsoft.Identity.Web.MicrosoftIdentityAuthenticationException.MicrosoftIdentityAuthenticationException(string! message, System.Exception! innerException) -> void +Microsoft.Identity.Web.MicrosoftIdentityMessageHandler +Microsoft.Identity.Web.MicrosoftIdentityMessageHandler.MicrosoftIdentityMessageHandler(Microsoft.Identity.Abstractions.IAuthorizationHeaderProvider! headerProvider, Microsoft.Identity.Web.MicrosoftIdentityMessageHandlerOptions? defaultOptions = null, Microsoft.Extensions.Logging.ILogger? logger = null) -> void +Microsoft.Identity.Web.MicrosoftIdentityMessageHandlerOptions +Microsoft.Identity.Web.MicrosoftIdentityMessageHandlerOptions.MicrosoftIdentityMessageHandlerOptions() -> void +Microsoft.Identity.Web.MicrosoftIdentityMessageHandlerOptions.Scopes.get -> System.Collections.Generic.IList! +Microsoft.Identity.Web.MicrosoftIdentityMessageHandlerOptions.Scopes.set -> void +override Microsoft.Identity.Web.MicrosoftIdentityMessageHandler.SendAsync(System.Net.Http.HttpRequestMessage! request, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +static Microsoft.Identity.Web.HttpRequestMessageAuthenticationExtensions.GetAuthenticationOptions(this System.Net.Http.HttpRequestMessage! request) -> Microsoft.Identity.Web.MicrosoftIdentityMessageHandlerOptions? +static Microsoft.Identity.Web.HttpRequestMessageAuthenticationExtensions.WithAuthenticationOptions(this System.Net.Http.HttpRequestMessage! request, Microsoft.Identity.Web.MicrosoftIdentityMessageHandlerOptions! options) -> System.Net.Http.HttpRequestMessage! +static Microsoft.Identity.Web.HttpRequestMessageAuthenticationExtensions.WithAuthenticationOptions(this System.Net.Http.HttpRequestMessage! request, System.Action! configure) -> System.Net.Http.HttpRequestMessage! Microsoft.Identity.Web.Experimental.CerticateObserverAction.SuccessfullyUsed = 2 -> Microsoft.Identity.Web.Experimental.CerticateObserverAction diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net8.0/PublicAPI.Unshipped.txt index 4afdc65b8..413c49d7f 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net8.0/PublicAPI.Unshipped.txt +++ b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -1,2 +1,16 @@ #nullable enable +Microsoft.Identity.Web.HttpRequestMessageAuthenticationExtensions +Microsoft.Identity.Web.MicrosoftIdentityAuthenticationException +Microsoft.Identity.Web.MicrosoftIdentityAuthenticationException.MicrosoftIdentityAuthenticationException(string! message) -> void +Microsoft.Identity.Web.MicrosoftIdentityAuthenticationException.MicrosoftIdentityAuthenticationException(string! message, System.Exception! innerException) -> void +Microsoft.Identity.Web.MicrosoftIdentityMessageHandler +Microsoft.Identity.Web.MicrosoftIdentityMessageHandler.MicrosoftIdentityMessageHandler(Microsoft.Identity.Abstractions.IAuthorizationHeaderProvider! headerProvider, Microsoft.Identity.Web.MicrosoftIdentityMessageHandlerOptions? defaultOptions = null, Microsoft.Extensions.Logging.ILogger? logger = null) -> void +Microsoft.Identity.Web.MicrosoftIdentityMessageHandlerOptions +Microsoft.Identity.Web.MicrosoftIdentityMessageHandlerOptions.MicrosoftIdentityMessageHandlerOptions() -> void +Microsoft.Identity.Web.MicrosoftIdentityMessageHandlerOptions.Scopes.get -> System.Collections.Generic.IList! +Microsoft.Identity.Web.MicrosoftIdentityMessageHandlerOptions.Scopes.set -> void +override Microsoft.Identity.Web.MicrosoftIdentityMessageHandler.SendAsync(System.Net.Http.HttpRequestMessage! request, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +static Microsoft.Identity.Web.HttpRequestMessageAuthenticationExtensions.GetAuthenticationOptions(this System.Net.Http.HttpRequestMessage! request) -> Microsoft.Identity.Web.MicrosoftIdentityMessageHandlerOptions? +static Microsoft.Identity.Web.HttpRequestMessageAuthenticationExtensions.WithAuthenticationOptions(this System.Net.Http.HttpRequestMessage! request, Microsoft.Identity.Web.MicrosoftIdentityMessageHandlerOptions! options) -> System.Net.Http.HttpRequestMessage! +static Microsoft.Identity.Web.HttpRequestMessageAuthenticationExtensions.WithAuthenticationOptions(this System.Net.Http.HttpRequestMessage! request, System.Action! configure) -> System.Net.Http.HttpRequestMessage! Microsoft.Identity.Web.Experimental.CerticateObserverAction.SuccessfullyUsed = 2 -> Microsoft.Identity.Web.Experimental.CerticateObserverAction diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net9.0/PublicAPI.Unshipped.txt b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net9.0/PublicAPI.Unshipped.txt index 4afdc65b8..413c49d7f 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net9.0/PublicAPI.Unshipped.txt +++ b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net9.0/PublicAPI.Unshipped.txt @@ -1,2 +1,16 @@ #nullable enable +Microsoft.Identity.Web.HttpRequestMessageAuthenticationExtensions +Microsoft.Identity.Web.MicrosoftIdentityAuthenticationException +Microsoft.Identity.Web.MicrosoftIdentityAuthenticationException.MicrosoftIdentityAuthenticationException(string! message) -> void +Microsoft.Identity.Web.MicrosoftIdentityAuthenticationException.MicrosoftIdentityAuthenticationException(string! message, System.Exception! innerException) -> void +Microsoft.Identity.Web.MicrosoftIdentityMessageHandler +Microsoft.Identity.Web.MicrosoftIdentityMessageHandler.MicrosoftIdentityMessageHandler(Microsoft.Identity.Abstractions.IAuthorizationHeaderProvider! headerProvider, Microsoft.Identity.Web.MicrosoftIdentityMessageHandlerOptions? defaultOptions = null, Microsoft.Extensions.Logging.ILogger? logger = null) -> void +Microsoft.Identity.Web.MicrosoftIdentityMessageHandlerOptions +Microsoft.Identity.Web.MicrosoftIdentityMessageHandlerOptions.MicrosoftIdentityMessageHandlerOptions() -> void +Microsoft.Identity.Web.MicrosoftIdentityMessageHandlerOptions.Scopes.get -> System.Collections.Generic.IList! +Microsoft.Identity.Web.MicrosoftIdentityMessageHandlerOptions.Scopes.set -> void +override Microsoft.Identity.Web.MicrosoftIdentityMessageHandler.SendAsync(System.Net.Http.HttpRequestMessage! request, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +static Microsoft.Identity.Web.HttpRequestMessageAuthenticationExtensions.GetAuthenticationOptions(this System.Net.Http.HttpRequestMessage! request) -> Microsoft.Identity.Web.MicrosoftIdentityMessageHandlerOptions? +static Microsoft.Identity.Web.HttpRequestMessageAuthenticationExtensions.WithAuthenticationOptions(this System.Net.Http.HttpRequestMessage! request, Microsoft.Identity.Web.MicrosoftIdentityMessageHandlerOptions! options) -> System.Net.Http.HttpRequestMessage! +static Microsoft.Identity.Web.HttpRequestMessageAuthenticationExtensions.WithAuthenticationOptions(this System.Net.Http.HttpRequestMessage! request, System.Action! configure) -> System.Net.Http.HttpRequestMessage! Microsoft.Identity.Web.Experimental.CerticateObserverAction.SuccessfullyUsed = 2 -> Microsoft.Identity.Web.Experimental.CerticateObserverAction diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index 4afdc65b8..413c49d7f 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -1,2 +1,16 @@ #nullable enable +Microsoft.Identity.Web.HttpRequestMessageAuthenticationExtensions +Microsoft.Identity.Web.MicrosoftIdentityAuthenticationException +Microsoft.Identity.Web.MicrosoftIdentityAuthenticationException.MicrosoftIdentityAuthenticationException(string! message) -> void +Microsoft.Identity.Web.MicrosoftIdentityAuthenticationException.MicrosoftIdentityAuthenticationException(string! message, System.Exception! innerException) -> void +Microsoft.Identity.Web.MicrosoftIdentityMessageHandler +Microsoft.Identity.Web.MicrosoftIdentityMessageHandler.MicrosoftIdentityMessageHandler(Microsoft.Identity.Abstractions.IAuthorizationHeaderProvider! headerProvider, Microsoft.Identity.Web.MicrosoftIdentityMessageHandlerOptions? defaultOptions = null, Microsoft.Extensions.Logging.ILogger? logger = null) -> void +Microsoft.Identity.Web.MicrosoftIdentityMessageHandlerOptions +Microsoft.Identity.Web.MicrosoftIdentityMessageHandlerOptions.MicrosoftIdentityMessageHandlerOptions() -> void +Microsoft.Identity.Web.MicrosoftIdentityMessageHandlerOptions.Scopes.get -> System.Collections.Generic.IList! +Microsoft.Identity.Web.MicrosoftIdentityMessageHandlerOptions.Scopes.set -> void +override Microsoft.Identity.Web.MicrosoftIdentityMessageHandler.SendAsync(System.Net.Http.HttpRequestMessage! request, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +static Microsoft.Identity.Web.HttpRequestMessageAuthenticationExtensions.GetAuthenticationOptions(this System.Net.Http.HttpRequestMessage! request) -> Microsoft.Identity.Web.MicrosoftIdentityMessageHandlerOptions? +static Microsoft.Identity.Web.HttpRequestMessageAuthenticationExtensions.WithAuthenticationOptions(this System.Net.Http.HttpRequestMessage! request, Microsoft.Identity.Web.MicrosoftIdentityMessageHandlerOptions! options) -> System.Net.Http.HttpRequestMessage! +static Microsoft.Identity.Web.HttpRequestMessageAuthenticationExtensions.WithAuthenticationOptions(this System.Net.Http.HttpRequestMessage! request, System.Action! configure) -> System.Net.Http.HttpRequestMessage! Microsoft.Identity.Web.Experimental.CerticateObserverAction.SuccessfullyUsed = 2 -> Microsoft.Identity.Web.Experimental.CerticateObserverAction diff --git a/tests/Microsoft.Identity.Web.Test/MicrosoftIdentityMessageHandlerNewTests.cs b/tests/Microsoft.Identity.Web.Test/MicrosoftIdentityMessageHandlerNewTests.cs new file mode 100644 index 000000000..1a17813b9 --- /dev/null +++ b/tests/Microsoft.Identity.Web.Test/MicrosoftIdentityMessageHandlerNewTests.cs @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Web.Test.Common.Mocks; +using NSubstitute; +using Xunit; + +namespace Microsoft.Identity.Web.Test +{ + public class MicrosoftIdentityMessageHandlerNewTests + { + private readonly IAuthorizationHeaderProvider _mockHeaderProvider; + private readonly ILogger _mockLogger; + + public MicrosoftIdentityMessageHandlerNewTests() + { + _mockHeaderProvider = Substitute.For(); + _mockLogger = Substitute.For>(); + } + + [Fact] + public void Constructor_WithNullHeaderProvider_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws(() => new MicrosoftIdentityMessageHandler(null!)); + } + + [Fact] + public void Constructor_WithValidParameters_CreatesInstance() + { + // Arrange + var options = new MicrosoftIdentityMessageHandlerOptions + { + Scopes = { "https://graph.microsoft.com/.default" } + }; + + // Act + var handler = new MicrosoftIdentityMessageHandler(_mockHeaderProvider, options, _mockLogger); + + // Assert + Assert.NotNull(handler); + } + + [Fact] + public void SendAsync_WithValidConfiguration_CanBeUsedWithHttpClient() + { + // Arrange + var options = new MicrosoftIdentityMessageHandlerOptions + { + Scopes = { "https://graph.microsoft.com/.default" } + }; + + var handler = new MicrosoftIdentityMessageHandler(_mockHeaderProvider, options, _mockLogger); + + // Act & Assert - should not throw + using var client = new HttpClient(handler); + Assert.NotNull(client); + } + + [Fact] + public void SendAsync_WithPerRequestConfiguration_CanBeUsedWithHttpClient() + { + // Arrange + var defaultOptions = new MicrosoftIdentityMessageHandlerOptions + { + Scopes = { "https://graph.microsoft.com/.default" } + }; + + var handler = new MicrosoftIdentityMessageHandler(_mockHeaderProvider, defaultOptions, _mockLogger); + + // Act & Assert - should not throw + using var client = new HttpClient(handler); + + // Create a request with per-request options + var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com") + .WithAuthenticationOptions(options => + { + options.Scopes.Add("https://custom.api/.default"); + }); + + Assert.NotNull(request.GetAuthenticationOptions()); + Assert.Contains("https://custom.api/.default", request.GetAuthenticationOptions()!.Scopes); + } + + [Fact] + public void HttpRequestMessageExtensions_WithAuthenticationOptions_SetsAndGetsOptions() + { + // Arrange + var options = new MicrosoftIdentityMessageHandlerOptions + { + Scopes = { "https://test.api/.default" } + }; + + var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com"); + + // Act + request.WithAuthenticationOptions(options); + var retrievedOptions = request.GetAuthenticationOptions(); + + // Assert + Assert.NotNull(retrievedOptions); + Assert.Contains("https://test.api/.default", retrievedOptions.Scopes); + } + + [Fact] + public void HttpRequestMessageExtensions_WithAuthenticationOptionsDelegate_ConfiguresOptions() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com"); + + // Act + request.WithAuthenticationOptions(options => + { + options.Scopes.Add("https://configured.api/.default"); + }); + + var retrievedOptions = request.GetAuthenticationOptions(); + + // Assert + Assert.NotNull(retrievedOptions); + Assert.Contains("https://configured.api/.default", retrievedOptions.Scopes); + } + + [Fact] + public void MicrosoftIdentityAuthenticationException_WithMessage_CreatesException() + { + // Arrange + const string message = "Test authentication error"; + + // Act + var exception = new MicrosoftIdentityAuthenticationException(message); + + // Assert + Assert.Equal(message, exception.Message); + } + + [Fact] + public void MicrosoftIdentityAuthenticationException_WithMessageAndInnerException_CreatesException() + { + // Arrange + const string message = "Test authentication error"; + var innerException = new InvalidOperationException("Inner error"); + + // Act + var exception = new MicrosoftIdentityAuthenticationException(message, innerException); + + // Assert + Assert.Equal(message, exception.Message); + Assert.Equal(innerException, exception.InnerException); + } + } +} \ No newline at end of file