Skip to content

Commit e13b503

Browse files
iNinjaCopilot
andauthored
Sidecar: add per-route override gating and harden BaseUrl handling (#3794)
Adds an opt-in/opt-out configuration for whether 'optionsOverride.*' query parameters are applied to a resolved DownstreamApiOptions on each of the four sidecar routes: Sidecar:AllowOverrides:GetAuthorizationHeader (default: true) Sidecar:AllowOverrides:GetAuthorizationHeaderUnauthenticated (default: false) Sidecar:AllowOverrides:CallDownstreamApi (default: true) Sidecar:AllowOverrides:CallDownstreamApiUnauthenticated (default: false) When the flag for a route is false, any 'optionsOverride.*' query parameters are ignored and a single warning is logged. 'optionsOverride.BaseUrl' is now unconditionally ignored on every route regardless of the flag, since the downstream BaseUrl is fixed by host configuration. The OpenAPI document marks the parameter as deprecated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent ce9f7c4 commit e13b503

14 files changed

Lines changed: 768 additions & 50 deletions
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
namespace Microsoft.Identity.Web.Sidecar.Configuration;
5+
6+
/// <summary>
7+
/// Top-level configuration for the sidecar host. Bound from the
8+
/// <c>Sidecar</c> configuration section.
9+
/// </summary>
10+
public class SidecarOptions
11+
{
12+
/// <summary>
13+
/// Per-route gating for caller-supplied <c>optionsOverride.*</c> query
14+
/// parameters. When the corresponding flag is <c>false</c>, any
15+
/// <c>optionsOverride.*</c> parameters supplied by the caller are
16+
/// ignored on that route and a warning is logged.
17+
/// </summary>
18+
public AllowOverridesOptions AllowOverrides { get; set; } = new();
19+
}
20+
21+
/// <summary>
22+
/// Per-route flags controlling whether the sidecar will honour
23+
/// <c>optionsOverride.*</c> query parameters.
24+
/// </summary>
25+
public class AllowOverridesOptions
26+
{
27+
/// <summary>
28+
/// Allow overrides on <c>GET /AuthorizationHeader/{apiName}</c>.
29+
/// Defaults to <c>true</c>.
30+
/// </summary>
31+
public bool GetAuthorizationHeader { get; set; } = true;
32+
33+
/// <summary>
34+
/// Allow overrides on <c>GET /AuthorizationHeaderUnauthenticated/{apiName}</c>.
35+
/// Defaults to <c>false</c>.
36+
/// </summary>
37+
public bool GetAuthorizationHeaderUnauthenticated { get; set; }
38+
39+
/// <summary>
40+
/// Allow overrides on <c>POST /DownstreamApi/{apiName}</c>.
41+
/// Defaults to <c>true</c>.
42+
/// </summary>
43+
public bool CallDownstreamApi { get; set; } = true;
44+
45+
/// <summary>
46+
/// Allow overrides on <c>POST /DownstreamApiUnauthenticated/{apiName}</c>.
47+
/// Defaults to <c>false</c>.
48+
/// </summary>
49+
public bool CallDownstreamApiUnauthenticated { get; set; }
50+
}

src/Microsoft.Identity.Web.Sidecar/DownstreamApiOptionsMerger.cs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,9 @@ public static DownstreamApiOptions MergeOptions(DownstreamApiOptions left, Downs
2727
res.RequestAppToken = right.RequestAppToken;
2828
}
2929

30-
if (!string.IsNullOrEmpty(right.BaseUrl))
31-
{
32-
res.BaseUrl = right.BaseUrl;
33-
}
30+
// BaseUrl from the override is intentionally not merged. The downstream
31+
// BaseUrl is fixed by host configuration and cannot be overridden through
32+
// the optionsOverride channel.
3433

3534
if (!string.IsNullOrEmpty(right.RelativePath))
3635
{

src/Microsoft.Identity.Web.Sidecar/Endpoints/AuthorizationHeaderEndpoint.cs

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,39 +7,69 @@
77
using Microsoft.Extensions.Options;
88
using Microsoft.Identity.Abstractions;
99
using Microsoft.Identity.Client;
10+
using Microsoft.Identity.Web.Sidecar.Configuration;
1011
using Microsoft.Identity.Web.Sidecar.Logging;
1112
using Microsoft.Identity.Web.Sidecar.Models;
1213

1314
namespace Microsoft.Identity.Web.Sidecar.Endpoints;
1415

1516
public static class AuthorizationHeaderEndpoint
1617
{
18+
internal const string AuthenticatedRouteName = "AuthorizationHeader";
19+
internal const string UnauthenticatedRouteName = "AuthorizationHeaderUnauthenticated";
20+
1721
public static void AddAuthorizationHeaderRequestEndpoints(this WebApplication app)
1822
{
19-
app.MapGet("/AuthorizationHeader/{apiName}", AuthorizationHeaderAsync).
20-
WithName("AuthorizationHeader").
23+
app.MapGet("/AuthorizationHeader/{apiName}",
24+
(HttpContext httpContext, [Description("The downstream API to acquire an authorization header for.")][FromRoute] string apiName,
25+
[AsParameters] AuthorizationHeaderRequest requestParameters,
26+
BindableDownstreamApiOptions optionsOverride,
27+
[FromServices] IAuthorizationHeaderProvider headerProvider,
28+
[FromServices] IOptionsMonitor<DownstreamApiOptions> optionsMonitor,
29+
[FromServices] IOptions<SidecarOptions> sidecarOptions,
30+
[FromServices] ILogger<Program> logger,
31+
CancellationToken cancellationToken) =>
32+
AuthorizationHeaderAsync(
33+
httpContext, apiName, requestParameters, optionsOverride, headerProvider, optionsMonitor,
34+
sidecarOptions.Value.AllowOverrides.GetAuthorizationHeader, AuthenticatedRouteName, logger, cancellationToken)).
35+
WithName(AuthenticatedRouteName).
2136
RequireAuthorization().
2237
ProducesProblem(StatusCodes.Status400BadRequest).
2338
ProducesProblem(StatusCodes.Status401Unauthorized).
2439
WithSummary("Get an authorization header for a configured downstream API.").
2540
WithDescription(
2641
"This endpoint will use the identity of the authenticated request to acquire an authorization header." +
2742
"Use dotted query parameters prefixed with 'optionsOverride.' to override call settings with respect to the configuration. " +
43+
"Whether overrides are honoured is controlled by 'Sidecar:AllowOverrides:GetAuthorizationHeader' (default: true). " +
44+
"'optionsOverride.BaseUrl' is always ignored. " +
2845
"Examples:\n" +
2946
" ?optionsOverride.Scopes=User.Read&optionsOverride.Scopes=Mail.Read\n" +
3047
" ?optionsOverride.RequestAppToken=true&optionsOverride.Scopes=https://graph.microsoft.com/.default\n" +
3148
" ?optionsOverride.AcquireTokenOptions.Tenant=GUID\n" +
3249
"Repeat parameters like 'optionsOverride.Scopes' to add multiple scopes.");
3350

34-
app.MapGet("/AuthorizationHeaderUnauthenticated/{apiName}", AuthorizationHeaderAsync).
35-
WithName("AuthorizationHeaderUnauthenticated").
51+
app.MapGet("/AuthorizationHeaderUnauthenticated/{apiName}",
52+
(HttpContext httpContext, [Description("The downstream API to acquire an authorization header for.")][FromRoute] string apiName,
53+
[AsParameters] AuthorizationHeaderRequest requestParameters,
54+
BindableDownstreamApiOptions optionsOverride,
55+
[FromServices] IAuthorizationHeaderProvider headerProvider,
56+
[FromServices] IOptionsMonitor<DownstreamApiOptions> optionsMonitor,
57+
[FromServices] IOptions<SidecarOptions> sidecarOptions,
58+
[FromServices] ILogger<Program> logger,
59+
CancellationToken cancellationToken) =>
60+
AuthorizationHeaderAsync(
61+
httpContext, apiName, requestParameters, optionsOverride, headerProvider, optionsMonitor,
62+
sidecarOptions.Value.AllowOverrides.GetAuthorizationHeaderUnauthenticated, UnauthenticatedRouteName, logger, cancellationToken)).
63+
WithName(UnauthenticatedRouteName).
3664
AllowAnonymous().
3765
ProducesProblem(StatusCodes.Status400BadRequest).
3866
ProducesProblem(StatusCodes.Status401Unauthorized).
3967
WithSummary("Get an authorization header for a configured downstream API using this configured client credentials.").
4068
WithDescription(
4169
"This endpoint will use the configured client credentials to acquire an authorization header." +
4270
"Use dotted query parameters prefixed with 'optionsOverride.' to override call settings with respect to the configuration. " +
71+
"Whether overrides are honoured is controlled by 'Sidecar:AllowOverrides:GetAuthorizationHeaderUnauthenticated' (default: false). " +
72+
"'optionsOverride.BaseUrl' is always ignored. " +
4373
"Examples:\n" +
4474
" ?optionsOverride.Scopes=User.Read&optionsOverride.Scopes=Mail.Read\n" +
4575
" ?optionsOverride.RequestAppToken=true&optionsOverride.Scopes=https://graph.microsoft.com/.default\n" +
@@ -49,14 +79,14 @@ public static void AddAuthorizationHeaderRequestEndpoints(this WebApplication ap
4979

5080
private static async Task<Results<Ok<Models.AuthorizationHeaderResult>, ProblemHttpResult>> AuthorizationHeaderAsync(
5181
HttpContext httpContext,
52-
[Description("The downstream API to acquire an authorization header for.")]
53-
[FromRoute]
5482
string apiName,
55-
[AsParameters] AuthorizationHeaderRequest requestParameters,
83+
AuthorizationHeaderRequest requestParameters,
5684
BindableDownstreamApiOptions optionsOverride,
57-
[FromServices] IAuthorizationHeaderProvider headerProvider,
58-
[FromServices] IOptionsMonitor<DownstreamApiOptions> optionsMonitor,
59-
[FromServices] ILogger<Program> logger,
85+
IAuthorizationHeaderProvider headerProvider,
86+
IOptionsMonitor<DownstreamApiOptions> optionsMonitor,
87+
bool allowOverrides,
88+
string routeName,
89+
ILogger<Program> logger,
6090
CancellationToken cancellationToken)
6191
{
6292
DownstreamApiOptions? options = optionsMonitor.Get(apiName);
@@ -70,7 +100,14 @@ public static void AddAuthorizationHeaderRequestEndpoints(this WebApplication ap
70100

71101
if (optionsOverride.HasAny)
72102
{
73-
options = DownstreamApiOptionsMerger.MergeOptions(options, optionsOverride);
103+
if (allowOverrides)
104+
{
105+
options = DownstreamApiOptionsMerger.MergeOptions(options, optionsOverride);
106+
}
107+
else
108+
{
109+
logger.OverridesIgnored(routeName);
110+
}
74111
}
75112

76113
if (options.Scopes is null)

src/Microsoft.Identity.Web.Sidecar/Endpoints/DownstreamApiEndpoint.cs

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,37 +9,67 @@
99
using Microsoft.Extensions.Options;
1010
using Microsoft.Identity.Abstractions;
1111
using Microsoft.Identity.Client;
12+
using Microsoft.Identity.Web.Sidecar.Configuration;
1213
using Microsoft.Identity.Web.Sidecar.Logging;
1314
using Microsoft.Identity.Web.Sidecar.Models;
1415

1516
namespace Microsoft.Identity.Web.Sidecar.Endpoints;
1617

1718
public static class DownstreamApiEndpoint
1819
{
20+
internal const string AuthenticatedRouteName = "DownstreamApi";
21+
internal const string UnauthenticatedRouteName = "DownstreamApiUnauthenticated";
22+
1923
public static void AddDownstreamApiRequestEndpoints(this WebApplication app)
2024
{
21-
app.MapPost("/DownstreamApi/{apiName}", DownstreamApiAsync).
22-
WithName("DownstreamApi").
25+
app.MapPost("/DownstreamApi/{apiName}",
26+
(HttpContext httpContext, [Description("The downstream API to call")][FromRoute] string apiName,
27+
[AsParameters] DownstreamApiRequest requestParameters,
28+
BindableDownstreamApiOptions optionsOverride,
29+
[FromServices] IDownstreamApi downstreamApi,
30+
[FromServices] IOptionsMonitor<DownstreamApiOptions> optionsMonitor,
31+
[FromServices] IOptions<SidecarOptions> sidecarOptions,
32+
[FromServices] ILogger<Program> logger,
33+
CancellationToken cancellationToken) =>
34+
DownstreamApiAsync(
35+
httpContext, apiName, requestParameters, optionsOverride, downstreamApi, optionsMonitor,
36+
sidecarOptions.Value.AllowOverrides.CallDownstreamApi, AuthenticatedRouteName, logger, cancellationToken)).
37+
WithName(AuthenticatedRouteName).
2338
RequireAuthorization().
2439
ProducesProblem(StatusCodes.Status400BadRequest).
2540
ProducesProblem(StatusCodes.Status401Unauthorized).
2641
WithSummary("Invoke a configured downstream API through the sidecar using the authenticated identity.").
2742
WithDescription(
2843
"Override downstream call options using dotted query parameters prefixed with 'optionsOverride.'. " +
44+
"Whether overrides are honoured is controlled by 'Sidecar:AllowOverrides:CallDownstreamApi' (default: true). " +
45+
"'optionsOverride.BaseUrl' is always ignored. " +
2946
"Examples:\n" +
3047
" ?optionsOverride.Scopes=User.Read\n" +
3148
" ?optionsOverride.Scopes=User.Read&optionsOverride.Scopes=Mail.Read\n" +
3249
" ?optionsOverride.AcquireTokenOptions.Tenant=GUID\n" +
3350
" ?optionsOverride.RequestAppToken=true&optionsOverride.Scopes=https://graph.microsoft.com/.default");
3451

35-
app.MapPost("/DownstreamApiUnauthenticated/{apiName}", DownstreamApiAsync).
36-
WithName("DownstreamApiUnauthenticated").
52+
app.MapPost("/DownstreamApiUnauthenticated/{apiName}",
53+
(HttpContext httpContext, [Description("The downstream API to call")][FromRoute] string apiName,
54+
[AsParameters] DownstreamApiRequest requestParameters,
55+
BindableDownstreamApiOptions optionsOverride,
56+
[FromServices] IDownstreamApi downstreamApi,
57+
[FromServices] IOptionsMonitor<DownstreamApiOptions> optionsMonitor,
58+
[FromServices] IOptions<SidecarOptions> sidecarOptions,
59+
[FromServices] ILogger<Program> logger,
60+
CancellationToken cancellationToken) =>
61+
DownstreamApiAsync(
62+
httpContext, apiName, requestParameters, optionsOverride, downstreamApi, optionsMonitor,
63+
sidecarOptions.Value.AllowOverrides.CallDownstreamApiUnauthenticated, UnauthenticatedRouteName, logger, cancellationToken)).
64+
WithName(UnauthenticatedRouteName).
3765
AllowAnonymous().
3866
ProducesProblem(StatusCodes.Status400BadRequest).
3967
ProducesProblem(StatusCodes.Status401Unauthorized).
4068
WithSummary("Invoke a configured downstream API through the sidecar using the configured client credentials.").
4169
WithDescription(
4270
"Override downstream call options using dotted query parameters prefixed with 'optionsOverride.'. " +
71+
"Whether overrides are honoured is controlled by 'Sidecar:AllowOverrides:CallDownstreamApiUnauthenticated' (default: false). " +
72+
"'optionsOverride.BaseUrl' is always ignored. " +
4373
"Examples:\n" +
4474
" ?optionsOverride.Scopes=User.Read\n" +
4575
" ?optionsOverride.Scopes=User.Read&optionsOverride.Scopes=Mail.Read\n" +
@@ -49,14 +79,14 @@ public static void AddDownstreamApiRequestEndpoints(this WebApplication app)
4979

5080
private static async Task<Results<Ok<DownstreamApiResult>, ProblemHttpResult>> DownstreamApiAsync(
5181
HttpContext httpContext,
52-
[Description("The downstream API to call")]
53-
[FromRoute]
5482
string apiName,
55-
[AsParameters] DownstreamApiRequest requestParameters,
83+
DownstreamApiRequest requestParameters,
5684
BindableDownstreamApiOptions optionsOverride,
57-
[FromServices] IDownstreamApi downstreamApi,
58-
[FromServices] IOptionsMonitor<DownstreamApiOptions> optionsMonitor,
59-
[FromServices] ILogger<Program> logger,
85+
IDownstreamApi downstreamApi,
86+
IOptionsMonitor<DownstreamApiOptions> optionsMonitor,
87+
bool allowOverrides,
88+
string routeName,
89+
ILogger<Program> logger,
6090
CancellationToken cancellationToken)
6191
{
6292
DownstreamApiOptions? options = optionsMonitor.Get(apiName);
@@ -70,7 +100,14 @@ private static async Task<Results<Ok<DownstreamApiResult>, ProblemHttpResult>> D
70100

71101
if (optionsOverride.HasAny)
72102
{
73-
options = DownstreamApiOptionsMerger.MergeOptions(options, optionsOverride);
103+
if (allowOverrides)
104+
{
105+
options = DownstreamApiOptionsMerger.MergeOptions(options, optionsOverride);
106+
}
107+
else
108+
{
109+
logger.OverridesIgnored(routeName);
110+
}
74111
}
75112

76113
if (options.Scopes is null || !options.Scopes.Any())

src/Microsoft.Identity.Web.Sidecar/Logging/Logging.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,18 @@ public static partial class LoggerMessageExtensions
1818
Message = "An error occurred while parsing the token.",
1919
EventName = "ValidateRequest_UnableToParseToken")]
2020
public static partial void UnableToParseToken(this ILogger logger, Exception? exception);
21+
22+
[LoggerMessage(
23+
EventId = 3,
24+
Level = LogLevel.Warning,
25+
Message = "Caller-supplied 'optionsOverride.*' parameters were ignored on route '{RouteName}' because overrides are not allowed for it by configuration.",
26+
EventName = "OverridesIgnored")]
27+
public static partial void OverridesIgnored(this ILogger logger, string routeName);
28+
29+
[LoggerMessage(
30+
EventId = 4,
31+
Level = LogLevel.Warning,
32+
Message = "Caller-supplied 'optionsOverride.BaseUrl' was ignored. The downstream BaseUrl is fixed by the host configuration and cannot be overridden by the caller.",
33+
EventName = "BaseUrlOverrideIgnored")]
34+
public static partial void BaseUrlOverrideIgnored(this ILogger logger);
2135
}

src/Microsoft.Identity.Web.Sidecar/Models/BindableDownstreamApiOptions.cs

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44
using System.Reflection;
55
using Microsoft.AspNetCore.Http.Metadata;
66
using Microsoft.AspNetCore.Mvc;
7+
using Microsoft.Extensions.DependencyInjection;
8+
using Microsoft.Extensions.Logging;
79
using Microsoft.Identity.Abstractions;
10+
using Microsoft.Identity.Web.Sidecar.Logging;
811

912
namespace Microsoft.Identity.Web.Sidecar.Models;
1013

@@ -28,8 +31,25 @@ public BindableDownstreamApiOptions()
2831
public static ValueTask<BindableDownstreamApiOptions?> BindAsync(HttpContext ctx, ParameterInfo parameter)
2932
{
3033
var paramName = parameter.Name ?? "optionsOverride";
31-
bool hasAny = ctx.Request.Query.Keys.Any(k =>
32-
k.StartsWith(paramName + ".", StringComparison.OrdinalIgnoreCase));
34+
var prefix = paramName + ".";
35+
var baseUrlKey = paramName + ".BaseUrl";
36+
37+
bool hasAny = false;
38+
bool hasNonBaseUrlOverride = false;
39+
foreach (var k in ctx.Request.Query.Keys)
40+
{
41+
if (!k.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
42+
{
43+
continue;
44+
}
45+
46+
hasAny = true;
47+
if (!k.Equals(baseUrlKey, StringComparison.OrdinalIgnoreCase))
48+
{
49+
hasNonBaseUrlOverride = true;
50+
break;
51+
}
52+
}
3353

3454
var result = new BindableDownstreamApiOptions();
3555

@@ -38,7 +58,10 @@ public BindableDownstreamApiOptions()
3858
return ValueTask.FromResult<BindableDownstreamApiOptions?>(result);
3959
}
4060

41-
result.HasAny = true;
61+
// HasAny only reflects non-BaseUrl overrides because BaseUrl is always
62+
// ignored. This avoids a misleading "OverridesIgnored" warning at the
63+
// route layer when the caller only supplied an (already-rejected) BaseUrl.
64+
result.HasAny = hasNonBaseUrlOverride;
4265

4366
var query = ctx.Request.Query;
4467

@@ -103,7 +126,12 @@ public BindableDownstreamApiOptions()
103126
}
104127
else if (path.Equals("BaseUrl", StringComparison.OrdinalIgnoreCase))
105128
{
106-
result.BaseUrl = values.LastOrDefault();
129+
// Caller-supplied BaseUrl is unconditionally rejected: the downstream
130+
// BaseUrl is fixed by the host configuration and cannot be overridden
131+
// through optionsOverride. Log a warning and ignore the value.
132+
var loggerFactory = ctx.RequestServices.GetService<ILoggerFactory>();
133+
loggerFactory?.CreateLogger(typeof(BindableDownstreamApiOptions).FullName!)
134+
.BaseUrlOverrideIgnored();
107135
}
108136
else if (path.Equals("RelativePath", StringComparison.OrdinalIgnoreCase))
109137
{

0 commit comments

Comments
 (0)