diff --git a/src/Microsoft.Identity.Web.Sidecar/Endpoints/AuthorizationHeaderEndpoint.cs b/src/Microsoft.Identity.Web.Sidecar/Endpoints/AuthorizationHeaderEndpoint.cs index 0f96297ea..f4cc032e5 100644 --- a/src/Microsoft.Identity.Web.Sidecar/Endpoints/AuthorizationHeaderEndpoint.cs +++ b/src/Microsoft.Identity.Web.Sidecar/Endpoints/AuthorizationHeaderEndpoint.cs @@ -38,9 +38,10 @@ public static void AddAuthorizationHeaderRequestEndpoints(this WebApplication ap ProducesProblem(StatusCodes.Status401Unauthorized). WithSummary("Get an authorization header for a configured downstream API."). WithDescription( - "This endpoint will use the identity of the authenticated request to acquire an authorization header." + + "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). " + + "Agent identity parameters (AgentIdentity, AgentUsername, AgentUserId) are also subject to this setting. " + "'optionsOverride.BaseUrl' is always ignored. " + "Examples:\n" + " ?optionsOverride.Scopes=User.Read&optionsOverride.Scopes=Mail.Read\n" + @@ -66,9 +67,10 @@ public static void AddAuthorizationHeaderRequestEndpoints(this WebApplication ap ProducesProblem(StatusCodes.Status401Unauthorized). WithSummary("Get an authorization header for a configured downstream API using this configured client credentials."). WithDescription( - "This endpoint will use the configured client credentials to acquire an authorization header." + + "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). " + + "Agent identity parameters (AgentIdentity, AgentUsername, AgentUserId) are also subject to this setting. " + "'optionsOverride.BaseUrl' is always ignored. " + "Examples:\n" + " ?optionsOverride.Scopes=User.Read&optionsOverride.Scopes=Mail.Read\n" + @@ -117,7 +119,14 @@ public static void AddAuthorizationHeaderRequestEndpoints(this WebApplication ap statusCode: StatusCodes.Status400BadRequest); } - AgentOverrides.SetOverrides(options, requestParameters.AgentIdentity, requestParameters.AgentUsername, requestParameters.AgentUserId); + if (allowOverrides) + { + AgentOverrides.SetOverrides(options, requestParameters.AgentIdentity, requestParameters.AgentUsername, requestParameters.AgentUserId); + } + else if (requestParameters.AgentIdentity is not null || requestParameters.AgentUsername is not null || requestParameters.AgentUserId is not null) + { + logger.AgentIdentityOverridesIgnored(routeName); + } string authorizationHeader; diff --git a/src/Microsoft.Identity.Web.Sidecar/Endpoints/DownstreamApiEndpoint.cs b/src/Microsoft.Identity.Web.Sidecar/Endpoints/DownstreamApiEndpoint.cs index c241fc451..d6e645190 100644 --- a/src/Microsoft.Identity.Web.Sidecar/Endpoints/DownstreamApiEndpoint.cs +++ b/src/Microsoft.Identity.Web.Sidecar/Endpoints/DownstreamApiEndpoint.cs @@ -42,6 +42,7 @@ public static void AddDownstreamApiRequestEndpoints(this WebApplication app) WithDescription( "Override downstream call options using dotted query parameters prefixed with 'optionsOverride.'. " + "Whether overrides are honoured is controlled by 'Sidecar:AllowOverrides:CallDownstreamApi' (default: true). " + + "Agent identity parameters (AgentIdentity, AgentUsername, AgentUserId) are also subject to this setting. " + "'optionsOverride.BaseUrl' is always ignored. " + "Examples:\n" + " ?optionsOverride.Scopes=User.Read\n" + @@ -69,6 +70,7 @@ public static void AddDownstreamApiRequestEndpoints(this WebApplication app) WithDescription( "Override downstream call options using dotted query parameters prefixed with 'optionsOverride.'. " + "Whether overrides are honoured is controlled by 'Sidecar:AllowOverrides:CallDownstreamApiUnauthenticated' (default: false). " + + "Agent identity parameters (AgentIdentity, AgentUsername, AgentUserId) are also subject to this setting. " + "'optionsOverride.BaseUrl' is always ignored. " + "Examples:\n" + " ?optionsOverride.Scopes=User.Read\n" + @@ -117,7 +119,14 @@ private static async Task, ProblemHttpResult>> D statusCode: StatusCodes.Status400BadRequest); } - AgentOverrides.SetOverrides(options, requestParameters.AgentIdentity, requestParameters.AgentUsername, requestParameters.AgentUserId); + if (allowOverrides) + { + AgentOverrides.SetOverrides(options, requestParameters.AgentIdentity, requestParameters.AgentUsername, requestParameters.AgentUserId); + } + else if (requestParameters.AgentIdentity is not null || requestParameters.AgentUsername is not null || requestParameters.AgentUserId is not null) + { + logger.AgentIdentityOverridesIgnored(routeName); + } HttpContent? content = null; diff --git a/src/Microsoft.Identity.Web.Sidecar/Logging/Logging.cs b/src/Microsoft.Identity.Web.Sidecar/Logging/Logging.cs index 9a7002dc1..167c1e6bb 100644 --- a/src/Microsoft.Identity.Web.Sidecar/Logging/Logging.cs +++ b/src/Microsoft.Identity.Web.Sidecar/Logging/Logging.cs @@ -32,4 +32,11 @@ public static partial class LoggerMessageExtensions 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); + + [LoggerMessage( + EventId = 5, + Level = LogLevel.Warning, + Message = "Caller-supplied agent identity parameters were ignored on route '{RouteName}' because overrides are not allowed for it by configuration. To enable agent identity overrides, set 'Sidecar:AllowOverrides:{RouteName}' to true.", + EventName = "AgentIdentityOverridesIgnored")] + public static partial void AgentIdentityOverridesIgnored(this ILogger logger, string routeName); } 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 6966a9695..32f4860b1 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 @@ -1,5 +1,5 @@ { - "openapi": "3.0.1", + "openapi": "3.1.1", "info": { "title": "Microsoft.Identity.Web.Sidecar | v1", "version": "1.0.0" @@ -51,7 +51,7 @@ "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. 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.", + "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). Agent identity parameters (AgentIdentity, AgentUsername, AgentUserId) are also subject to this setting. '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": [ { @@ -91,7 +91,6 @@ "name": "optionsOverride.Scopes", "in": "query", "description": "Repeatable. Each occurrence adds one scope. Example: optionsOverride.Scopes=User.Read", - "style": "form", "schema": { "type": "string" } @@ -156,9 +155,9 @@ { "name": "optionsOverride.AcquireTokenOptions.ForceRefresh", "in": "query", - "description": "boolean", + "description": "true = bypass token cache.", "schema": { - "type": "true = bypass token cache." + "type": "boolean" } }, { @@ -250,7 +249,7 @@ "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. 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.", + "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). Agent identity parameters (AgentIdentity, AgentUsername, AgentUserId) are also subject to this setting. '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": [ { @@ -290,7 +289,6 @@ "name": "optionsOverride.Scopes", "in": "query", "description": "Repeatable. Each occurrence adds one scope. Example: optionsOverride.Scopes=User.Read", - "style": "form", "schema": { "type": "string" } @@ -355,9 +353,9 @@ { "name": "optionsOverride.AcquireTokenOptions.ForceRefresh", "in": "query", - "description": "boolean", + "description": "true = bypass token cache.", "schema": { - "type": "true = bypass token cache." + "type": "boolean" } }, { @@ -449,7 +447,7 @@ "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.'. 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", + "description": "Override downstream call options using dotted query parameters prefixed with 'optionsOverride.'. Whether overrides are honoured is controlled by 'Sidecar:AllowOverrides:CallDownstreamApi' (default: true). Agent identity parameters (AgentIdentity, AgentUsername, AgentUserId) are also subject to this setting. '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": [ { @@ -489,7 +487,6 @@ "name": "optionsOverride.Scopes", "in": "query", "description": "Repeatable. Each occurrence adds one scope. Example: optionsOverride.Scopes=User.Read", - "style": "form", "schema": { "type": "string" } @@ -554,9 +551,9 @@ { "name": "optionsOverride.AcquireTokenOptions.ForceRefresh", "in": "query", - "description": "boolean", + "description": "true = bypass token cache.", "schema": { - "type": "true = bypass token cache." + "type": "boolean" } }, { @@ -648,7 +645,7 @@ "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.'. 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", + "description": "Override downstream call options using dotted query parameters prefixed with 'optionsOverride.'. Whether overrides are honoured is controlled by 'Sidecar:AllowOverrides:CallDownstreamApiUnauthenticated' (default: false). Agent identity parameters (AgentIdentity, AgentUsername, AgentUserId) are also subject to this setting. '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": [ { @@ -688,7 +685,6 @@ "name": "optionsOverride.Scopes", "in": "query", "description": "Repeatable. Each occurrence adds one scope. Example: optionsOverride.Scopes=User.Read", - "style": "form", "schema": { "type": "string" } @@ -753,9 +749,9 @@ { "name": "optionsOverride.AcquireTokenOptions.ForceRefresh", "in": "query", - "description": "boolean", + "description": "true = bypass token cache.", "schema": { - "type": "true = bypass token cache." + "type": "boolean" } }, { @@ -864,7 +860,11 @@ "type": "object", "properties": { "statusCode": { - "type": "integer", + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], "format": "int32" }, "headers": { @@ -877,8 +877,10 @@ } }, "content": { - "type": "string", - "nullable": true + "type": [ + "null", + "string" + ] } } }, @@ -887,25 +889,37 @@ "type": "object", "properties": { "type": { - "type": "string", - "nullable": true + "type": [ + "null", + "string" + ] }, "title": { - "type": "string", - "nullable": true + "type": [ + "null", + "string" + ] }, "status": { - "type": "integer", - "format": "int32", - "nullable": true + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "null", + "integer", + "string" + ], + "format": "int32" }, "detail": { - "type": "string", - "nullable": true + "type": [ + "null", + "string" + ] }, "instance": { - "type": "string", - "nullable": true + "type": [ + "null", + "string" + ] } } }, diff --git a/src/Microsoft.Identity.Web.Sidecar/README.md b/src/Microsoft.Identity.Web.Sidecar/README.md index afc79fb90..4b621911b 100644 --- a/src/Microsoft.Identity.Web.Sidecar/README.md +++ b/src/Microsoft.Identity.Web.Sidecar/README.md @@ -119,13 +119,17 @@ Complete documentation is provided [here](./OpenAPI/Microsoft.Identity.Web.Sidec All token-acquisition endpoints accept dotted query parameters prefixed with `optionsOverride.`; they merge into a `DownstreamApis` profile through [`BindableDownstreamApiOptions`](Models/BindableDownstreamApiOptions.cs). +Whether overrides are honoured is controlled by the per-route `Sidecar:AllowOverrides` configuration flags. Authenticated routes allow overrides by default; unauthenticated routes ignore them unless the operator explicitly opts in. See [`SidecarOptions`](Configuration/SidecarOptions.cs) for details. + +`optionsOverride.BaseUrl` is always ignored regardless of the override flag. + Examples: - `?optionsOverride.Scopes=User.Read&optionsOverride.Scopes=Mail.Read` - `?optionsOverride.RequestAppToken=true` - `?optionsOverride.AcquireTokenOptions.Tenant=` - `?optionsOverride.RelativePath=me/messages` -Agent impersonation hints: +Agent identity parameters are also subject to the per-route override flag: - `AgentIdentity=` - `AgentUsername=upn@contoso.com` - `AgentUserId=` @@ -148,5 +152,5 @@ Agent impersonation hints: | Authentication & authorization | [`Program`](Program.cs) wires `AddMicrosoftIdentityWebApi`, optional scope enforcement, and agent identity overrides. | | Endpoints | [`ValidateRequestEndpoints`](Endpoints/ValidateRequestEndpoints.cs), [`AuthorizationHeaderEndpoint`](Endpoints/AuthorizationHeaderEndpoint.cs), and [`DownstreamApiEndpoint`](Endpoints/DownstreamApiEndpoint.cs). | | Downstream API | [`BindableDownstreamApiOptions`](Models/BindableDownstreamApiOptions.cs) merges per-request overrides into per call `DownstreamApis` configuration. | -| Agent Identities | [`AgentOverrides`](AgentOverrides.cs) binds agent identity, userPrincipalName, or user object ID when present. | +| Agent Identities | [`AgentOverrides`](AgentOverrides.cs) binds agent identity, userPrincipalName, or user object ID when present and allowed by the per-route override flag. | diff --git a/tests/E2E Tests/Sidecar.Tests/AgentOverrideGatingTests.cs b/tests/E2E Tests/Sidecar.Tests/AgentOverrideGatingTests.cs new file mode 100644 index 000000000..d1ef249ed --- /dev/null +++ b/tests/E2E Tests/Sidecar.Tests/AgentOverrideGatingTests.cs @@ -0,0 +1,306 @@ +// 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; + +/// +/// Verifies that AgentIdentity, AgentUsername, and AgentUserId +/// query parameters are subject to the per-route Sidecar:AllowOverrides gate +/// and are not applied on routes where overrides are disabled. +/// +public class AgentOverrideGatingTests : IClassFixture +{ + private readonly SidecarApiFactory _factory; + + // The key used by WithAgentUserIdentity / WithAgentIdentity to store the + // agent identity in AcquireTokenOptions.ExtraParameters. + private const string AgentIdentityKey = "IDWEB_AGENT_IDENTITY"; + + public AgentOverrideGatingTests(SidecarApiFactory factory) + { + _factory = factory; + } + + private HttpClient CreateClient( + Dictionary sidecarConfig, + OptionsCapturingAuthorizationHeaderProvider capture, + string apiName = "test-api") + { + return _factory.WithWebHostBuilder(builder => + { + builder.ConfigureAppConfiguration((_, config) => + { + config.AddInMemoryCollection(sidecarConfig); + }); + + builder.ConfigureServices(services => + { + TestAuthenticationHandler.AddAlwaysSucceedTestAuthentication(services); + services.AddSingleton(capture); + services.Configure(apiName, options => + { + options.BaseUrl = "https://api.example.com"; + options.RelativePath = "/test"; + options.Scopes = new[] { "User.Read" }; + }); + }); + }).CreateClient(); + } + + private static void AddBearer(HttpClient client) => + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "valid-test-token"); + + // ── AuthorizationHeader (authenticated) ────────────────────────────── + + [Fact] + public async Task AuthorizationHeader_OverridesAllowed_AppliesAgentIdentity() + { + var capture = new OptionsCapturingAuthorizationHeaderProvider(); + var client = CreateClient( + new Dictionary + { + { "Sidecar:AllowOverrides:GetAuthorizationHeader", "true" }, + }, + capture); + AddBearer(client); + + var response = await client.GetAsync( + "/AuthorizationHeader/test-api?AgentIdentity=agent-app-id&AgentUsername=testuser@contoso.com"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(capture.LastOptions); + Assert.NotNull(capture.LastOptions!.AcquireTokenOptions.ExtraParameters); + Assert.True(capture.LastOptions.AcquireTokenOptions.ExtraParameters!.ContainsKey(AgentIdentityKey)); + Assert.Equal("agent-app-id", capture.LastOptions.AcquireTokenOptions.ExtraParameters[AgentIdentityKey]); + } + + [Fact] + public async Task AuthorizationHeader_OverridesDisabled_IgnoresAgentIdentity() + { + var capture = new OptionsCapturingAuthorizationHeaderProvider(); + var client = CreateClient( + new Dictionary + { + { "Sidecar:AllowOverrides:GetAuthorizationHeader", "false" }, + }, + capture); + AddBearer(client); + + var response = await client.GetAsync( + "/AuthorizationHeader/test-api?AgentIdentity=agent-app-id&AgentUsername=testuser@contoso.com"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(capture.LastOptions); + // Agent identity should NOT have been applied. + var extras = capture.LastOptions!.AcquireTokenOptions.ExtraParameters; + Assert.True( + extras is null || !extras.ContainsKey(AgentIdentityKey), + "Agent identity should be ignored when overrides are disabled."); + } + + // ── AuthorizationHeaderUnauthenticated ──────────────────────────────── + + [Fact] + public async Task AuthorizationHeaderUnauthenticated_DefaultConfig_IgnoresAgentIdentity() + { + var capture = new OptionsCapturingAuthorizationHeaderProvider(); + // No explicit config — default is GetAuthorizationHeaderUnauthenticated = false. + var client = CreateClient(new Dictionary(), capture); + + var response = await client.GetAsync( + "/AuthorizationHeaderUnauthenticated/test-api?AgentIdentity=agent-app-id&AgentUsername=testuser@contoso.com"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(capture.LastOptions); + var extras = capture.LastOptions!.AcquireTokenOptions.ExtraParameters; + Assert.True( + extras is null || !extras.ContainsKey(AgentIdentityKey), + "Agent identity should be ignored on unauthenticated route by default."); + } + + [Fact] + public async Task AuthorizationHeaderUnauthenticated_OverridesEnabled_AppliesAgentIdentity() + { + var capture = new OptionsCapturingAuthorizationHeaderProvider(); + var client = CreateClient( + new Dictionary + { + { "Sidecar:AllowOverrides:GetAuthorizationHeaderUnauthenticated", "true" }, + }, + capture); + + var response = await client.GetAsync( + "/AuthorizationHeaderUnauthenticated/test-api?AgentIdentity=agent-app-id&AgentUsername=testuser@contoso.com"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(capture.LastOptions); + Assert.NotNull(capture.LastOptions!.AcquireTokenOptions.ExtraParameters); + Assert.True(capture.LastOptions.AcquireTokenOptions.ExtraParameters!.ContainsKey(AgentIdentityKey)); + } + + // ── DownstreamApi (authenticated) ──────────────────────────────────── + + [Fact] + public async Task DownstreamApi_OverridesDisabled_IgnoresAgentIdentity() + { + var capture = new OptionsCapturingAuthorizationHeaderProvider(); + var captureHandler = new CapturingHttpMessageHandler(HttpStatusCode.OK); + + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureAppConfiguration((_, config) => + { + config.AddInMemoryCollection(new Dictionary + { + { "Sidecar:AllowOverrides:CallDownstreamApi", "false" }, + }); + }); + + builder.ConfigureServices(services => + { + TestAuthenticationHandler.AddAlwaysSucceedTestAuthentication(services); + services.AddSingleton(capture); + services.Configure("test-api", options => + { + options.BaseUrl = "https://api.example.com"; + options.RelativePath = "/test"; + options.HttpMethod = HttpMethod.Get.Method; + options.Scopes = new[] { "User.Read" }; + }); + services.AddSingleton(new SingleHandlerHttpClientFactory(captureHandler)); + }); + }).CreateClient(); + AddBearer(client); + + var response = await client.PostAsync( + "/DownstreamApi/test-api?AgentIdentity=agent-app-id&AgentUsername=testuser@contoso.com", + content: null); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(capture.LastOptions); + var extras = capture.LastOptions!.AcquireTokenOptions.ExtraParameters; + Assert.True( + extras is null || !extras.ContainsKey(AgentIdentityKey), + "Agent identity should be ignored when overrides are disabled on DownstreamApi."); + } + + // ── DownstreamApiUnauthenticated ───────────────────────────────────── + + [Fact] + public async Task DownstreamApiUnauthenticated_DefaultConfig_IgnoresAgentIdentity() + { + var capture = new OptionsCapturingAuthorizationHeaderProvider(); + var captureHandler = new CapturingHttpMessageHandler(HttpStatusCode.OK); + + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureAppConfiguration((_, config) => + { + config.AddInMemoryCollection(new Dictionary()); + }); + + builder.ConfigureServices(services => + { + TestAuthenticationHandler.AddAlwaysSucceedTestAuthentication(services); + services.AddSingleton(capture); + services.Configure("test-api", options => + { + options.BaseUrl = "https://api.example.com"; + options.RelativePath = "/test"; + options.HttpMethod = HttpMethod.Get.Method; + options.Scopes = new[] { "User.Read" }; + }); + services.AddSingleton(new SingleHandlerHttpClientFactory(captureHandler)); + }); + }).CreateClient(); + + var response = await client.PostAsync( + "/DownstreamApiUnauthenticated/test-api?AgentIdentity=agent-app-id&AgentUsername=testuser@contoso.com", + content: null); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(capture.LastOptions); + var extras = capture.LastOptions!.AcquireTokenOptions.ExtraParameters; + Assert.True( + extras is null || !extras.ContainsKey(AgentIdentityKey), + "Agent identity should be ignored on unauthenticated DownstreamApi route by default."); + } + + // ── Helpers ────────────────────────────────────────────────────────── + + /// + /// Captures the passed to + /// so tests can assert on agent + /// identity fields. + /// + private sealed class OptionsCapturingAuthorizationHeaderProvider : IAuthorizationHeaderProvider + { + public AuthorizationHeaderProviderOptions? LastOptions { get; private set; } + + public Task CreateAuthorizationHeaderAsync( + IEnumerable scopes, + AuthorizationHeaderProviderOptions? options = null, + ClaimsPrincipal? claimsPrincipal = null, + CancellationToken cancellationToken = default) + { + LastOptions = options; + return Task.FromResult("Bearer test-token"); + } + + public Task CreateAuthorizationHeaderForAppAsync( + string scopes, + AuthorizationHeaderProviderOptions? downstreamApiOptions = null, + CancellationToken cancellationToken = default) + { + LastOptions = downstreamApiOptions; + return Task.FromResult("Bearer test-token"); + } + + public Task CreateAuthorizationHeaderForUserAsync( + IEnumerable scopes, + AuthorizationHeaderProviderOptions? authorizationHeaderProviderOptions = null, + ClaimsPrincipal? claimsPrincipal = null, + CancellationToken cancellationToken = default) + { + LastOptions = authorizationHeaderProviderOptions; + return Task.FromResult("Bearer test-token"); + } + } + + private sealed class CapturingHttpMessageHandler : HttpMessageHandler + { + private readonly HttpStatusCode _statusCode; + + public CapturingHttpMessageHandler(HttpStatusCode statusCode) + { + _statusCode = statusCode; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + 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); + } +}