feat(security): opt-in authentication for /metrics (SEC-14)

New MxGateway:Metrics:RequireAuthentication (default false). When true,
/metrics requires an API key with the admin scope (verified through the
cached verifier and the shared API-key failure limiter) or a dashboard
login with a real dashboard role; anonymous localhost and
Authentication:Mode=Disabled do not qualify. An anonymous scrape gets a
plain 401 Bearer challenge, a throttled peer 429. /health stays anonymous
because the off-repo Overview dashboard polls /health/active.

MapZbMetrics returns IEndpointConventionBuilder, so the policy is attached
with RequireAuthorization.
This commit is contained in:
Joseph Doherty
2026-10-22 06:37:31 -04:00
parent ed3a2c9c8b
commit 65bca804bb
10 changed files with 619 additions and 3 deletions
@@ -52,4 +52,7 @@ public sealed class GatewayOptions
/// login / API-key rate limiting).
/// </summary>
public SecurityOptions Security { get; init; } = new();
/// <summary>Gets Prometheus <c>/metrics</c> endpoint options.</summary>
public MetricsOptions Metrics { get; init; } = new();
}
@@ -0,0 +1,15 @@
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
/// <summary>Options for the Prometheus <c>/metrics</c> scrape endpoint (<c>MxGateway:Metrics</c>).</summary>
public sealed class MetricsOptions
{
/// <summary>
/// Gets a value indicating whether <c>/metrics</c> requires an authenticated caller. When
/// <see langword="true"/>, a scrape must present either an API key carrying the <c>admin</c>
/// scope (<c>Authorization: Bearer mxgw_…</c>) or a dashboard login cookie holding a real
/// dashboard role; anonymous localhost access does not qualify. Default <see langword="false"/>
/// keeps the endpoint anonymous for existing scrapers. The <c>/health</c> endpoints stay
/// anonymous either way.
/// </summary>
public bool RequireAuthentication { get; init; }
}
@@ -113,6 +113,7 @@ public static class GatewayApplication
builder.Services.AddZbSecrets(builder.Configuration, "Secrets");
builder.Services.AddSqliteAuthStore(builder.Configuration);
builder.Services.AddGatewayGrpcAuthorization();
builder.Services.AddGatewayMetricsAuthorization();
AddLoginRateLimiter(builder);
builder.Services.AddHealthChecks()
.AddTypeActivatedCheck<AuthStoreHealthCheck>(
@@ -350,8 +351,19 @@ public static class GatewayApplication
{
endpoints.MapStaticAssets(ResolveStaticAssetsManifestPath());
// /health stays anonymous unconditionally: an off-repo Overview dashboard polls
// /health/active without credentials.
endpoints.MapZbHealth();
endpoints.MapZbMetrics();
IEndpointConventionBuilder metricsEndpoint = endpoints.MapZbMetrics();
// Read from configuration rather than IOptions<GatewayOptions>: resolving the options here
// would run whole-options validation during Build instead of at StartAsync.
if (endpoints.ServiceProvider
.GetRequiredService<IConfiguration>()
.GetValue<bool>("MxGateway:Metrics:RequireAuthentication"))
{
// Opt-in (SEC-14): an admin-scoped API key or a dashboard login with a real role.
metricsEndpoint.RequireAuthorization(MetricsAuthorizationDefaults.Policy);
}
endpoints.MapGrpcService<MxAccessGatewayService>();
endpoints.MapZbGalaxyRepository();
@@ -0,0 +1,210 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
using ZB.MOM.WW.MxGateway.Server.Metrics;
namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
/// <summary>Names shared by the optional <c>/metrics</c> authentication (<c>MxGateway:Metrics:RequireAuthentication</c>).</summary>
public static class MetricsAuthorizationDefaults
{
/// <summary>Authentication scheme that authenticates a metrics scrape.</summary>
public const string AuthenticationScheme = "MxGateway.Metrics";
/// <summary>Authorization policy attached to <c>/metrics</c> when authentication is required.</summary>
public const string Policy = "MxGateway.Metrics";
/// <summary>Claim type carrying one API-key scope on a principal the metrics scheme authenticated.</summary>
public const string ScopeClaimType = "mxgateway:scope";
}
/// <summary>
/// Authenticates a <c>/metrics</c> scrape from either credential the gateway already issues: an API
/// key presented as <c>Authorization: Bearer mxgw_…</c> (verified through the same cached
/// <see cref="IApiKeyVerifier"/> and per-peer failure limiter as gRPC calls), or, when no
/// authorization header is sent, the dashboard login cookie.
/// </summary>
/// <remarks>
/// One scheme fronting both credentials keeps the challenge a plain <c>401</c> with
/// <c>WWW-Authenticate: Bearer</c>, which is what a Prometheus scraper understands; listing the
/// cookie scheme on the policy directly would turn an anonymous scrape into a 302 redirect to the
/// login page. A throttled key partition answers <c>429</c>, mirroring the gRPC
/// <c>ResourceExhausted</c>. Failure reasons are never echoed to the caller and the presented
/// secret is never logged.
/// </remarks>
public sealed class MetricsAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
IApiKeyVerifier apiKeyVerifier,
ApiKeyFailureLimiter failureLimiter,
IAuthenticationSchemeProvider schemeProvider,
GatewayMetrics metrics)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
private bool _throttled;
/// <inheritdoc />
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
string? authorizationHeader = Request.Headers.Authorization;
if (string.IsNullOrWhiteSpace(authorizationHeader))
{
return await AuthenticateDashboardCookieAsync().ConfigureAwait(false);
}
// Same throttle as the gRPC interceptor: checked before the store read so online guessing
// against /metrics cannot spend a verification per attempt or bypass the gRPC limiter.
ApiKeyThrottlePartition partition = new(
Context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
GatewayGrpcAuthorizationInterceptor.TryResolveKeyId(authorizationHeader));
ApiKeyThrottleDecision decision = failureLimiter.Check(
partition,
out ApiKeyFailureLimiter.PartitionResolution resolution);
if (decision is ApiKeyThrottleDecision.ThrottledByPeer or ApiKeyThrottleDecision.ThrottledByAggregate)
{
_throttled = true;
metrics.RecordAuthThrottled(decision == ApiKeyThrottleDecision.ThrottledByPeer ? "peer" : "aggregate");
return AuthenticateResult.Fail("Too many authentication attempts.");
}
ApiKeyVerification verification = await apiKeyVerifier
.VerifyAsync(authorizationHeader, Context.RequestAborted)
.ConfigureAwait(false);
if (!verification.Succeeded || verification.Identity is null)
{
failureLimiter.RecordFailure(partition);
return AuthenticateResult.Fail("Missing or invalid API key.");
}
failureLimiter.Reset(partition, resolution);
List<Claim> claims = [new Claim(ClaimTypes.Name, verification.Identity.KeyId)];
foreach (string scope in verification.Identity.Scopes)
{
claims.Add(new Claim(MetricsAuthorizationDefaults.ScopeClaimType, scope));
}
ClaimsPrincipal principal = new(new ClaimsIdentity(claims, Scheme.Name));
return AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name));
}
/// <inheritdoc />
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
{
if (_throttled)
{
Response.StatusCode = StatusCodes.Status429TooManyRequests;
return Task.CompletedTask;
}
Response.StatusCode = StatusCodes.Status401Unauthorized;
Response.Headers.WWWAuthenticate = "Bearer";
return Task.CompletedTask;
}
// No authorization header: fall back to the dashboard login cookie, so an operator already
// signed in to the dashboard can open /metrics in the same browser.
private async Task<AuthenticateResult> AuthenticateDashboardCookieAsync()
{
if (await schemeProvider.GetSchemeAsync(DashboardAuthenticationDefaults.AuthenticationScheme).ConfigureAwait(false)
is null)
{
return AuthenticateResult.NoResult();
}
AuthenticateResult cookie = await Context
.AuthenticateAsync(DashboardAuthenticationDefaults.AuthenticationScheme)
.ConfigureAwait(false);
return cookie.Succeeded && cookie.Principal is not null
? AuthenticateResult.Success(new AuthenticationTicket(cookie.Principal, Scheme.Name))
: AuthenticateResult.NoResult();
}
}
/// <summary>Requirement for <c>/metrics</c> access; see <see cref="MetricsAccessHandler"/>.</summary>
public sealed class MetricsAccessRequirement : IAuthorizationRequirement
{
}
/// <summary>
/// Grants <c>/metrics</c> to an API key carrying the <c>admin</c> scope or to a dashboard login
/// holding a real dashboard role (Administrator or Viewer). It reads no options, so neither
/// anonymous localhost nor <c>Authentication:Mode=Disabled</c> can satisfy it.
/// </summary>
/// <remarks>
/// A Viewer login qualifies because every dashboard role already sees the same aggregate counters
/// on the dashboard itself; the metrics carry no per-session or per-tag data. An API key needs
/// <c>admin</c> because metrics are an operator surface, not one of the data-plane scopes a client
/// key is normally issued with.
/// </remarks>
public sealed class MetricsAccessHandler : AuthorizationHandler<MetricsAccessRequirement>
{
/// <inheritdoc />
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
MetricsAccessRequirement requirement)
{
foreach (ClaimsIdentity identity in context.User.Identities)
{
if (!identity.IsAuthenticated)
{
continue;
}
// Scope claims count only on an identity the metrics scheme minted from a verified key.
bool isApiKeyIdentity = string.Equals(
identity.AuthenticationType,
MetricsAuthorizationDefaults.AuthenticationScheme,
StringComparison.Ordinal);
if (isApiKeyIdentity
&& identity.HasClaim(MetricsAuthorizationDefaults.ScopeClaimType, GatewayScopes.Admin))
{
context.Succeed(requirement);
return Task.CompletedTask;
}
if (!isApiKeyIdentity
&& (identity.HasClaim(identity.RoleClaimType, DashboardRoles.Admin)
|| identity.HasClaim(identity.RoleClaimType, DashboardRoles.Viewer)))
{
context.Succeed(requirement);
return Task.CompletedTask;
}
}
return Task.CompletedTask;
}
}
/// <summary>Registration for the optional <c>/metrics</c> authentication.</summary>
public static class MetricsAuthorizationServiceCollectionExtensions
{
/// <summary>
/// Registers the metrics authentication scheme, the <see cref="MetricsAuthorizationDefaults.Policy"/>
/// policy and its handler. Registration is unconditional and inert: the policy only takes effect
/// when <c>MxGateway:Metrics:RequireAuthentication</c> attaches it to the endpoint.
/// </summary>
/// <param name="services">Service collection to register into.</param>
/// <returns>The same service collection, for chaining.</returns>
public static IServiceCollection AddGatewayMetricsAuthorization(this IServiceCollection services)
{
services.AddAuthentication()
.AddScheme<AuthenticationSchemeOptions, MetricsAuthenticationHandler>(
MetricsAuthorizationDefaults.AuthenticationScheme,
_ => { });
services.AddAuthorizationBuilder()
.AddPolicy(
MetricsAuthorizationDefaults.Policy,
policy => policy
.AddAuthenticationSchemes(MetricsAuthorizationDefaults.AuthenticationScheme)
.AddRequirements(new MetricsAccessRequirement()));
services.AddSingleton<IAuthorizationHandler, MetricsAccessHandler>();
return services;
}
}
@@ -98,6 +98,70 @@ public sealed class GatewayApplicationTests
}
}
/// <summary>
/// With <c>MxGateway:Metrics:RequireAuthentication=true</c> an anonymous scrape is refused with a
/// plain 401 Bearer challenge (not a login redirect), while <c>/health/active</c> — polled by the
/// off-repo Overview dashboard without credentials — stays anonymous.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Build_WhenMetricsRequireAuthentication_RefusesAnonymousScrapeButNotHealth()
{
await using WebApplication app = GatewayApplication.Build(
["--urls=http://127.0.0.1:0", "--MxGateway:Metrics:RequireAuthentication=true"]);
await app.StartAsync();
try
{
using HttpClient client = new() { BaseAddress = new Uri(app.Urls.First()) };
using HttpResponseMessage metrics = await client.GetAsync("/metrics");
using HttpRequestMessage badKeyRequest = new(HttpMethod.Get, "/metrics");
badKeyRequest.Headers.TryAddWithoutValidation("Authorization", "Bearer mxgw_nobody_not-a-real-secret");
using HttpResponseMessage badKey = await client.SendAsync(badKeyRequest);
using HttpResponseMessage health = await client.GetAsync("/health/active");
Assert.Equal(HttpStatusCode.Unauthorized, metrics.StatusCode);
Assert.Contains(metrics.Headers.WwwAuthenticate, value => value.Scheme == "Bearer");
Assert.Equal(HttpStatusCode.Unauthorized, badKey.StatusCode);
Assert.NotEqual(HttpStatusCode.Unauthorized, health.StatusCode);
Assert.NotEqual(HttpStatusCode.Forbidden, health.StatusCode);
}
finally
{
await app.StopAsync();
}
}
/// <summary>
/// The dashboard-login path: with dev-only <c>Dashboard:DisableLogin</c> every request carries
/// the auto-login Administrator principal under the dashboard cookie scheme, which the metrics
/// scheme accepts when no authorization header is sent.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Build_WhenMetricsRequireAuthentication_AcceptsDashboardLogin()
{
await using WebApplication app = GatewayApplication.Build(
[
"--urls=http://127.0.0.1:0",
"--MxGateway:Metrics:RequireAuthentication=true",
"--MxGateway:Dashboard:DisableLogin=true",
]);
await app.StartAsync();
try
{
using HttpClient client = new() { BaseAddress = new Uri(app.Urls.First()) };
using HttpResponseMessage response = await client.GetAsync("/metrics");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
finally
{
await app.StopAsync();
}
}
/// <summary>Verifies that Build maps dashboard and authentication endpoints when the dashboard is enabled.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
@@ -1078,7 +1078,6 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
}
}
/// <summary>An <see cref="IAsyncStreamReader{T}"/> that yields no messages.</summary>
/// <typeparam name="T">The streamed message type.</typeparam>
private sealed class EmptyStreamReader<T> : IAsyncStreamReader<T>
@@ -0,0 +1,249 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
using ZB.MOM.WW.MxGateway.Server.Metrics;
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
using LibApiKeyIdentity = ZB.MOM.WW.Auth.Abstractions.ApiKeys.ApiKeyIdentity;
namespace ZB.MOM.WW.MxGateway.Tests.Security.Authorization;
/// <summary>
/// The optional <c>/metrics</c> authentication (<c>MxGateway:Metrics:RequireAuthentication</c>,
/// SEC-14): the scheme that authenticates a scrape and the requirement that authorizes it.
/// </summary>
public sealed class MetricsAuthorizationTests
{
private const string AdminHeader = "Bearer mxgw_scraper_secret";
/// <summary>A verified API key authenticates and carries its scopes as claims.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Authenticate_WithValidApiKey_SucceedsWithScopeClaims()
{
CountingVerifier verifier = new(Success(GatewayScopes.Admin));
DefaultHttpContext httpContext = ContextWithAuthorization(AdminHeader);
AuthenticateResult result = await AuthenticateAsync(verifier, httpContext);
Assert.True(result.Succeeded);
Assert.Equal("scraper", result.Principal!.Identity!.Name);
Assert.True(result.Principal.HasClaim(MetricsAuthorizationDefaults.ScopeClaimType, GatewayScopes.Admin));
Assert.Equal(AdminHeader, verifier.LastHeader);
}
/// <summary>An invalid key fails authentication with an opaque reason.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Authenticate_WithInvalidApiKey_Fails()
{
CountingVerifier verifier = new(new ApiKeyVerification(false, null, ApiKeyFailure.SecretMismatch));
AuthenticateResult result = await AuthenticateAsync(verifier, ContextWithAuthorization(AdminHeader));
Assert.False(result.Succeeded);
Assert.NotNull(result.Failure);
Assert.DoesNotContain("secret", result.Failure!.Message, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Repeated bad keys from one peer trip the shared failure limiter, after which the verifier is
/// no longer reached and the challenge answers 429 — /metrics is not a bypass of the gRPC throttle.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Authenticate_AfterRepeatedFailures_IsThrottledBeforeVerification()
{
CountingVerifier verifier = new(new ApiKeyVerification(false, null, ApiKeyFailure.SecretMismatch));
ApiKeyFailureLimiter limiter = new(
limit: 2,
TimeSpan.FromMinutes(1),
maxPartitions: 16,
aggregateLimit: 0,
probeInterval: TimeSpan.Zero,
TimeProvider.System);
for (int attempt = 0; attempt < 2; attempt++)
{
await AuthenticateAsync(verifier, ContextWithAuthorization(AdminHeader), limiter);
}
DefaultHttpContext throttled = ContextWithAuthorization(AdminHeader);
MetricsAuthenticationHandler handler = await CreateHandlerAsync(verifier, throttled, limiter);
AuthenticateResult result = await handler.AuthenticateAsync();
await handler.ChallengeAsync(new AuthenticationProperties());
Assert.False(result.Succeeded);
Assert.Equal(2, verifier.CallCount);
Assert.Equal(StatusCodes.Status429TooManyRequests, throttled.Response.StatusCode);
}
/// <summary>An anonymous scrape is challenged with a plain 401 Bearer, not a login redirect.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Challenge_WithoutCredentials_Returns401Bearer()
{
DefaultHttpContext httpContext = new();
MetricsAuthenticationHandler handler = await CreateHandlerAsync(
new CountingVerifier(Success(GatewayScopes.Admin)),
httpContext);
AuthenticateResult result = await handler.AuthenticateAsync();
await handler.ChallengeAsync(new AuthenticationProperties());
Assert.True(result.None);
Assert.Equal(StatusCodes.Status401Unauthorized, httpContext.Response.StatusCode);
Assert.Equal("Bearer", httpContext.Response.Headers.WWWAuthenticate.ToString());
}
/// <summary>An API key holding the admin scope satisfies the metrics requirement.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Requirement_ApiKeyWithAdminScope_Succeeds()
{
Assert.True(await EvaluateAsync(ApiKeyPrincipal(GatewayScopes.Admin)));
}
/// <summary>An API key without the admin scope does not satisfy the requirement.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Requirement_ApiKeyWithoutAdminScope_Fails()
{
Assert.False(await EvaluateAsync(ApiKeyPrincipal(GatewayScopes.EventsRead, GatewayScopes.InvokeRead)));
}
/// <summary>A scope claim counts only on an identity the metrics scheme minted from a verified key.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Requirement_ScopeClaimOnForeignIdentity_Fails()
{
ClaimsPrincipal principal = new(new ClaimsIdentity(
[new Claim(MetricsAuthorizationDefaults.ScopeClaimType, GatewayScopes.Admin)],
DashboardAuthenticationDefaults.AuthenticationScheme));
Assert.False(await EvaluateAsync(principal));
}
/// <summary>A dashboard login holding a real dashboard role satisfies the requirement.</summary>
/// <param name="role">The dashboard role.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(DashboardRoles.Admin)]
[InlineData(DashboardRoles.Viewer)]
public async Task Requirement_DashboardLoginWithRole_Succeeds(string role)
{
ClaimsPrincipal principal = new(new ClaimsIdentity(
[new Claim(ClaimTypes.Name, "operator"), new Claim(ClaimTypes.Role, role)],
DashboardAuthenticationDefaults.AuthenticationScheme));
Assert.True(await EvaluateAsync(principal));
}
/// <summary>An unauthenticated principal (anonymous localhost included) never satisfies it.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Requirement_Unauthenticated_Fails()
{
ClaimsPrincipal principal = new(new ClaimsIdentity([new Claim(ClaimTypes.Role, DashboardRoles.Admin)]));
Assert.False(await EvaluateAsync(principal));
}
private static async Task<bool> EvaluateAsync(ClaimsPrincipal principal)
{
MetricsAccessRequirement requirement = new();
AuthorizationHandlerContext context = new([requirement], principal, resource: null);
await new MetricsAccessHandler().HandleAsync(context);
return context.HasSucceeded;
}
private static ClaimsPrincipal ApiKeyPrincipal(params string[] scopes)
{
List<Claim> claims = [new Claim(ClaimTypes.Name, "scraper")];
claims.AddRange(scopes.Select(scope => new Claim(MetricsAuthorizationDefaults.ScopeClaimType, scope)));
return new ClaimsPrincipal(new ClaimsIdentity(claims, MetricsAuthorizationDefaults.AuthenticationScheme));
}
private static async Task<AuthenticateResult> AuthenticateAsync(
IApiKeyVerifier verifier,
HttpContext httpContext,
ApiKeyFailureLimiter? limiter = null)
{
MetricsAuthenticationHandler handler = await CreateHandlerAsync(verifier, httpContext, limiter);
return await handler.AuthenticateAsync();
}
private static async Task<MetricsAuthenticationHandler> CreateHandlerAsync(
IApiKeyVerifier verifier,
HttpContext httpContext,
ApiKeyFailureLimiter? limiter = null)
{
MetricsAuthenticationHandler handler = new(
new StaticOptionsMonitor(),
NullLoggerFactory.Instance,
UrlEncoder.Default,
verifier,
limiter ?? new ApiKeyFailureLimiter(1000, TimeSpan.FromMinutes(1), 1024, 0, TimeSpan.Zero, TimeProvider.System),
new AuthenticationSchemeProvider(Options.Create(new AuthenticationOptions())),
new GatewayMetrics());
await handler.InitializeAsync(
new AuthenticationScheme(
MetricsAuthorizationDefaults.AuthenticationScheme,
displayName: null,
typeof(MetricsAuthenticationHandler)),
httpContext);
return handler;
}
private static DefaultHttpContext ContextWithAuthorization(string header)
{
DefaultHttpContext context = new();
context.Request.Headers.Authorization = header;
context.Connection.RemoteIpAddress = System.Net.IPAddress.Parse("198.51.100.7");
return context;
}
private static ApiKeyVerification Success(params string[] scopes) => new(
Succeeded: true,
Identity: new LibApiKeyIdentity(
KeyId: "scraper",
DisplayName: "Scraper",
Scopes: new HashSet<string>(scopes, StringComparer.Ordinal),
Constraints: null),
Failure: null);
private sealed class CountingVerifier(ApiKeyVerification result) : IApiKeyVerifier
{
/// <summary>Gets the number of verifications performed.</summary>
public int CallCount { get; private set; }
/// <summary>Gets the last authorization header presented.</summary>
public string? LastHeader { get; private set; }
/// <inheritdoc />
public Task<ApiKeyVerification> VerifyAsync(string authorizationHeader, CancellationToken ct)
{
CallCount++;
LastHeader = authorizationHeader;
return Task.FromResult(result);
}
}
private sealed class StaticOptionsMonitor : IOptionsMonitor<AuthenticationSchemeOptions>
{
/// <inheritdoc />
public AuthenticationSchemeOptions CurrentValue { get; } = new();
/// <inheritdoc />
public AuthenticationSchemeOptions Get(string? name) => CurrentValue;
/// <inheritdoc />
public IDisposable? OnChange(Action<AuthenticationSchemeOptions, string?> listener) => null;
}
}