diff --git a/src/Microsoft.Identity.Web.Sidecar/Configuration/SidecarOptions.cs b/src/Microsoft.Identity.Web.Sidecar/Configuration/SidecarOptions.cs new file mode 100644 index 000000000..98d35646d --- /dev/null +++ b/src/Microsoft.Identity.Web.Sidecar/Configuration/SidecarOptions.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace Microsoft.Identity.Web.Sidecar.Configuration; + +/// +/// Top-level configuration for the sidecar host. Bound from the +/// Sidecar configuration section. +/// +public class SidecarOptions +{ + /// + /// Per-route gating for caller-supplied optionsOverride.* query + /// parameters. When the corresponding flag is false, any + /// optionsOverride.* parameters supplied by the caller are + /// ignored on that route and a warning is logged. + /// + public AllowOverridesOptions AllowOverrides { get; set; } = new(); +} + +/// +/// Per-route flags controlling whether the sidecar will honour +/// optionsOverride.* query parameters. +/// +public class AllowOverridesOptions +{ + /// + /// Allow overrides on GET /AuthorizationHeader/{apiName}. + /// Defaults to true. + /// + public bool GetAuthorizationHeader { get; set; } = true; + + /// + /// Allow overrides on GET /AuthorizationHeaderUnauthenticated/{apiName}. + /// Defaults to false. + /// + public bool GetAuthorizationHeaderUnauthenticated { get; set; } + + /// + /// Allow overrides on POST /DownstreamApi/{apiName}. + /// Defaults to true. + /// + public bool CallDownstreamApi { get; set; } = true; + + /// + /// Allow overrides on POST /DownstreamApiUnauthenticated/{apiName}. + /// Defaults to false. + /// + public bool CallDownstreamApiUnauthenticated { get; set; } +} diff --git a/src/Microsoft.Identity.Web.Sidecar/DownstreamApiOptionsMerger.cs b/src/Microsoft.Identity.Web.Sidecar/DownstreamApiOptionsMerger.cs index 7251f2272..9ac369ed6 100644 --- a/src/Microsoft.Identity.Web.Sidecar/DownstreamApiOptionsMerger.cs +++ b/src/Microsoft.Identity.Web.Sidecar/DownstreamApiOptionsMerger.cs @@ -27,10 +27,9 @@ public static DownstreamApiOptions MergeOptions(DownstreamApiOptions left, Downs res.RequestAppToken = right.RequestAppToken; } - if (!string.IsNullOrEmpty(right.BaseUrl)) - { - res.BaseUrl = right.BaseUrl; - } + // BaseUrl from the override is intentionally not merged. The downstream + // BaseUrl is fixed by host configuration and cannot be overridden through + // the optionsOverride channel. if (!string.IsNullOrEmpty(right.RelativePath)) { diff --git a/src/Microsoft.Identity.Web.Sidecar/Endpoints/AuthorizationHeaderEndpoint.cs b/src/Microsoft.Identity.Web.Sidecar/Endpoints/AuthorizationHeaderEndpoint.cs index b34abc246..0f96297ea 100644 --- a/src/Microsoft.Identity.Web.Sidecar/Endpoints/AuthorizationHeaderEndpoint.cs +++ b/src/Microsoft.Identity.Web.Sidecar/Endpoints/AuthorizationHeaderEndpoint.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Options; using Microsoft.Identity.Abstractions; using Microsoft.Identity.Client; +using Microsoft.Identity.Web.Sidecar.Configuration; using Microsoft.Identity.Web.Sidecar.Logging; using Microsoft.Identity.Web.Sidecar.Models; @@ -14,10 +15,24 @@ namespace Microsoft.Identity.Web.Sidecar.Endpoints; public static class AuthorizationHeaderEndpoint { + internal const string AuthenticatedRouteName = "AuthorizationHeader"; + internal const string UnauthenticatedRouteName = "AuthorizationHeaderUnauthenticated"; + public static void AddAuthorizationHeaderRequestEndpoints(this WebApplication app) { - app.MapGet("/AuthorizationHeader/{apiName}", AuthorizationHeaderAsync). - WithName("AuthorizationHeader"). + app.MapGet("/AuthorizationHeader/{apiName}", + (HttpContext httpContext, [Description("The downstream API to acquire an authorization header for.")][FromRoute] string apiName, + [AsParameters] AuthorizationHeaderRequest requestParameters, + BindableDownstreamApiOptions optionsOverride, + [FromServices] IAuthorizationHeaderProvider headerProvider, + [FromServices] IOptionsMonitor optionsMonitor, + [FromServices] IOptions sidecarOptions, + [FromServices] ILogger logger, + CancellationToken cancellationToken) => + AuthorizationHeaderAsync( + httpContext, apiName, requestParameters, optionsOverride, headerProvider, optionsMonitor, + sidecarOptions.Value.AllowOverrides.GetAuthorizationHeader, AuthenticatedRouteName, logger, cancellationToken)). + WithName(AuthenticatedRouteName). RequireAuthorization(). ProducesProblem(StatusCodes.Status400BadRequest). ProducesProblem(StatusCodes.Status401Unauthorized). @@ -25,14 +40,27 @@ public static void AddAuthorizationHeaderRequestEndpoints(this WebApplication ap WithDescription( "This endpoint will use the identity of the authenticated request to acquire an authorization header." + "Use dotted query parameters prefixed with 'optionsOverride.' to override call settings with respect to the configuration. " + + "Whether overrides are honoured is controlled by 'Sidecar:AllowOverrides:GetAuthorizationHeader' (default: true). " + + "'optionsOverride.BaseUrl' is always ignored. " + "Examples:\n" + " ?optionsOverride.Scopes=User.Read&optionsOverride.Scopes=Mail.Read\n" + " ?optionsOverride.RequestAppToken=true&optionsOverride.Scopes=https://graph.microsoft.com/.default\n" + " ?optionsOverride.AcquireTokenOptions.Tenant=GUID\n" + "Repeat parameters like 'optionsOverride.Scopes' to add multiple scopes."); - app.MapGet("/AuthorizationHeaderUnauthenticated/{apiName}", AuthorizationHeaderAsync). - WithName("AuthorizationHeaderUnauthenticated"). + app.MapGet("/AuthorizationHeaderUnauthenticated/{apiName}", + (HttpContext httpContext, [Description("The downstream API to acquire an authorization header for.")][FromRoute] string apiName, + [AsParameters] AuthorizationHeaderRequest requestParameters, + BindableDownstreamApiOptions optionsOverride, + [FromServices] IAuthorizationHeaderProvider headerProvider, + [FromServices] IOptionsMonitor optionsMonitor, + [FromServices] IOptions sidecarOptions, + [FromServices] ILogger logger, + CancellationToken cancellationToken) => + AuthorizationHeaderAsync( + httpContext, apiName, requestParameters, optionsOverride, headerProvider, optionsMonitor, + sidecarOptions.Value.AllowOverrides.GetAuthorizationHeaderUnauthenticated, UnauthenticatedRouteName, logger, cancellationToken)). + WithName(UnauthenticatedRouteName). AllowAnonymous(). ProducesProblem(StatusCodes.Status400BadRequest). ProducesProblem(StatusCodes.Status401Unauthorized). @@ -40,6 +68,8 @@ public static void AddAuthorizationHeaderRequestEndpoints(this WebApplication ap WithDescription( "This endpoint will use the configured client credentials to acquire an authorization header." + "Use dotted query parameters prefixed with 'optionsOverride.' to override call settings with respect to the configuration. " + + "Whether overrides are honoured is controlled by 'Sidecar:AllowOverrides:GetAuthorizationHeaderUnauthenticated' (default: false). " + + "'optionsOverride.BaseUrl' is always ignored. " + "Examples:\n" + " ?optionsOverride.Scopes=User.Read&optionsOverride.Scopes=Mail.Read\n" + " ?optionsOverride.RequestAppToken=true&optionsOverride.Scopes=https://graph.microsoft.com/.default\n" + @@ -49,14 +79,14 @@ public static void AddAuthorizationHeaderRequestEndpoints(this WebApplication ap private static async Task, ProblemHttpResult>> AuthorizationHeaderAsync( HttpContext httpContext, - [Description("The downstream API to acquire an authorization header for.")] - [FromRoute] string apiName, - [AsParameters] AuthorizationHeaderRequest requestParameters, + AuthorizationHeaderRequest requestParameters, BindableDownstreamApiOptions optionsOverride, - [FromServices] IAuthorizationHeaderProvider headerProvider, - [FromServices] IOptionsMonitor optionsMonitor, - [FromServices] ILogger logger, + IAuthorizationHeaderProvider headerProvider, + IOptionsMonitor optionsMonitor, + bool allowOverrides, + string routeName, + ILogger logger, CancellationToken cancellationToken) { DownstreamApiOptions? options = optionsMonitor.Get(apiName); @@ -70,7 +100,14 @@ public static void AddAuthorizationHeaderRequestEndpoints(this WebApplication ap if (optionsOverride.HasAny) { - options = DownstreamApiOptionsMerger.MergeOptions(options, optionsOverride); + if (allowOverrides) + { + options = DownstreamApiOptionsMerger.MergeOptions(options, optionsOverride); + } + else + { + logger.OverridesIgnored(routeName); + } } if (options.Scopes is null) diff --git a/src/Microsoft.Identity.Web.Sidecar/Endpoints/DownstreamApiEndpoint.cs b/src/Microsoft.Identity.Web.Sidecar/Endpoints/DownstreamApiEndpoint.cs index c869b01fd..c241fc451 100644 --- a/src/Microsoft.Identity.Web.Sidecar/Endpoints/DownstreamApiEndpoint.cs +++ b/src/Microsoft.Identity.Web.Sidecar/Endpoints/DownstreamApiEndpoint.cs @@ -9,6 +9,7 @@ using Microsoft.Extensions.Options; using Microsoft.Identity.Abstractions; using Microsoft.Identity.Client; +using Microsoft.Identity.Web.Sidecar.Configuration; using Microsoft.Identity.Web.Sidecar.Logging; using Microsoft.Identity.Web.Sidecar.Models; @@ -16,30 +17,59 @@ namespace Microsoft.Identity.Web.Sidecar.Endpoints; public static class DownstreamApiEndpoint { + internal const string AuthenticatedRouteName = "DownstreamApi"; + internal const string UnauthenticatedRouteName = "DownstreamApiUnauthenticated"; + public static void AddDownstreamApiRequestEndpoints(this WebApplication app) { - app.MapPost("/DownstreamApi/{apiName}", DownstreamApiAsync). - WithName("DownstreamApi"). + app.MapPost("/DownstreamApi/{apiName}", + (HttpContext httpContext, [Description("The downstream API to call")][FromRoute] string apiName, + [AsParameters] DownstreamApiRequest requestParameters, + BindableDownstreamApiOptions optionsOverride, + [FromServices] IDownstreamApi downstreamApi, + [FromServices] IOptionsMonitor optionsMonitor, + [FromServices] IOptions sidecarOptions, + [FromServices] ILogger logger, + CancellationToken cancellationToken) => + DownstreamApiAsync( + httpContext, apiName, requestParameters, optionsOverride, downstreamApi, optionsMonitor, + sidecarOptions.Value.AllowOverrides.CallDownstreamApi, AuthenticatedRouteName, logger, cancellationToken)). + WithName(AuthenticatedRouteName). RequireAuthorization(). ProducesProblem(StatusCodes.Status400BadRequest). ProducesProblem(StatusCodes.Status401Unauthorized). WithSummary("Invoke a configured downstream API through the sidecar using the authenticated identity."). WithDescription( "Override downstream call options using dotted query parameters prefixed with 'optionsOverride.'. " + + "Whether overrides are honoured is controlled by 'Sidecar:AllowOverrides:CallDownstreamApi' (default: true). " + + "'optionsOverride.BaseUrl' is always ignored. " + "Examples:\n" + " ?optionsOverride.Scopes=User.Read\n" + " ?optionsOverride.Scopes=User.Read&optionsOverride.Scopes=Mail.Read\n" + " ?optionsOverride.AcquireTokenOptions.Tenant=GUID\n" + " ?optionsOverride.RequestAppToken=true&optionsOverride.Scopes=https://graph.microsoft.com/.default"); - app.MapPost("/DownstreamApiUnauthenticated/{apiName}", DownstreamApiAsync). - WithName("DownstreamApiUnauthenticated"). + app.MapPost("/DownstreamApiUnauthenticated/{apiName}", + (HttpContext httpContext, [Description("The downstream API to call")][FromRoute] string apiName, + [AsParameters] DownstreamApiRequest requestParameters, + BindableDownstreamApiOptions optionsOverride, + [FromServices] IDownstreamApi downstreamApi, + [FromServices] IOptionsMonitor optionsMonitor, + [FromServices] IOptions sidecarOptions, + [FromServices] ILogger logger, + CancellationToken cancellationToken) => + DownstreamApiAsync( + httpContext, apiName, requestParameters, optionsOverride, downstreamApi, optionsMonitor, + sidecarOptions.Value.AllowOverrides.CallDownstreamApiUnauthenticated, UnauthenticatedRouteName, logger, cancellationToken)). + WithName(UnauthenticatedRouteName). AllowAnonymous(). ProducesProblem(StatusCodes.Status400BadRequest). ProducesProblem(StatusCodes.Status401Unauthorized). WithSummary("Invoke a configured downstream API through the sidecar using the configured client credentials."). WithDescription( "Override downstream call options using dotted query parameters prefixed with 'optionsOverride.'. " + + "Whether overrides are honoured is controlled by 'Sidecar:AllowOverrides:CallDownstreamApiUnauthenticated' (default: false). " + + "'optionsOverride.BaseUrl' is always ignored. " + "Examples:\n" + " ?optionsOverride.Scopes=User.Read\n" + " ?optionsOverride.Scopes=User.Read&optionsOverride.Scopes=Mail.Read\n" + @@ -49,14 +79,14 @@ public static void AddDownstreamApiRequestEndpoints(this WebApplication app) private static async Task, ProblemHttpResult>> DownstreamApiAsync( HttpContext httpContext, - [Description("The downstream API to call")] - [FromRoute] string apiName, - [AsParameters] DownstreamApiRequest requestParameters, + DownstreamApiRequest requestParameters, BindableDownstreamApiOptions optionsOverride, - [FromServices] IDownstreamApi downstreamApi, - [FromServices] IOptionsMonitor optionsMonitor, - [FromServices] ILogger logger, + IDownstreamApi downstreamApi, + IOptionsMonitor optionsMonitor, + bool allowOverrides, + string routeName, + ILogger logger, CancellationToken cancellationToken) { DownstreamApiOptions? options = optionsMonitor.Get(apiName); @@ -70,7 +100,14 @@ private static async Task, ProblemHttpResult>> D if (optionsOverride.HasAny) { - options = DownstreamApiOptionsMerger.MergeOptions(options, optionsOverride); + if (allowOverrides) + { + options = DownstreamApiOptionsMerger.MergeOptions(options, optionsOverride); + } + else + { + logger.OverridesIgnored(routeName); + } } if (options.Scopes is null || !options.Scopes.Any()) diff --git a/src/Microsoft.Identity.Web.Sidecar/Logging/Logging.cs b/src/Microsoft.Identity.Web.Sidecar/Logging/Logging.cs index 18af28346..9a7002dc1 100644 --- a/src/Microsoft.Identity.Web.Sidecar/Logging/Logging.cs +++ b/src/Microsoft.Identity.Web.Sidecar/Logging/Logging.cs @@ -18,4 +18,18 @@ public static partial class LoggerMessageExtensions Message = "An error occurred while parsing the token.", EventName = "ValidateRequest_UnableToParseToken")] public static partial void UnableToParseToken(this ILogger logger, Exception? exception); + + [LoggerMessage( + EventId = 3, + Level = LogLevel.Warning, + Message = "Caller-supplied 'optionsOverride.*' parameters were ignored on route '{RouteName}' because overrides are not allowed for it by configuration.", + EventName = "OverridesIgnored")] + public static partial void OverridesIgnored(this ILogger logger, string routeName); + + [LoggerMessage( + EventId = 4, + Level = LogLevel.Warning, + Message = "Caller-supplied 'optionsOverride.BaseUrl' was ignored. The downstream BaseUrl is fixed by the host configuration and cannot be overridden by the caller.", + EventName = "BaseUrlOverrideIgnored")] + public static partial void BaseUrlOverrideIgnored(this ILogger logger); } diff --git a/src/Microsoft.Identity.Web.Sidecar/Models/BindableDownstreamApiOptions.cs b/src/Microsoft.Identity.Web.Sidecar/Models/BindableDownstreamApiOptions.cs index 33c662668..070885e1d 100644 --- a/src/Microsoft.Identity.Web.Sidecar/Models/BindableDownstreamApiOptions.cs +++ b/src/Microsoft.Identity.Web.Sidecar/Models/BindableDownstreamApiOptions.cs @@ -4,7 +4,10 @@ using System.Reflection; using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Web.Sidecar.Logging; namespace Microsoft.Identity.Web.Sidecar.Models; @@ -28,8 +31,25 @@ public BindableDownstreamApiOptions() public static ValueTask BindAsync(HttpContext ctx, ParameterInfo parameter) { var paramName = parameter.Name ?? "optionsOverride"; - bool hasAny = ctx.Request.Query.Keys.Any(k => - k.StartsWith(paramName + ".", StringComparison.OrdinalIgnoreCase)); + var prefix = paramName + "."; + var baseUrlKey = paramName + ".BaseUrl"; + + bool hasAny = false; + bool hasNonBaseUrlOverride = false; + foreach (var k in ctx.Request.Query.Keys) + { + if (!k.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + hasAny = true; + if (!k.Equals(baseUrlKey, StringComparison.OrdinalIgnoreCase)) + { + hasNonBaseUrlOverride = true; + break; + } + } var result = new BindableDownstreamApiOptions(); @@ -38,7 +58,10 @@ public BindableDownstreamApiOptions() return ValueTask.FromResult(result); } - result.HasAny = true; + // HasAny only reflects non-BaseUrl overrides because BaseUrl is always + // ignored. This avoids a misleading "OverridesIgnored" warning at the + // route layer when the caller only supplied an (already-rejected) BaseUrl. + result.HasAny = hasNonBaseUrlOverride; var query = ctx.Request.Query; @@ -103,7 +126,12 @@ public BindableDownstreamApiOptions() } else if (path.Equals("BaseUrl", StringComparison.OrdinalIgnoreCase)) { - result.BaseUrl = values.LastOrDefault(); + // Caller-supplied BaseUrl is unconditionally rejected: the downstream + // BaseUrl is fixed by the host configuration and cannot be overridden + // through optionsOverride. Log a warning and ignore the value. + var loggerFactory = ctx.RequestServices.GetService(); + loggerFactory?.CreateLogger(typeof(BindableDownstreamApiOptions).FullName!) + .BaseUrlOverrideIgnored(); } else if (path.Equals("RelativePath", StringComparison.OrdinalIgnoreCase)) { diff --git a/src/Microsoft.Identity.Web.Sidecar/OpenAPI/Microsoft.Identity.Web.Sidecar.json b/src/Microsoft.Identity.Web.Sidecar/OpenAPI/Microsoft.Identity.Web.Sidecar.json index 56f5845e4..6966a9695 100644 --- a/src/Microsoft.Identity.Web.Sidecar/OpenAPI/Microsoft.Identity.Web.Sidecar.json +++ b/src/Microsoft.Identity.Web.Sidecar/OpenAPI/Microsoft.Identity.Web.Sidecar.json @@ -48,10 +48,10 @@ "/AuthorizationHeader/{apiName}": { "get": { "tags": [ - "AuthorizationHeaderEndpoint" + "Microsoft.Identity.Web.Sidecar" ], "summary": "Get an authorization header for a configured downstream API.", - "description": "This endpoint will use the identity of the authenticated request to acquire an authorization header.Use dotted query parameters prefixed with 'optionsOverride.' to override call settings with respect to the configuration. Examples:\n ?optionsOverride.Scopes=User.Read&optionsOverride.Scopes=Mail.Read\n ?optionsOverride.RequestAppToken=true&optionsOverride.Scopes=https://graph.microsoft.com/.default\n ?optionsOverride.AcquireTokenOptions.Tenant=GUID\nRepeat parameters like 'optionsOverride.Scopes' to add multiple scopes.", + "description": "This endpoint will use the identity of the authenticated request to acquire an authorization header.Use dotted query parameters prefixed with 'optionsOverride.' to override call settings with respect to the configuration. Whether overrides are honoured is controlled by 'Sidecar:AllowOverrides:GetAuthorizationHeader' (default: true). 'optionsOverride.BaseUrl' is always ignored. Examples:\n ?optionsOverride.Scopes=User.Read&optionsOverride.Scopes=Mail.Read\n ?optionsOverride.RequestAppToken=true&optionsOverride.Scopes=https://graph.microsoft.com/.default\n ?optionsOverride.AcquireTokenOptions.Tenant=GUID\nRepeat parameters like 'optionsOverride.Scopes' to add multiple scopes.", "operationId": "AuthorizationHeader", "parameters": [ { @@ -107,7 +107,8 @@ { "name": "optionsOverride.BaseUrl", "in": "query", - "description": "Override downstream API base URL.", + "description": "Ignored. The downstream BaseUrl is fixed by host configuration and cannot be overridden via optionsOverride.", + "deprecated": true, "schema": { "type": "string" } @@ -246,10 +247,10 @@ "/AuthorizationHeaderUnauthenticated/{apiName}": { "get": { "tags": [ - "AuthorizationHeaderEndpoint" + "Microsoft.Identity.Web.Sidecar" ], "summary": "Get an authorization header for a configured downstream API using this configured client credentials.", - "description": "This endpoint will use the configured client credentials to acquire an authorization header.Use dotted query parameters prefixed with 'optionsOverride.' to override call settings with respect to the configuration. Examples:\n ?optionsOverride.Scopes=User.Read&optionsOverride.Scopes=Mail.Read\n ?optionsOverride.RequestAppToken=true&optionsOverride.Scopes=https://graph.microsoft.com/.default\n ?optionsOverride.AcquireTokenOptions.Tenant=GUID\nRepeat parameters like 'optionsOverride.Scopes' to add multiple scopes.", + "description": "This endpoint will use the configured client credentials to acquire an authorization header.Use dotted query parameters prefixed with 'optionsOverride.' to override call settings with respect to the configuration. Whether overrides are honoured is controlled by 'Sidecar:AllowOverrides:GetAuthorizationHeaderUnauthenticated' (default: false). 'optionsOverride.BaseUrl' is always ignored. Examples:\n ?optionsOverride.Scopes=User.Read&optionsOverride.Scopes=Mail.Read\n ?optionsOverride.RequestAppToken=true&optionsOverride.Scopes=https://graph.microsoft.com/.default\n ?optionsOverride.AcquireTokenOptions.Tenant=GUID\nRepeat parameters like 'optionsOverride.Scopes' to add multiple scopes.", "operationId": "AuthorizationHeaderUnauthenticated", "parameters": [ { @@ -305,7 +306,8 @@ { "name": "optionsOverride.BaseUrl", "in": "query", - "description": "Override downstream API base URL.", + "description": "Ignored. The downstream BaseUrl is fixed by host configuration and cannot be overridden via optionsOverride.", + "deprecated": true, "schema": { "type": "string" } @@ -444,10 +446,10 @@ "/DownstreamApi/{apiName}": { "post": { "tags": [ - "DownstreamApiEndpoint" + "Microsoft.Identity.Web.Sidecar" ], "summary": "Invoke a configured downstream API through the sidecar using the authenticated identity.", - "description": "Override downstream call options using dotted query parameters prefixed with 'optionsOverride.'. Examples:\n ?optionsOverride.Scopes=User.Read\n ?optionsOverride.Scopes=User.Read&optionsOverride.Scopes=Mail.Read\n ?optionsOverride.AcquireTokenOptions.Tenant=GUID\n ?optionsOverride.RequestAppToken=true&optionsOverride.Scopes=https://graph.microsoft.com/.default", + "description": "Override downstream call options using dotted query parameters prefixed with 'optionsOverride.'. Whether overrides are honoured is controlled by 'Sidecar:AllowOverrides:CallDownstreamApi' (default: true). 'optionsOverride.BaseUrl' is always ignored. Examples:\n ?optionsOverride.Scopes=User.Read\n ?optionsOverride.Scopes=User.Read&optionsOverride.Scopes=Mail.Read\n ?optionsOverride.AcquireTokenOptions.Tenant=GUID\n ?optionsOverride.RequestAppToken=true&optionsOverride.Scopes=https://graph.microsoft.com/.default", "operationId": "DownstreamApi", "parameters": [ { @@ -503,7 +505,8 @@ { "name": "optionsOverride.BaseUrl", "in": "query", - "description": "Override downstream API base URL.", + "description": "Ignored. The downstream BaseUrl is fixed by host configuration and cannot be overridden via optionsOverride.", + "deprecated": true, "schema": { "type": "string" } @@ -642,10 +645,10 @@ "/DownstreamApiUnauthenticated/{apiName}": { "post": { "tags": [ - "DownstreamApiEndpoint" + "Microsoft.Identity.Web.Sidecar" ], "summary": "Invoke a configured downstream API through the sidecar using the configured client credentials.", - "description": "Override downstream call options using dotted query parameters prefixed with 'optionsOverride.'. Examples:\n ?optionsOverride.Scopes=User.Read\n ?optionsOverride.Scopes=User.Read&optionsOverride.Scopes=Mail.Read\n ?optionsOverride.AcquireTokenOptions.Tenant=GUID\n ?optionsOverride.RequestAppToken=true&optionsOverride.Scopes=https://graph.microsoft.com/.default", + "description": "Override downstream call options using dotted query parameters prefixed with 'optionsOverride.'. Whether overrides are honoured is controlled by 'Sidecar:AllowOverrides:CallDownstreamApiUnauthenticated' (default: false). 'optionsOverride.BaseUrl' is always ignored. Examples:\n ?optionsOverride.Scopes=User.Read\n ?optionsOverride.Scopes=User.Read&optionsOverride.Scopes=Mail.Read\n ?optionsOverride.AcquireTokenOptions.Tenant=GUID\n ?optionsOverride.RequestAppToken=true&optionsOverride.Scopes=https://graph.microsoft.com/.default", "operationId": "DownstreamApiUnauthenticated", "parameters": [ { @@ -701,7 +704,8 @@ { "name": "optionsOverride.BaseUrl", "in": "query", - "description": "Override downstream API base URL.", + "description": "Ignored. The downstream BaseUrl is fixed by host configuration and cannot be overridden via optionsOverride.", + "deprecated": true, "schema": { "type": "string" } @@ -931,10 +935,7 @@ "name": "ValidateRequestEndpoints" }, { - "name": "AuthorizationHeaderEndpoint" - }, - { - "name": "DownstreamApiEndpoint" + "name": "Microsoft.Identity.Web.Sidecar" } ] } \ No newline at end of file diff --git a/src/Microsoft.Identity.Web.Sidecar/OpenApiDescriptions.cs b/src/Microsoft.Identity.Web.Sidecar/OpenApiDescriptions.cs index 1730f1284..447f068cb 100644 --- a/src/Microsoft.Identity.Web.Sidecar/OpenApiDescriptions.cs +++ b/src/Microsoft.Identity.Web.Sidecar/OpenApiDescriptions.cs @@ -25,7 +25,15 @@ internal static void AddOptionsOverrideParameters(OpenApiOperation op) AddSimple(op, "optionsOverride.RequestAppToken", "boolean", "true = acquire an app (client credentials) token instead of user token."); // Base request shaping - AddSimple(op, "optionsOverride.BaseUrl", "string", "Override downstream API base URL."); + op.Parameters.Add(new OpenApiParameter + { + Name = "optionsOverride.BaseUrl", + In = ParameterLocation.Query, + Description = "Ignored. The downstream BaseUrl is fixed by host configuration and cannot be overridden via optionsOverride.", + Required = false, + Deprecated = true, + Schema = new OpenApiSchema { Type = "string" } + }); AddSimple(op, "optionsOverride.RelativePath", "string", "Override relative path appended to BaseUrl."); AddSimple(op, "optionsOverride.HttpMethod", "string", "Override HTTP method (GET, POST, PATCH, etc.)."); AddSimple(op, "optionsOverride.AcceptHeader", "string", "Sets Accept header (e.g. application/json)."); diff --git a/src/Microsoft.Identity.Web.Sidecar/Program.cs b/src/Microsoft.Identity.Web.Sidecar/Program.cs index 3c6b28763..0051452f8 100644 --- a/src/Microsoft.Identity.Web.Sidecar/Program.cs +++ b/src/Microsoft.Identity.Web.Sidecar/Program.cs @@ -4,6 +4,7 @@ using System.Diagnostics.CodeAnalysis; using System.IdentityModel.Tokens.Jwt; using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Identity.Web.Sidecar.Configuration; using Microsoft.Identity.Web.Sidecar.Endpoints; using Microsoft.IdentityModel.JsonWebTokens; @@ -51,6 +52,8 @@ public static void Main(string[] args) builder.Services.AddAgentIdentities() .AddDownstreamApis(builder.Configuration.GetSection("DownstreamApis")); + builder.Services.Configure(builder.Configuration.GetSection("Sidecar")); + builder.Services.AddHealthChecks(); ConfigureAuthN(builder); diff --git a/src/Microsoft.Identity.Web.Sidecar/appsettings.json b/src/Microsoft.Identity.Web.Sidecar/appsettings.json index ed24c71e2..8a0fed809 100644 --- a/src/Microsoft.Identity.Web.Sidecar/appsettings.json +++ b/src/Microsoft.Identity.Web.Sidecar/appsettings.json @@ -29,6 +29,18 @@ // } //}, + // Per-route control of whether 'optionsOverride.*' query parameters are + // applied to the resolved DownstreamApiOptions. 'optionsOverride.BaseUrl' + // is unconditionally ignored regardless of these flags. + //"Sidecar": { + // "AllowOverrides": { + // "GetAuthorizationHeader": true, + // "GetAuthorizationHeaderUnauthenticated": false, + // "CallDownstreamApi": true, + // "CallDownstreamApiUnauthenticated": false + // } + //}, + "Logging": { "LogLevel": { "Default": "Information", diff --git a/tests/E2E Tests/Sidecar.Tests/DownstreamApiOptionsMergeTests.cs b/tests/E2E Tests/Sidecar.Tests/DownstreamApiOptionsMergeTests.cs index 718947df1..f17cd3cab 100644 --- a/tests/E2E Tests/Sidecar.Tests/DownstreamApiOptionsMergeTests.cs +++ b/tests/E2E Tests/Sidecar.Tests/DownstreamApiOptionsMergeTests.cs @@ -594,7 +594,7 @@ public void MergeDownstreamApiOptionsOverrides_WithRequestAppTokenFalse_DoesNotO } [Fact] - public void MergeDownstreamApiOptionsOverrides_WithBaseUrlOverride_OverridesBaseUrl() + public void MergeDownstreamApiOptionsOverrides_WithBaseUrlOverride_PreservesOriginalBaseUrl() { // Arrange var left = new DownstreamApiOptions @@ -609,8 +609,8 @@ public void MergeDownstreamApiOptionsOverrides_WithBaseUrlOverride_OverridesBase // Act var result = DownstreamApiOptionsMerger.MergeOptions(left, right); - // Assert - Assert.Equal("https://new.api.com/", result.BaseUrl); + // Assert - BaseUrl from the override side is intentionally not applied. + Assert.Equal("https://original.api.com/", result.BaseUrl); } [Fact] diff --git a/tests/E2E Tests/Sidecar.Tests/ModelsTests.cs b/tests/E2E Tests/Sidecar.Tests/ModelsTests.cs index 09f447b00..dad88a237 100644 --- a/tests/E2E Tests/Sidecar.Tests/ModelsTests.cs +++ b/tests/E2E Tests/Sidecar.Tests/ModelsTests.cs @@ -1,7 +1,12 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using System.Reflection; using System.Text.Json.Nodes; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Identity.Web.Sidecar.Models; using Xunit; @@ -353,4 +358,88 @@ public void DownstreamApiResult_WithSpecialCharactersInContent_HandlesCorrectly( Assert.Equal(statusCode, result.StatusCode); Assert.Equal(specialContent, result.Content); } + + private static ParameterInfo GetOptionsOverrideParameter() + { + // Re-use any method whose first parameter is named "optionsOverride". + // We just need a ParameterInfo with the right Name for BindAsync. + var method = typeof(BindableDownstreamApiOptionsTestHelper) + .GetMethod(nameof(BindableDownstreamApiOptionsTestHelper.Sample), BindingFlags.Static | BindingFlags.NonPublic)!; + return method.GetParameters()[0]; + } + + private static HttpContext CreateHttpContext(string queryString) + { + var ctx = new DefaultHttpContext(); + ctx.Request.QueryString = new QueryString(queryString); + var services = new ServiceCollection(); + services.AddSingleton(NullLoggerFactory.Instance); + ctx.RequestServices = services.BuildServiceProvider(); + return ctx; + } + + [Fact] + public async Task BindableDownstreamApiOptions_HasAny_True_WhenNonBaseUrlOverridePresent() + { + // Arrange + var ctx = CreateHttpContext("?optionsOverride.Scopes=User.Read"); + + // Act + var result = await BindableDownstreamApiOptions.BindAsync(ctx, GetOptionsOverrideParameter()); + + // Assert + Assert.NotNull(result); + Assert.True(result!.HasAny); + } + + [Fact] + public async Task BindableDownstreamApiOptions_HasAny_False_WhenOnlyBaseUrlOverridePresent() + { + // Arrange + var ctx = CreateHttpContext("?optionsOverride.BaseUrl=https://other.example.com"); + + // Act + var result = await BindableDownstreamApiOptions.BindAsync(ctx, GetOptionsOverrideParameter()); + + // Assert + Assert.NotNull(result); + Assert.False(result!.HasAny); + Assert.Null(result.BaseUrl); + } + + [Fact] + public async Task BindableDownstreamApiOptions_HasAny_True_WhenBaseUrlAndScopesOverridesPresent() + { + // Arrange + var ctx = CreateHttpContext("?optionsOverride.BaseUrl=https://other.example.com&optionsOverride.Scopes=User.Read"); + + // Act + var result = await BindableDownstreamApiOptions.BindAsync(ctx, GetOptionsOverrideParameter()); + + // Assert + Assert.NotNull(result); + Assert.True(result!.HasAny); + Assert.Null(result.BaseUrl); + Assert.NotNull(result.Scopes); + Assert.Contains("User.Read", result.Scopes!); + } + + [Fact] + public async Task BindableDownstreamApiOptions_HasAny_False_WhenNoOverridesPresent() + { + // Arrange + var ctx = CreateHttpContext("?unrelated=value"); + + // Act + var result = await BindableDownstreamApiOptions.BindAsync(ctx, GetOptionsOverrideParameter()); + + // Assert + Assert.NotNull(result); + Assert.False(result!.HasAny); + } + + private static class BindableDownstreamApiOptionsTestHelper + { + internal static void Sample(BindableDownstreamApiOptions optionsOverride) { _ = optionsOverride; } + } } diff --git a/tests/E2E Tests/Sidecar.Tests/OptionsOverrideHeaderHandlingTests.cs b/tests/E2E Tests/Sidecar.Tests/OptionsOverrideHeaderHandlingTests.cs new file mode 100644 index 000000000..c3e6cb49d --- /dev/null +++ b/tests/E2E Tests/Sidecar.Tests/OptionsOverrideHeaderHandlingTests.cs @@ -0,0 +1,138 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Net; +using System.Net.Http.Headers; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Web; +using Xunit; + +namespace Sidecar.Tests; + +/// +/// Validates how the library treats caller-supplied entries in +/// when those entries +/// arrive through the sidecar's optionsOverride.* query parameters. +/// +public class OptionsOverrideHeaderHandlingTests(SidecarApiFactory factory) : IClassFixture +{ + private readonly SidecarApiFactory _factory = factory; + + [Fact(Skip = "Pending dependency merge")] + public async Task DownstreamApi_OptionsOverrideExtraHeaderParameters_AuthorizationOverride_DoesNotReachOutboundRequest() + { + // Arrange + const string apiName = "test-api"; + const string libraryAuthHeader = "Bearer library-issued-token"; + var capture = new CapturingHttpMessageHandler(HttpStatusCode.OK); + + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + TestAuthenticationHandler.AddAlwaysSucceedTestAuthentication(services); + + services.AddSingleton( + new TestAuthorizationHeaderProvider { Result = libraryAuthHeader }); + + services.AddSingleton(new CapturingHttpClientFactory(capture)); + + services.Configure(apiName, options => + { + options.BaseUrl = "https://api.example.com"; + options.RelativePath = "/test"; + options.HttpMethod = HttpMethod.Get.Method; + options.Scopes = new[] { "User.Read" }; + }); + }); + }).CreateClient(); + + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "valid-test-token"); + + // Act ΓÇö caller tries to inject a different Authorization value via optionsOverride. + var response = await client.PostAsync( + $"/DownstreamApi/{apiName}?optionsOverride.ExtraHeaderParameters.Authorization=Bearer%20caller-supplied", + content: null); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(capture.LastRequest); + var authValues = capture.LastRequest!.Headers.GetValues("Authorization").ToArray(); + Assert.Single(authValues); + Assert.Equal(libraryAuthHeader, authValues[0]); + } + + [Fact(Skip = "Pending dependency merge")] + public async Task DownstreamApi_OptionsOverrideExtraHeaderParameters_AllowedHeader_IsForwarded() + { + // Arrange + const string apiName = "test-api"; + var capture = new CapturingHttpMessageHandler(HttpStatusCode.OK); + + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + TestAuthenticationHandler.AddAlwaysSucceedTestAuthentication(services); + + services.AddSingleton( + new TestAuthorizationHeaderProvider { Result = "Bearer library-issued-token" }); + + services.AddSingleton(new CapturingHttpClientFactory(capture)); + + services.Configure(apiName, options => + { + options.BaseUrl = "https://api.example.com"; + options.RelativePath = "/test"; + options.HttpMethod = HttpMethod.Get.Method; + options.Scopes = new[] { "User.Read" }; + }); + }); + }).CreateClient(); + + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "valid-test-token"); + + // Act + var response = await client.PostAsync( + $"/DownstreamApi/{apiName}?optionsOverride.ExtraHeaderParameters.X-Custom-Tracking=trace-id-42", + content: null); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(capture.LastRequest); + Assert.True(capture.LastRequest!.Headers.Contains("X-Custom-Tracking")); + Assert.Equal("trace-id-42", capture.LastRequest!.Headers.GetValues("X-Custom-Tracking").Single()); + } + + private sealed class CapturingHttpMessageHandler : HttpMessageHandler + { + private readonly HttpStatusCode _statusCode; + + public CapturingHttpMessageHandler(HttpStatusCode statusCode) + { + _statusCode = statusCode; + } + + public HttpRequestMessage? LastRequest { get; private set; } + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequest = request; + return Task.FromResult(new HttpResponseMessage(_statusCode)); + } + } + + private sealed class CapturingHttpClientFactory : IHttpClientFactory + { + private readonly CapturingHttpMessageHandler _handler; + + public CapturingHttpClientFactory(CapturingHttpMessageHandler handler) + { + _handler = handler; + } + + public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false); + } +} diff --git a/tests/E2E Tests/Sidecar.Tests/SidecarOverrideGatingTests.cs b/tests/E2E Tests/Sidecar.Tests/SidecarOverrideGatingTests.cs new file mode 100644 index 000000000..d403e8856 --- /dev/null +++ b/tests/E2E Tests/Sidecar.Tests/SidecarOverrideGatingTests.cs @@ -0,0 +1,302 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Net; +using System.Net.Http.Headers; +using System.Security.Claims; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Web; +using Xunit; + +namespace Sidecar.Tests; + +/// +/// Exercises the per-route Sidecar:AllowOverrides configuration that +/// controls whether optionsOverride.* query parameters are applied to a +/// resolved instance, and verifies that +/// optionsOverride.BaseUrl is unconditionally dropped on every route. +/// +public class SidecarOverrideGatingTests : IClassFixture +{ + private readonly SidecarApiFactory _factory; + + public SidecarOverrideGatingTests(SidecarApiFactory factory) + { + _factory = factory; + } + + private HttpClient CreateClient( + Dictionary sidecarConfig, + IAuthorizationHeaderProvider headerProvider, + Action configureApi, + string apiName, + IHttpClientFactory? httpClientFactory = null) + { + return _factory.WithWebHostBuilder(builder => + { + builder.ConfigureAppConfiguration((_, config) => + { + config.AddInMemoryCollection(sidecarConfig); + }); + + builder.ConfigureServices(services => + { + TestAuthenticationHandler.AddAlwaysSucceedTestAuthentication(services); + services.AddSingleton(headerProvider); + services.Configure(apiName, configureApi); + + if (httpClientFactory is not null) + { + services.AddSingleton(httpClientFactory); + } + }); + }).CreateClient(); + } + + private static void AddBearer(HttpClient client) => + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "valid-test-token"); + + [Fact] + public async Task AuthorizationHeader_OverridesAllowed_AppliesScopeOverride() + { + const string apiName = "test-api"; + var capture = new CapturingAuthorizationHeaderProvider { Result = "Bearer t" }; + + var client = CreateClient( + new Dictionary + { + { "Sidecar:AllowOverrides:GetAuthorizationHeader", "true" }, + }, + capture, + options => + { + options.BaseUrl = "https://api.example.com"; + options.RelativePath = "/test"; + options.Scopes = new[] { "User.Read" }; + }, + apiName); + AddBearer(client); + + var response = await client.GetAsync($"/AuthorizationHeader/{apiName}?optionsOverride.Scopes=Mail.Read"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(capture.LastScopes); + Assert.Contains("Mail.Read", capture.LastScopes!); + } + + [Fact] + public async Task AuthorizationHeader_OverridesDisabled_IgnoresScopeOverride() + { + const string apiName = "test-api"; + var capture = new CapturingAuthorizationHeaderProvider { Result = "Bearer t" }; + + var client = CreateClient( + new Dictionary + { + { "Sidecar:AllowOverrides:GetAuthorizationHeader", "false" }, + }, + capture, + options => + { + options.BaseUrl = "https://api.example.com"; + options.RelativePath = "/test"; + options.Scopes = new[] { "User.Read" }; + }, + apiName); + AddBearer(client); + + var response = await client.GetAsync($"/AuthorizationHeader/{apiName}?optionsOverride.Scopes=Mail.Read"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(capture.LastScopes); + Assert.DoesNotContain("Mail.Read", capture.LastScopes!); + Assert.Contains("User.Read", capture.LastScopes!); + } + + [Fact] + public async Task AuthorizationHeaderUnauthenticated_DefaultIgnoresOverride() + { + const string apiName = "test-api"; + var capture = new CapturingAuthorizationHeaderProvider { Result = "Bearer t" }; + + // No explicit Sidecar config: default is GetAuthorizationHeaderUnauthenticated=false. + var client = CreateClient( + new Dictionary(), + capture, + options => + { + options.BaseUrl = "https://api.example.com"; + options.RelativePath = "/test"; + options.Scopes = new[] { "User.Read" }; + }, + apiName); + + var response = await client.GetAsync($"/AuthorizationHeaderUnauthenticated/{apiName}?optionsOverride.Scopes=Mail.Read"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(capture.LastScopes); + Assert.DoesNotContain("Mail.Read", capture.LastScopes!); + } + + [Fact] + public async Task AuthorizationHeaderUnauthenticated_OverridesEnabled_AppliesOverride() + { + const string apiName = "test-api"; + var capture = new CapturingAuthorizationHeaderProvider { Result = "Bearer t" }; + + var client = CreateClient( + new Dictionary + { + { "Sidecar:AllowOverrides:GetAuthorizationHeaderUnauthenticated", "true" }, + }, + capture, + options => + { + options.BaseUrl = "https://api.example.com"; + options.RelativePath = "/test"; + options.Scopes = new[] { "User.Read" }; + }, + apiName); + + var response = await client.GetAsync($"/AuthorizationHeaderUnauthenticated/{apiName}?optionsOverride.Scopes=Mail.Read"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(capture.LastScopes); + Assert.Contains("Mail.Read", capture.LastScopes!); + } + + [Fact] + public async Task DownstreamApi_BaseUrlOverride_AlwaysDropped_OverridesEnabled() + { + const string apiName = "test-api"; + var capture = new CapturingHttpMessageHandler(HttpStatusCode.OK); + var headerProvider = new CapturingAuthorizationHeaderProvider { Result = "Bearer library-token" }; + + var client = CreateClient( + new Dictionary + { + { "Sidecar:AllowOverrides:CallDownstreamApi", "true" }, + }, + headerProvider, + options => + { + options.BaseUrl = "https://api.example.com"; + options.RelativePath = "/test"; + options.HttpMethod = HttpMethod.Get.Method; + options.Scopes = new[] { "User.Read" }; + }, + apiName, + httpClientFactory: new SingleHandlerHttpClientFactory(capture)); + AddBearer(client); + + var response = await client.PostAsync( + $"/DownstreamApi/{apiName}?optionsOverride.BaseUrl=https://evil.example.com", + content: null); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(capture.LastRequest); + Assert.StartsWith("https://api.example.com", capture.LastRequest!.RequestUri!.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task DownstreamApi_BaseUrlOverride_AlwaysDropped_OverridesDisabled() + { + const string apiName = "test-api"; + var capture = new CapturingHttpMessageHandler(HttpStatusCode.OK); + var headerProvider = new CapturingAuthorizationHeaderProvider { Result = "Bearer library-token" }; + + var client = CreateClient( + new Dictionary + { + { "Sidecar:AllowOverrides:CallDownstreamApi", "false" }, + }, + headerProvider, + options => + { + options.BaseUrl = "https://api.example.com"; + options.RelativePath = "/test"; + options.HttpMethod = HttpMethod.Get.Method; + options.Scopes = new[] { "User.Read" }; + }, + apiName, + httpClientFactory: new SingleHandlerHttpClientFactory(capture)); + AddBearer(client); + + var response = await client.PostAsync( + $"/DownstreamApi/{apiName}?optionsOverride.BaseUrl=https://evil.example.com", + content: null); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(capture.LastRequest); + Assert.StartsWith("https://api.example.com", capture.LastRequest!.RequestUri!.ToString(), StringComparison.Ordinal); + } + + private sealed class CapturingAuthorizationHeaderProvider : IAuthorizationHeaderProvider + { + public string? Result { get; init; } + + public IEnumerable? LastScopes { get; private set; } + + public Task CreateAuthorizationHeaderAsync( + IEnumerable scopes, + AuthorizationHeaderProviderOptions? options = null, + ClaimsPrincipal? claimsPrincipal = null, + CancellationToken cancellationToken = default) + { + LastScopes = scopes?.ToArray(); + return Task.FromResult(Result ?? string.Empty); + } + + public Task CreateAuthorizationHeaderForAppAsync( + string scopes, + AuthorizationHeaderProviderOptions? downstreamApiOptions = null, + CancellationToken cancellationToken = default) + { + LastScopes = string.IsNullOrEmpty(scopes) ? Array.Empty() : new[] { scopes }; + return Task.FromResult(Result ?? string.Empty); + } + + public Task CreateAuthorizationHeaderForUserAsync( + IEnumerable scopes, + AuthorizationHeaderProviderOptions? authorizationHeaderProviderOptions = null, + ClaimsPrincipal? claimsPrincipal = null, + CancellationToken cancellationToken = default) + { + LastScopes = scopes?.ToArray(); + return Task.FromResult(Result ?? string.Empty); + } + } + + private sealed class CapturingHttpMessageHandler : HttpMessageHandler + { + private readonly HttpStatusCode _statusCode; + + public CapturingHttpMessageHandler(HttpStatusCode statusCode) + { + _statusCode = statusCode; + } + + public HttpRequestMessage? LastRequest { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequest = request; + return Task.FromResult(new HttpResponseMessage(_statusCode)); + } + } + + private sealed class SingleHandlerHttpClientFactory : IHttpClientFactory + { + private readonly HttpMessageHandler _handler; + + public SingleHandlerHttpClientFactory(HttpMessageHandler handler) + { + _handler = handler; + } + + public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false); + } +}