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.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
using ZB.MOM.WW.MxGateway.Server.Metrics;
namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
/// Names shared by the optional /metrics authentication (MxGateway:Metrics:RequireAuthentication).
public static class MetricsAuthorizationDefaults
{
/// Authentication scheme that authenticates a metrics scrape.
public const string AuthenticationScheme = "MxGateway.Metrics";
/// Authorization policy attached to /metrics when authentication is required.
public const string Policy = "MxGateway.Metrics";
/// Claim type carrying one API-key scope on a principal the metrics scheme authenticated.
public const string ScopeClaimType = "mxgateway:scope";
}
///
/// Authenticates a /metrics scrape from either credential the gateway already issues: an API
/// key presented as Authorization: Bearer mxgw_… (verified through the same cached
/// and per-peer failure limiter as gRPC calls), or, when no
/// authorization header is sent, the dashboard login cookie.
///
///
/// One scheme fronting both credentials keeps the challenge a plain 401 with
/// WWW-Authenticate: Bearer, 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 429, mirroring the gRPC
/// ResourceExhausted. Failure reasons are never echoed to the caller and the presented
/// secret is never logged. The limiter's per-peer partition here is the remote IP, whereas the gRPC
/// interceptor's is the gRPC peer string (address and port, i.e. per connection), so the two paths
/// share only the per-key-id aggregate layer.
///
public sealed class MetricsAuthenticationHandler(
IOptionsMonitor options,
ILoggerFactory logger,
UrlEncoder encoder,
IApiKeyVerifier apiKeyVerifier,
ApiKeyFailureLimiter failureLimiter,
IAuthenticationSchemeProvider schemeProvider,
IOptions gatewayOptions,
GatewayMetrics metrics)
: AuthenticationHandler(options, logger, encoder)
{
private bool _throttled;
///
protected override async Task 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 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));
}
///
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 AuthenticateDashboardCookieAsync()
{
// Dashboard:DisableLogin registers an always-succeeding auto-login handler under the cookie
// scheme name. Falling back to it would let every anonymous request satisfy /metrics, so
// with DisableLogin on, RequireAuthentication=true accepts API keys only.
if (gatewayOptions.Value.Dashboard.DisableLogin)
{
return AuthenticateResult.NoResult();
}
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();
}
}
/// Requirement for /metrics access; see .
public sealed class MetricsAccessRequirement : IAuthorizationRequirement
{
}
///
/// Grants /metrics to an API key carrying the admin scope or to a dashboard login
/// holding a real dashboard role (Administrator or Viewer). It reads no options, so neither
/// anonymous localhost nor Authentication:Mode=Disabled can satisfy it.
///
///
/// 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
/// admin because metrics are an operator surface, not one of the data-plane scopes a client
/// key is normally issued with.
///
public sealed class MetricsAccessHandler : AuthorizationHandler
{
///
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;
}
}
/// Registration for the optional /metrics authentication.
public static class MetricsAuthorizationServiceCollectionExtensions
{
///
/// Registers the metrics authentication scheme, the
/// policy and its handler. Registration is unconditional and inert: the policy only takes effect
/// when MxGateway:Metrics:RequireAuthentication attaches it to the endpoint.
///
/// Service collection to register into.
/// The same service collection, for chaining.
public static IServiceCollection AddGatewayMetricsAuthorization(this IServiceCollection services)
{
services.AddAuthentication()
.AddScheme(
MetricsAuthorizationDefaults.AuthenticationScheme,
_ => { });
services.AddAuthorizationBuilder()
.AddPolicy(
MetricsAuthorizationDefaults.Policy,
policy => policy
.AddAuthenticationSchemes(MetricsAuthorizationDefaults.AuthenticationScheme)
.AddRequirements(new MetricsAccessRequirement()));
services.AddSingleton();
return services;
}
}