From 125ae09c8c8c553ab24653612818b494e49aaf49 Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 29 Sep 2026 06:53:27 +0200 Subject: [PATCH 01/24] Accept external access tokens on configured bearer routes Per-service bearer routes authenticate by an access token from a configured authorization server (discovery + JWKS, cached, refreshed on an unknown key) with strict validation: exact issuer, audience per route, lifetime with small skew, RS256/ES256 only, at+jwt type. A request on a bearer route never reaches the cookie session, provider or tenant selection: it is refused with an RFC 6750 challenge naming the RFC 9728 resource metadata, or forwarded straight to the backend with the Microsoft Identity Platform headers, Tenant-ID from tid, and the token's scopes and client id. The resource metadata path passes through anonymously, and a bearer-route token is refused on every other path. Refs #143 --- .../AuthProxy/BearerRoutes/BearerChallenge.cs | 131 ++++++++ .../BearerRoutes/BearerForwardedIdentity.cs | 60 ++++ .../BearerRoutes/BearerIssuerMetadata.cs | 79 +++++ .../BearerRoutes/BearerRouteClaims.cs | 49 +++ .../BearerRouteConfigurationValidator.cs | 169 ++++++++++ .../BearerRoutes/BearerRouteDefaults.cs | 27 ++ .../BearerRoutes/BearerRouteForwarder.cs | 69 ++++ .../BearerRouteHeadersTransform.cs | 63 ++++ .../BearerRoutes/BearerRouteMiddleware.cs | 104 ++++++ .../BearerRouteServiceCollectionExtensions.cs | 39 +++ .../BearerRoutes/BearerRouteTable.cs | 254 +++++++++++++++ .../BearerRoutes/BearerRoutesLogging.cs | 25 ++ .../BearerRoutes/BearerTokenValidation.cs | 43 +++ .../BearerTokenValidationStatus.cs | 28 ++ .../BearerRoutes/BearerTokenValidator.cs | 295 ++++++++++++++++++ .../BearerRoutes/IBearerIssuerMetadata.cs | 30 ++ .../BearerRoutes/IBearerRouteForwarder.cs | 22 ++ .../BearerRoutes/IBearerTokenValidator.cs | 19 ++ .../BearerRoutes/ResolvedBearerIssuer.cs | 13 + .../BearerRoutes/ResolvedBearerRoute.cs | 52 +++ .../AuthProxy/Configuration/BearerIssuer.cs | 45 +++ Source/AuthProxy/Configuration/BearerRoute.cs | 96 ++++++ Source/AuthProxy/Configuration/Service.cs | 10 + Source/AuthProxy/Headers.cs | 15 + .../InjectIdentityHeadersTransform.cs | 4 + Source/AuthProxy/IngressExtensions.cs | 6 + Source/AuthProxy/Program.cs | 2 + Source/AuthProxy/TenancyMiddleware.cs | 2 + 28 files changed, 1751 insertions(+) create mode 100644 Source/AuthProxy/BearerRoutes/BearerChallenge.cs create mode 100644 Source/AuthProxy/BearerRoutes/BearerForwardedIdentity.cs create mode 100644 Source/AuthProxy/BearerRoutes/BearerIssuerMetadata.cs create mode 100644 Source/AuthProxy/BearerRoutes/BearerRouteClaims.cs create mode 100644 Source/AuthProxy/BearerRoutes/BearerRouteConfigurationValidator.cs create mode 100644 Source/AuthProxy/BearerRoutes/BearerRouteDefaults.cs create mode 100644 Source/AuthProxy/BearerRoutes/BearerRouteForwarder.cs create mode 100644 Source/AuthProxy/BearerRoutes/BearerRouteHeadersTransform.cs create mode 100644 Source/AuthProxy/BearerRoutes/BearerRouteMiddleware.cs create mode 100644 Source/AuthProxy/BearerRoutes/BearerRouteServiceCollectionExtensions.cs create mode 100644 Source/AuthProxy/BearerRoutes/BearerRouteTable.cs create mode 100644 Source/AuthProxy/BearerRoutes/BearerRoutesLogging.cs create mode 100644 Source/AuthProxy/BearerRoutes/BearerTokenValidation.cs create mode 100644 Source/AuthProxy/BearerRoutes/BearerTokenValidationStatus.cs create mode 100644 Source/AuthProxy/BearerRoutes/BearerTokenValidator.cs create mode 100644 Source/AuthProxy/BearerRoutes/IBearerIssuerMetadata.cs create mode 100644 Source/AuthProxy/BearerRoutes/IBearerRouteForwarder.cs create mode 100644 Source/AuthProxy/BearerRoutes/IBearerTokenValidator.cs create mode 100644 Source/AuthProxy/BearerRoutes/ResolvedBearerIssuer.cs create mode 100644 Source/AuthProxy/BearerRoutes/ResolvedBearerRoute.cs create mode 100644 Source/AuthProxy/Configuration/BearerIssuer.cs create mode 100644 Source/AuthProxy/Configuration/BearerRoute.cs diff --git a/Source/AuthProxy/BearerRoutes/BearerChallenge.cs b/Source/AuthProxy/BearerRoutes/BearerChallenge.cs new file mode 100644 index 00000000..da3f8d7b --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/BearerChallenge.cs @@ -0,0 +1,131 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Net.Http.Headers; + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Writes the API-style refusals of a bearer route: RFC 6750 challenges carrying the RFC 9728 +/// resource_metadata parameter an MCP client discovers its authorization server from. +/// +/// +/// Never a redirect and never a page: a bearer route is called by programs, and a program told to go and sign +/// in somewhere has nothing to do with the answer. Every refusal is marked no-store so no cache +/// can hand one caller's refusal to another. The response body is empty; why a token was refused is logged, +/// never told to the caller. +/// +public static class BearerChallenge +{ + /// + /// The seconds a client is asked to wait when the issuer's keys could not be retrieved. + /// + public const int IssuerUnavailableRetryAfterSeconds = 30; + + /// + /// Writes the refusal matching a failed validation. + /// + /// The current . + /// The failed validation. + /// The route the token was presented on. + public static void Write(HttpContext context, BearerTokenValidation validation, ResolvedBearerRoute route) + { + switch (validation.Status) + { + case BearerTokenValidationStatus.Missing: + // RFC 6750 §3.1: a request that carried no credentials gets no error code. + Unauthorized(context, route.ResourceMetadataUrl, error: null); + break; + + case BearerTokenValidationStatus.InsufficientScope: + Challenge( + context, + StatusCodes.Status403Forbidden, + route.ResourceMetadataUrl, + "insufficient_scope", + string.Join(' ', BearerTokenValidator.RequiredScopes(route))); + break; + + case BearerTokenValidationStatus.MissingTenant: + Forbidden(context); + break; + + case BearerTokenValidationStatus.IssuerUnavailable: + // The token could not be checked, which is not the same as the token being wrong: telling the + // client its token is invalid would send it to sign in again for an outage on the issuer's side. + NoStore(context); + context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + context.Response.Headers.RetryAfter = IssuerUnavailableRetryAfterSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture); + break; + + default: + Unauthorized(context, route.ResourceMetadataUrl, "invalid_token"); + break; + } + } + + /// + /// Writes a 401 challenge. + /// + /// The current . + /// The protected-resource metadata URL to name, if any. + /// The RFC 6750 error code, or for a request that carried no token. + public static void Unauthorized(HttpContext context, Uri? resourceMetadataUrl, string? error) => + Challenge(context, StatusCodes.Status401Unauthorized, resourceMetadataUrl, error, scope: null); + + /// + /// Writes a bare 403: the token is valid, and the request is still refused. + /// + /// The current . + public static void Forbidden(HttpContext context) + { + NoStore(context); + context.Response.StatusCode = StatusCodes.Status403Forbidden; + } + + /// + /// Builds the value of a WWW-Authenticate bearer challenge. + /// + /// The protected-resource metadata URL to name, if any. + /// The RFC 6750 error code, if any. + /// The scopes the request needs, if any. + /// The header value. + /// + /// Every value placed in a quoted string here is either a constant, a URL that was parsed as an absolute URI + /// at startup, or a scope validated against the RFC 6749 scope-token grammar — none can carry a quote or a + /// backslash, so none can break out of its parameter. + /// + public static string ChallengeValue(Uri? resourceMetadataUrl, string? error, string? scope) + { + var parameters = new List(); + if (error is not null) + { + parameters.Add($"error=\"{error}\""); + } + + if (!string.IsNullOrEmpty(scope)) + { + parameters.Add($"scope=\"{scope}\""); + } + + if (resourceMetadataUrl is not null) + { + parameters.Add($"resource_metadata=\"{resourceMetadataUrl.AbsoluteUri}\""); + } + + return parameters.Count == 0 ? "Bearer" : $"Bearer {string.Join(", ", parameters)}"; + } + + static void Challenge(HttpContext context, int statusCode, Uri? resourceMetadataUrl, string? error, string? scope) + { + NoStore(context); + context.Response.StatusCode = statusCode; + context.Response.Headers.WWWAuthenticate = ChallengeValue(resourceMetadataUrl, error, scope); + } + + static void NoStore(HttpContext context) + { + context.Response.Headers.CacheControl = "no-store"; + context.Response.Headers[HeaderNames.Pragma] = "no-cache"; + } +} diff --git a/Source/AuthProxy/BearerRoutes/BearerForwardedIdentity.cs b/Source/AuthProxy/BearerRoutes/BearerForwardedIdentity.cs new file mode 100644 index 00000000..88e6dfce --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/BearerForwardedIdentity.cs @@ -0,0 +1,60 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.Identity; + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Represents what AuthProxy vouches for when it forwards a request authenticated on a bearer route. +/// +/// The principal sent in the Microsoft Identity Platform headers. +/// The tenant from the token, sent as . +/// The granted scopes, sent as . +/// The client the token was issued to, sent as , when the token names one. +/// Whether the Authorization header travels to the backend too. +public sealed record BearerForwardedIdentity( + ClientPrincipal Principal, + string TenantId, + IReadOnlyList Scopes, + string? ClientId, + bool ForwardAuthorizationHeader) +{ + /// + /// Creates the forwarded identity for a successfully validated token. + /// + /// The successful validation. + /// The route the token was validated for. + /// The forwarded identity. + /// + /// The principal is built the way a browser session's is: the user id from sub and the user + /// details from preferred_username, then name — after the route's claim mappings + /// have applied, so a route can present the identity the backend already knows (for example the GitHub id + /// and login a GitHub browser session carries). The only roles are anonymous and + /// authenticated: a token never grants a role. + /// + public static BearerForwardedIdentity From(BearerTokenValidation validation, ResolvedBearerRoute route) + { + var user = validation.Principal!; + var subject = user.FindFirst("sub")?.Value ?? string.Empty; + var details = user.FindFirst("preferred_username")?.Value + ?? user.FindFirst("name")?.Value + ?? subject; + + var principal = new ClientPrincipal + { + IdentityProvider = route.IdentityProvider, + UserId = subject, + UserDetails = details, + UserRoles = ["anonymous", "authenticated"], + Claims = [.. user.Claims.Select(_ => new ClientPrincipalClaim { Type = _.Type, Value = _.Value })], + }; + + return new BearerForwardedIdentity( + principal, + validation.TenantId!, + [.. user.FindAll(BearerRouteClaims.Scope).Select(_ => _.Value)], + user.FindFirst(BearerRouteClaims.ClientId)?.Value, + route.Route.ForwardAuthorizationHeader); + } +} diff --git a/Source/AuthProxy/BearerRoutes/BearerIssuerMetadata.cs b/Source/AuthProxy/BearerRoutes/BearerIssuerMetadata.cs new file mode 100644 index 00000000..f450e676 --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/BearerIssuerMetadata.cs @@ -0,0 +1,79 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Concurrent; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Reads and caches the signing keys bearer-token issuers publish. +/// +/// The factory for the client the metadata and JWKS are read with. +/// The logger. +/// +/// One configuration manager per metadata address holds the last good metadata and keys, refreshes them +/// periodically, and refreshes them on request when a token names an unknown key — which is how key rotation at the +/// issuer is picked up. A refresh that fails keeps the last good keys, so an issuer outage after the first retrieval +/// does not refuse tokens that were signed with a key already known. +/// +/// The metadata must name exactly the configured issuer. A document that names another one is refused rather than +/// trusted: the keys it points at belong to whoever that other issuer is. +/// +/// +public sealed class BearerIssuerMetadata( + IHttpClientFactory httpClientFactory, + ILogger logger) : IBearerIssuerMetadata +{ + /// + /// The shortest interval between two requested refreshes of one issuer's metadata. + /// + public static readonly TimeSpan RefreshInterval = TimeSpan.FromSeconds(30); + + readonly ConcurrentDictionary<(string Address, bool RequireHttps), ConfigurationManager> _managers = new(); + + /// + public async Task?> GetSigningKeys(ResolvedBearerIssuer issuer, CancellationToken cancellationToken) + { + try + { + var configuration = await ManagerFor(issuer).GetConfigurationAsync(cancellationToken); + if (!string.Equals(configuration.Issuer, issuer.Issuer, StringComparison.Ordinal)) + { + logger.IssuerMetadataNamesAnotherIssuer(issuer.Issuer, issuer.MetadataAddress); + return null; + } + + return [.. configuration.SigningKeys]; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + logger.IssuerMetadataUnavailable(exception, issuer.Issuer, issuer.MetadataAddress); + return null; + } + } + + /// + public void RequestRefresh(ResolvedBearerIssuer issuer) => ManagerFor(issuer).RequestRefresh(); + + ConfigurationManager ManagerFor(ResolvedBearerIssuer issuer) => + _managers.GetOrAdd( + (issuer.MetadataAddress, issuer.RequireHttps), + static (key, factory) => new ConfigurationManager( + key.Address, + new OpenIdConnectConfigurationRetriever(), + new HttpDocumentRetriever(factory.CreateClient(BearerRouteDefaults.MetadataHttpClientName)) + { + RequireHttps = key.RequireHttps, + }) + { + RefreshInterval = RefreshInterval, + }, + httpClientFactory); +} diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteClaims.cs b/Source/AuthProxy/BearerRoutes/BearerRouteClaims.cs new file mode 100644 index 00000000..3439daad --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/BearerRouteClaims.cs @@ -0,0 +1,49 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Defines the claim types AuthProxy writes into the forwarded principal of a request authenticated on a bearer +/// route. +/// +/// +/// The whole urn:cratis:bearer: namespace is AuthProxy's own. It is removed from every token before +/// AuthProxy writes its values, and removed from every principal that was not authenticated on a bearer route, so +/// a backend can rely on these claims meaning exactly what is documented here and on their presence meaning the +/// request came through a bearer route. +/// +public static class BearerRouteClaims +{ + /// + /// The claim type for the validated issuer of the access token. + /// + public const string Issuer = "urn:cratis:bearer:issuer"; + + /// + /// The claim type for the access token's own sub, kept even when a claim mapping rewrites + /// sub for the backend. + /// + public const string Subject = "urn:cratis:bearer:subject"; + + /// + /// The claim type for the client the token was issued to, from azp or, failing that, + /// client_id. This is the client's asserted identity: a public client cannot prove which program + /// is using it. + /// + public const string ClientId = "urn:cratis:bearer:client-id"; + + /// + /// The claim type for a granted scope. One claim per scope. + /// + public const string Scope = "urn:cratis:bearer:scope"; + + const string ReservedPrefix = "urn:cratis:bearer:"; + + /// + /// Determines whether a claim type belongs to the namespace AuthProxy reserves for bearer-route metadata. + /// + /// The claim type to inspect. + /// when the claim type is reserved; otherwise . + public static bool IsReserved(string claimType) => claimType.StartsWith(ReservedPrefix, StringComparison.OrdinalIgnoreCase); +} diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteConfigurationValidator.cs b/Source/AuthProxy/BearerRoutes/BearerRouteConfigurationValidator.cs new file mode 100644 index 00000000..bb68338e --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/BearerRouteConfigurationValidator.cs @@ -0,0 +1,169 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Buffers; +using System.Security.Claims; +using Cratis.AuthProxy.Authentication; +using Microsoft.Extensions.Options; +using C = Cratis.AuthProxy.Configuration; + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Refuses a configuration declaring a bearer route AuthProxy could not enforce as written. +/// +/// +/// A route that cannot be resolved is left out of , which leaves its path on the +/// browser-session model. That is closed, but invisibly so: the operator believes a token-authenticated API is +/// being served and every client gets a sign-in redirect. Every such mistake is named here, at startup, instead. +/// +public class BearerRouteConfigurationValidator : IValidateOptions +{ + /// + /// The RFC 6749 §3.3 scope-token characters: %x21 / %x23-5B / %x5D-7E. Excluding the quote and the backslash + /// also keeps every scope safe to quote in a WWW-Authenticate parameter. + /// + static readonly SearchValues _scopeCharacters = SearchValues.Create( + "!#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]^_`abcdefghijklmnopqrstuvwxyz{|}~"); + + /// + public ValidateOptionsResult Validate(string? name, C.AuthProxy options) + { + var failures = new List(); + var prefixes = new Dictionary(StringComparer.OrdinalIgnoreCase); + var metadataPaths = new Dictionary(StringComparer.OrdinalIgnoreCase); + var anonymousPaths = options.Services.Values.SelectMany(AnonymousPaths.For).ToArray(); + + foreach (var (serviceName, service) in options.Services) + { + for (var index = 0; index < service.BearerRoutes.Count; index++) + { + var route = service.BearerRoutes[index]; + var at = $"{C.AuthProxy.SectionKey}:{nameof(C.AuthProxy.Services)}:{serviceName}:{nameof(C.Service.BearerRoutes)}:{index}"; + ValidateRoute(at, serviceName, service, route, prefixes, metadataPaths, anonymousPaths, failures); + } + } + + return failures.Count == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(failures); + } + + static void ValidateRoute( + string at, + string serviceName, + C.Service service, + C.BearerRoute route, + Dictionary prefixes, + Dictionary metadataPaths, + string[] anonymousPaths, + List failures) + { + if (service.Backend is null || string.IsNullOrWhiteSpace(service.Backend.BaseUrl)) + { + failures.Add($"{at}: the service declares no backend, so a bearer route has nothing to forward to."); + } + + var rejection = AnonymousPathPolicy.Evaluate(route.PathPrefix, out var prefix); + if (rejection != AnonymousPathRejection.None) + { + failures.Add($"{at}:{nameof(C.BearerRoute.PathPrefix)} '{route.PathPrefix}' is not usable ({rejection})."); + } + else + { + if (!prefixes.TryAdd(prefix, serviceName)) + { + failures.Add($"{at}:{nameof(C.BearerRoute.PathPrefix)} '{prefix}' is already a bearer route of service '{prefixes[prefix]}'."); + } + + var overlapping = anonymousPaths.FirstOrDefault(_ => Overlaps(_, prefix)); + if (overlapping is not null) + { + failures.Add($"{at}:{nameof(C.BearerRoute.PathPrefix)} '{prefix}' overlaps the anonymous path '{overlapping}'. A path cannot be both."); + } + } + + if (route.Issuers.Count == 0) + { + failures.Add($"{at}:{nameof(C.BearerRoute.Issuers)} is empty. A bearer route must name the authorization server whose tokens it accepts."); + } + + for (var i = 0; i < route.Issuers.Count; i++) + { + if (!BearerRouteTable.TryResolveIssuer(route.Issuers[i], out _)) + { + failures.Add( + $"{at}:{nameof(C.BearerRoute.Issuers)}:{i}: the issuer and its metadata address must be absolute HTTPS URIs without query, " + + "fragment or user information (plain HTTP is accepted only on a loopback host, and then for both)."); + } + } + + if (route.Audiences.All(string.IsNullOrWhiteSpace)) + { + failures.Add($"{at}:{nameof(C.BearerRoute.Audiences)} is empty. A bearer route must name the audience its tokens are issued for."); + } + + foreach (var scope in route.RequiredScopes.Where(_ => !string.IsNullOrWhiteSpace(_)).Select(_ => _.Trim())) + { + if (scope.AsSpan().IndexOfAnyExcept(_scopeCharacters) >= 0) + { + failures.Add($"{at}:{nameof(C.BearerRoute.RequiredScopes)} '{scope}' is not a valid OAuth scope."); + } + } + + ValidateResourceMetadata(at, serviceName, route, metadataPaths, failures); + + if (route.ClockSkew is { } skew && (skew < TimeSpan.Zero || skew > C.BearerRoute.MaximumClockSkew)) + { + failures.Add($"{at}:{nameof(C.BearerRoute.ClockSkew)} must be between zero and {C.BearerRoute.MaximumClockSkew}."); + } + + foreach (var (target, source) in route.ClaimMappings) + { + if (string.IsNullOrWhiteSpace(target) || string.IsNullOrWhiteSpace(source)) + { + failures.Add($"{at}:{nameof(C.BearerRoute.ClaimMappings)} has a mapping with an empty claim type."); + } + else if (CanonicalIdentityClaims.IsReserved(target) || BearerRouteClaims.IsReserved(target) || IsRoleClaim(target)) + { + failures.Add($"{at}:{nameof(C.BearerRoute.ClaimMappings)} may not write '{target}': AuthProxy owns that claim type."); + } + } + } + + static void ValidateResourceMetadata( + string at, + string serviceName, + C.BearerRoute route, + Dictionary metadataPaths, + List failures) + { + if (string.IsNullOrWhiteSpace(route.ResourceMetadataUrl)) + { + return; + } + + if (!BearerRouteTable.TryParseAuthorityUri(route.ResourceMetadataUrl, out var metadataUrl, out _) + || AnonymousPathPolicy.Evaluate(metadataUrl.AbsolutePath, out var metadataPath) != AnonymousPathRejection.None) + { + failures.Add( + $"{at}:{nameof(C.BearerRoute.ResourceMetadataUrl)} '{route.ResourceMetadataUrl}' must be an absolute HTTPS URL without query, " + + "fragment or user information, whose path AuthProxy may forward to the service."); + return; + } + + if (metadataPaths.TryGetValue(metadataPath, out var owner) && !string.Equals(owner, serviceName, StringComparison.OrdinalIgnoreCase)) + { + failures.Add($"{at}:{nameof(C.BearerRoute.ResourceMetadataUrl)} path '{metadataPath}' is already served by service '{owner}'."); + return; + } + + metadataPaths[metadataPath] = serviceName; + } + + static bool Overlaps(string first, string second) => + new PathString(first).StartsWithSegments(second) || new PathString(second).StartsWithSegments(first); + + static bool IsRoleClaim(string claimType) => + string.Equals(claimType, ClaimTypes.Role, StringComparison.Ordinal) + || string.Equals(claimType, "role", StringComparison.Ordinal) + || string.Equals(claimType, "roles", StringComparison.Ordinal); +} diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteDefaults.cs b/Source/AuthProxy/BearerRoutes/BearerRouteDefaults.cs new file mode 100644 index 00000000..2ff96509 --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/BearerRouteDefaults.cs @@ -0,0 +1,27 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Defines constants used by bearer routes. +/// +public static class BearerRouteDefaults +{ + /// + /// The name of the HTTP client used to read issuer metadata and signing keys. + /// + public const string MetadataHttpClientName = "Cratis.AuthProxy.BearerRoutes.Metadata"; + + /// + /// The JWT signing algorithms accepted on a bearer route. Symmetric algorithms and none are + /// never accepted. + /// + public static readonly IReadOnlyList AllowedAlgorithms = ["RS256", "ES256"]; + + /// + /// The key holding the of a request + /// authenticated on a bearer route. Absent for everything else, including a protected-resource metadata request. + /// + internal const string ForwardedIdentityItemKey = "Cratis.AuthProxy.BearerRoutes.ForwardedIdentity"; +} diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteForwarder.cs b/Source/AuthProxy/BearerRoutes/BearerRouteForwarder.cs new file mode 100644 index 00000000..f2206cea --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/BearerRouteForwarder.cs @@ -0,0 +1,69 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Yarp.ReverseProxy.Configuration; +using Yarp.ReverseProxy.Forwarder; +using Yarp.ReverseProxy.Transforms.Builder; + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Forwards bearer-route requests straight to the service backend. +/// +/// +/// A bearer route does not go through the reverse-proxy route table, because everything that table's routes are +/// guarded by — the cookie session, provider selection, tenant selection, identity verification — is exactly what +/// a bearer route must not use. It forwards with the same default transforms (X-Forwarded-*, header +/// copying), the same HTTP client configuration and the same timeouts as the table's routes, then replaces every identity header with what +/// vouches for. +/// +/// The YARP forwarder. +/// The YARP client factory, which applies the reverse proxy's own client configuration. +/// The YARP transform builder. +/// The logger. +public sealed class BearerRouteForwarder( + IHttpForwarder forwarder, + IForwarderHttpClientFactory clientFactory, + ITransformBuilder transformBuilder, + ILogger logger) : IBearerRouteForwarder, IDisposable +{ + static readonly ForwarderRequestConfig _requestConfig = new() { ActivityTimeout = TimeSpan.FromMinutes(5) }; + + readonly HttpTransformer _transformer = transformBuilder.Create(context => context.RequestTransforms.Add(new BearerRouteHeadersTransform())); + + readonly HttpMessageInvoker _invoker = clientFactory.CreateClient(new ForwarderHttpClientContext + { + ClusterId = "bearer-routes", + OldConfig = HttpClientConfig.Empty, + OldMetadata = null, + OldClient = null, + NewConfig = HttpClientConfig.Empty, + NewMetadata = null, + }); + + /// + public async Task Forward(HttpContext context, ResolvedBearerRoute route, BearerForwardedIdentity? identity) + { + if (identity is null) + { + context.Items.Remove(BearerRouteDefaults.ForwardedIdentityItemKey); + } + else + { + context.Items[BearerRouteDefaults.ForwardedIdentityItemKey] = identity; + } + + var error = await forwarder.SendAsync(context, route.BackendBaseUrl, _invoker, _requestConfig, _transformer); + if (error != ForwarderError.None) + { + logger.BearerRouteForwardingFailed( + context.Features.Get()?.Exception, + route.Prefix, + route.ServiceName, + error); + } + } + + /// + public void Dispose() => _invoker.Dispose(); +} diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteHeadersTransform.cs b/Source/AuthProxy/BearerRoutes/BearerRouteHeadersTransform.cs new file mode 100644 index 00000000..945ad65c --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/BearerRouteHeadersTransform.cs @@ -0,0 +1,63 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.Identity; +using Microsoft.Net.Http.Headers; +using Yarp.ReverseProxy.Transforms; + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Replaces every identity header of a bearer-route request with what AuthProxy vouches for. +/// +/// +/// Every inbound copy of a header a backend trusts is removed first — the principal set, Tenant-ID, +/// and the token client and scope headers — so a caller cannot add to what the token says. The +/// Cookie header is removed because a bearer route does not use browser sessions, and a backend must +/// not be able to fall back to one. Authorization is removed unless the route asks for it: the +/// backend receives the principal AuthProxy vouches for, not the credential. +/// +public class BearerRouteHeadersTransform : RequestTransform +{ + /// + public override ValueTask ApplyAsync(RequestTransformContext context) + { + var headers = context.ProxyRequest.Headers; + headers.Remove(Headers.Principal); + headers.Remove(Headers.PrincipalId); + headers.Remove(Headers.PrincipalName); + headers.Remove(Headers.PrincipalNameExtended); + headers.Remove(Headers.TenantId); + headers.Remove(Headers.TokenClientId); + headers.Remove(Headers.TokenScope); + headers.Remove(HeaderNames.Cookie); + + var identity = context.HttpContext.Items.TryGetValue(BearerRouteDefaults.ForwardedIdentityItemKey, out var item) + ? item as BearerForwardedIdentity + : null; + + if (identity?.ForwardAuthorizationHeader != true) + { + headers.Authorization = null; + } + + if (identity is null) + { + return ValueTask.CompletedTask; + } + + context.ProxyRequest.SetMicrosoftIdentityHeaders(identity.Principal); + headers.TryAddWithoutValidation(Headers.TenantId, HeaderValue.ToTransportValue(identity.TenantId)); + if (identity.Scopes.Count > 0) + { + headers.TryAddWithoutValidation(Headers.TokenScope, HeaderValue.ToTransportValue(string.Join(' ', identity.Scopes))); + } + + if (!string.IsNullOrWhiteSpace(identity.ClientId)) + { + headers.TryAddWithoutValidation(Headers.TokenClientId, HeaderValue.ToTransportValue(identity.ClientId)); + } + + return ValueTask.CompletedTask; + } +} diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteMiddleware.cs b/Source/AuthProxy/BearerRoutes/BearerRouteMiddleware.cs new file mode 100644 index 00000000..5f84c1a4 --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/BearerRouteMiddleware.cs @@ -0,0 +1,104 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.Tenancy; +using Microsoft.Extensions.Options; +using Microsoft.Net.Http.Headers; +using C = Cratis.AuthProxy.Configuration; + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// The gate for bearer routes: authenticates a request on a bearer route by its access token alone and forwards +/// it, and refuses a bearer-route token anywhere else. +/// +/// The next middleware in the pipeline. +/// The auth proxy configuration monitor. +/// The access-token validator. +/// The tenant existence verifier, applied when tenant verification is configured. +/// The forwarder to the service backend. +/// The logger. +/// +/// Placed ahead of static files, authentication and everything after them, so a request on a bearer route never +/// reaches the cookie session, provider selection, tenant selection or the reverse-proxy route table: it is +/// answered here, by a forward or by an API-style refusal, and nothing else. The only other thing it does is +/// refuse a token from a bearer-route issuer on a path that is not one of the bearer routes, so such a token +/// cannot authenticate a browser-only surface through any other bearer support the deployment has configured. +/// +/// With no bearer route configured it hands every request on untouched. +/// +/// +public class BearerRouteMiddleware( + RequestDelegate next, + IOptionsMonitor config, + IBearerTokenValidator validator, + ITenantVerifier tenantVerifier, + IBearerRouteForwarder forwarder, + ILogger logger) +{ + /// + public async Task InvokeAsync(HttpContext context) + { + var current = config.CurrentValue; + if (BearerRouteTable.All(current).Count == 0) + { + await next(context); + return; + } + + if (BearerRouteTable.TryMatchResourceMetadata(context.Request.Path, current, out var metadataRoute)) + { + await ServeResourceMetadata(context, metadataRoute); + return; + } + + if (BearerRouteTable.TryMatch(context.Request.Path, current, out var route)) + { + await Authenticate(context, route); + return; + } + + if (BearerTokenValidator.TryReadPresentedIssuer(context.Request, out var issuer) + && BearerRouteTable.IsBearerRouteIssuer(issuer, current)) + { + logger.BearerTokenOutsideItsRoutes(issuer, RequestPathRedaction.Redact(context.Request.Path)); + BearerChallenge.Unauthorized(context, resourceMetadataUrl: null, "invalid_token"); + return; + } + + await next(context); + } + + async Task ServeResourceMetadata(HttpContext context, ResolvedBearerRoute route) + { + if (!HttpMethods.IsGet(context.Request.Method) && !HttpMethods.IsHead(context.Request.Method)) + { + context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed; + context.Response.Headers[HeaderNames.Allow] = "GET, HEAD"; + return; + } + + await forwarder.Forward(context, route, identity: null); + } + + async Task Authenticate(HttpContext context, ResolvedBearerRoute route) + { + var validation = await validator.Validate(context.Request, route, context.RequestAborted); + if (!validation.Succeeded) + { + logger.BearerTokenRefused(route.Prefix, route.ServiceName, validation.Status, validation.Reason ?? string.Empty); + BearerChallenge.Write(context, validation, route); + return; + } + + if (!await tenantVerifier.VerifyAsync(validation.TenantId!)) + { + logger.BearerTenantNotVerified(validation.TenantId!, RequestPathRedaction.Redact(context.Request.Path)); + BearerChallenge.Forbidden(context); + return; + } + + context.User = validation.Principal!; + await forwarder.Forward(context, route, BearerForwardedIdentity.From(validation, route)); + } +} diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteServiceCollectionExtensions.cs b/Source/AuthProxy/BearerRoutes/BearerRouteServiceCollectionExtensions.cs new file mode 100644 index 00000000..3e3fc316 --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/BearerRouteServiceCollectionExtensions.cs @@ -0,0 +1,39 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Extensions.Options; +using C = Cratis.AuthProxy.Configuration; + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Extension methods for registering bearer routes. +/// +public static class BearerRouteServiceCollectionExtensions +{ + /// + /// The timeout for reading an issuer's metadata document or JWKS. + /// + public static readonly TimeSpan MetadataTimeout = TimeSpan.FromSeconds(10); + + /// + /// Registers the services bearer routes need. Registering them changes nothing for a deployment that + /// declares no bearer route. + /// + /// The to configure. + /// The same for chaining. + /// + /// Must follow the reverse-proxy registration, whose forwarder and transform builder the bearer-route + /// forwarder uses. + /// + public static WebApplicationBuilder AddBearerRoutes(this WebApplicationBuilder builder) + { + builder.Services.AddHttpClient(BearerRouteDefaults.MetadataHttpClientName, client => client.Timeout = MetadataTimeout); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton, BearerRouteConfigurationValidator>(); + + return builder; + } +} diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteTable.cs b/Source/AuthProxy/BearerRoutes/BearerRouteTable.cs new file mode 100644 index 00000000..cc74460c --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/BearerRouteTable.cs @@ -0,0 +1,254 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Runtime.CompilerServices; +using C = Cratis.AuthProxy.Configuration; + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Resolves the bearer routes declared in . +/// +/// +/// The authentication scheme selector, the bearer gate, the tenancy and identity middlewares, the anonymous-path +/// matcher and the reverse-proxy route table all have to agree on which paths are bearer routes, so they all +/// resolve through here. A route that cannot be resolved — an unusable prefix or metadata URL, an issuer that is +/// not an absolute HTTPS URI, a service without a backend — is left out, which leaves its path on the +/// browser-session model; refuses such a configuration at startup. +/// +public static class BearerRouteTable +{ + /// + /// The path segment RFC 8414 inserts between an issuer's host and path to locate its metadata document. + /// + public const string AuthorizationServerMetadataPath = "/.well-known/oauth-authorization-server"; + + static readonly ConditionalWeakTable _resolved = []; + + /// + /// Gets every resolvable bearer route in the configuration. + /// + /// The auth proxy configuration to read. + /// The resolved routes. + public static IReadOnlyList All(C.AuthProxy config) => Resolve(config).Routes; + + /// + /// Gets the resolvable bearer routes a single service declares. + /// + /// The name of the service. + /// The service configuration. + /// The resolved routes. + public static IEnumerable For(string serviceName, C.Service service) + { + if (service.Backend is null) + { + yield break; + } + + foreach (var route in service.BearerRoutes) + { + if (TryResolve(serviceName, service.Backend.BaseUrl, route, out var resolved)) + { + yield return resolved; + } + } + } + + /// + /// Finds the bearer route covering a request path. + /// + /// The request path. + /// The auth proxy configuration to read. + /// The matching route. + /// when the path is on a bearer route; otherwise . + public static bool TryMatch(PathString path, C.AuthProxy config, out ResolvedBearerRoute route) + { + var routes = Resolve(config).Routes; + for (var i = 0; i < routes.Length; i++) + { + if (path.StartsWithSegments(routes[i].Prefix)) + { + route = routes[i]; + return true; + } + } + + route = default!; + return false; + } + + /// + /// Finds the bearer route whose protected-resource metadata document a request path names. + /// + /// The request path. + /// The auth proxy configuration to read. + /// The route declaring that metadata path. + /// when the path is exactly a declared metadata path; otherwise . + /// + /// Matched exactly, not as a prefix: the metadata document is the one thing on a bearer route that is served + /// without a token, so nothing under it is. + /// + public static bool TryMatchResourceMetadata(PathString path, C.AuthProxy config, out ResolvedBearerRoute route) + { + var candidate = (path.Value ?? string.Empty).TrimEnd('/'); + var routes = Resolve(config).Routes; + for (var i = 0; i < routes.Length; i++) + { + if (routes[i].ResourceMetadataPath is { } metadataPath + && string.Equals(metadataPath, candidate, StringComparison.OrdinalIgnoreCase)) + { + route = routes[i]; + return true; + } + } + + route = default!; + return false; + } + + /// + /// Determines whether an issuer is accepted on any bearer route. + /// + /// The issuer identifier to look for. + /// The auth proxy configuration to read. + /// when some bearer route accepts the issuer; otherwise . + public static bool IsBearerRouteIssuer(string issuer, C.AuthProxy config) => Resolve(config).Issuers.Contains(issuer); + + /// + /// Resolves a configured issuer, applying its defaults. + /// + /// The configured issuer. + /// The resolved issuer. + /// when the issuer is usable; otherwise . + public static bool TryResolveIssuer(C.BearerIssuer issuer, out ResolvedBearerIssuer resolved) + { + resolved = default!; + + if (!TryParseAuthorityUri(issuer.Issuer, out var issuerUri, out var isLoopbackHttp)) + { + return false; + } + + string metadataAddress; + if (string.IsNullOrWhiteSpace(issuer.MetadataAddress)) + { + metadataAddress = $"{issuerUri.GetLeftPart(UriPartial.Authority)}{AuthorizationServerMetadataPath}{issuerUri.AbsolutePath.TrimEnd('/')}"; + } + else if (TryParseAuthorityUri(issuer.MetadataAddress, out var metadataUri, out var metadataIsLoopbackHttp) + && (!metadataIsLoopbackHttp || isLoopbackHttp)) + { + metadataAddress = metadataUri.AbsoluteUri; + } + else + { + return false; + } + + var tokenTypes = issuer.TokenTypes.Where(_ => !string.IsNullOrWhiteSpace(_)).Select(_ => _.Trim()).ToArray(); + resolved = new ResolvedBearerIssuer( + issuer.Issuer, + metadataAddress, + tokenTypes.Length > 0 ? tokenTypes : C.BearerIssuer.DefaultTokenTypes, + RequireHttps: !isLoopbackHttp); + + return true; + } + + /// + /// Parses an absolute URI an authorization server or a resource is identified by. + /// + /// The configured value. + /// The parsed URI. + /// Whether the URI is a plain-HTTP loopback development address. + /// when the value is usable; otherwise . + /// + /// HTTPS is required, except on a loopback host, so neither a token's trust anchor nor the address a client is + /// told to fetch metadata from can be downgraded outside a developer's own machine. Query, fragment and user + /// information are refused because none of them belongs in an identifier. + /// + public static bool TryParseAuthorityUri(string? candidate, out Uri uri, out bool isLoopbackHttp) + { + isLoopbackHttp = false; + if (string.IsNullOrWhiteSpace(candidate) + || !Uri.TryCreate(candidate.Trim(), UriKind.Absolute, out uri!) + || !string.IsNullOrEmpty(uri.Query) + || !string.IsNullOrEmpty(uri.Fragment) + || !string.IsNullOrEmpty(uri.UserInfo)) + { + uri = default!; + return false; + } + + if (string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.Ordinal)) + { + return true; + } + + if (string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.Ordinal) && uri.IsLoopback) + { + isLoopbackHttp = true; + return true; + } + + uri = default!; + return false; + } + + static bool TryResolve(string serviceName, string backendBaseUrl, C.BearerRoute route, out ResolvedBearerRoute resolved) + { + resolved = default!; + + if (AnonymousPathPolicy.Evaluate(route.PathPrefix, out var prefix) != AnonymousPathRejection.None + || route.Issuers.Count == 0 + || route.Audiences.All(string.IsNullOrWhiteSpace)) + { + return false; + } + + var issuers = new List(); + foreach (var issuer in route.Issuers) + { + if (!TryResolveIssuer(issuer, out var resolvedIssuer)) + { + return false; + } + + issuers.Add(resolvedIssuer); + } + + Uri? metadataUrl = null; + string? metadataPath = null; + if (!string.IsNullOrWhiteSpace(route.ResourceMetadataUrl)) + { + if (!TryParseAuthorityUri(route.ResourceMetadataUrl, out metadataUrl, out _) + || AnonymousPathPolicy.Evaluate(metadataUrl.AbsolutePath, out var normalizedMetadataPath) != AnonymousPathRejection.None) + { + return false; + } + + metadataPath = normalizedMetadataPath; + } + + resolved = new ResolvedBearerRoute(serviceName, backendBaseUrl, prefix, issuers, route, metadataUrl, metadataPath); + return true; + } + + static Resolution Resolve(C.AuthProxy config) => _resolved.GetValue(config, Create); + + static Resolution Create(C.AuthProxy config) + { + // Longest prefix first, so a narrower route declared under a wider one is the one that applies to it. + var routes = config.Services + .SelectMany(_ => For(_.Key, _.Value)) + .OrderByDescending(_ => _.Prefix.Length) + .ToArray(); + var issuers = routes + .SelectMany(_ => _.Issuers) + .Select(_ => _.Issuer) + .ToHashSet(StringComparer.Ordinal); + + return new Resolution(routes, issuers); + } + + sealed record Resolution(ResolvedBearerRoute[] Routes, HashSet Issuers); +} diff --git a/Source/AuthProxy/BearerRoutes/BearerRoutesLogging.cs b/Source/AuthProxy/BearerRoutes/BearerRoutesLogging.cs new file mode 100644 index 00000000..da21d9f5 --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/BearerRoutesLogging.cs @@ -0,0 +1,25 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.BearerRoutes; + +internal static partial class BearerRoutesLogging +{ + [LoggerMessage(LogLevel.Warning, "The metadata at '{MetadataAddress}' does not name the configured issuer '{Issuer}'. Tokens from that issuer are refused until it does.")] + internal static partial void IssuerMetadataNamesAnotherIssuer(this ILogger logger, string issuer, string metadataAddress); + + [LoggerMessage(LogLevel.Warning, "The metadata or signing keys of issuer '{Issuer}' could not be retrieved from '{MetadataAddress}'.")] + internal static partial void IssuerMetadataUnavailable(this ILogger logger, Exception exception, string issuer, string metadataAddress); + + [LoggerMessage(LogLevel.Information, "Bearer token refused on route '{Route}' of service '{Service}' ({Status}): {Reason}")] + internal static partial void BearerTokenRefused(this ILogger logger, string route, string service, BearerTokenValidationStatus status, string reason); + + [LoggerMessage(LogLevel.Information, "A token from bearer-route issuer '{Issuer}' was presented on {Path}, which is not one of its bearer routes. Refused.")] + internal static partial void BearerTokenOutsideItsRoutes(this ILogger logger, string issuer, string path); + + [LoggerMessage(LogLevel.Warning, "Tenant '{TenantId}' named by a bearer token on {Path} could not be verified. Refused.")] + internal static partial void BearerTenantNotVerified(this ILogger logger, string tenantId, string path); + + [LoggerMessage(LogLevel.Warning, "Forwarding bearer route '{Route}' of service '{Service}' failed ({Error}).")] + internal static partial void BearerRouteForwardingFailed(this ILogger logger, Exception? exception, string route, string service, Yarp.ReverseProxy.Forwarder.ForwarderError error); +} diff --git a/Source/AuthProxy/BearerRoutes/BearerTokenValidation.cs b/Source/AuthProxy/BearerRoutes/BearerTokenValidation.cs new file mode 100644 index 00000000..79d7c337 --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/BearerTokenValidation.cs @@ -0,0 +1,43 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Security.Claims; + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Represents the result of validating the access token on a bearer route. +/// +/// The outcome. +/// The principal to forward, only when is . +/// The tenant from the token, only when is . +/// A short, log-safe explanation of a refusal. Never sent to the caller. +public sealed record BearerTokenValidation( + BearerTokenValidationStatus Status, + ClaimsPrincipal? Principal = null, + string? TenantId = null, + string? Reason = null) +{ + /// + /// Gets a value indicating whether the token was accepted. + /// + public bool Succeeded => Status == BearerTokenValidationStatus.Succeeded; + + /// + /// Creates a successful result. + /// + /// The principal to forward. + /// The tenant from the token. + /// The result. + public static BearerTokenValidation Success(ClaimsPrincipal principal, string tenantId) => + new(BearerTokenValidationStatus.Succeeded, principal, tenantId); + + /// + /// Creates a refusal. + /// + /// The refusal outcome. + /// A short, log-safe explanation. + /// The result. + public static BearerTokenValidation Refused(BearerTokenValidationStatus status, string reason) => + new(status, Reason: reason); +} diff --git a/Source/AuthProxy/BearerRoutes/BearerTokenValidationStatus.cs b/Source/AuthProxy/BearerRoutes/BearerTokenValidationStatus.cs new file mode 100644 index 00000000..51e072ce --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/BearerTokenValidationStatus.cs @@ -0,0 +1,28 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Represents the outcome of validating the access token on a bearer route. +/// +public enum BearerTokenValidationStatus +{ + /// The token is valid for the route and carries a tenant. + Succeeded = 0, + + /// The request carries no bearer token. + Missing = 1, + + /// The token is malformed, expired, wrongly signed, from another issuer or for another audience. + Invalid = 2, + + /// The token is valid but lacks a scope the route requires. + InsufficientScope = 3, + + /// The token is valid but carries no single usable tenant. + MissingTenant = 4, + + /// The issuer's metadata or signing keys could not be retrieved, so the token could not be checked. + IssuerUnavailable = 5, +} diff --git a/Source/AuthProxy/BearerRoutes/BearerTokenValidator.cs b/Source/AuthProxy/BearerRoutes/BearerTokenValidator.cs new file mode 100644 index 00000000..376aa903 --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/BearerTokenValidator.cs @@ -0,0 +1,295 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Buffers; +using System.Security.Claims; +using Cratis.AuthProxy.Authentication; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Validates the access token presented on a bearer route and builds the principal AuthProxy forwards for it. +/// +/// The source of the issuers' signing keys. +/// +/// The token is checked strictly: the issuer must be one the route accepts and must match exactly; the signature +/// must verify against a key the issuer publishes, with an asymmetric algorithm from +/// (so neither none nor a symmetric algorithm keyed +/// with a public key is accepted); the typ header must name an access token; the audience must be one +/// the route accepts; and the token must carry an expiry and be within its lifetime, allowing only the route's small +/// clock skew. Encrypted tokens are refused. Only then are scopes and tenant looked at. +/// +public sealed class BearerTokenValidator(IBearerIssuerMetadata metadata) : IBearerTokenValidator +{ + /// + /// The longest tenant identifier accepted from a token. + /// + public const int MaximumTenantIdLength = 256; + + const string BearerPrefix = "Bearer "; + const string ScopeClaimType = "scope"; + const string SubjectClaimType = "sub"; + + static readonly SearchValues _tokenCharacters = SearchValues.Create( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~+/="); + + static readonly SearchValues _tenantCharacters = SearchValues.Create( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"); + + static readonly JsonWebTokenHandler _handler = new() { MapInboundClaims = false }; + + enum TokenPresence + { + Absent = 0, + Present = 1, + Malformed = 2, + } + + /// + /// Gets the scopes a route requires, trimmed and without blanks. + /// + /// The route. + /// The required scopes. + public static IEnumerable RequiredScopes(ResolvedBearerRoute route) => + route.Route.RequiredScopes + .Where(_ => !string.IsNullOrWhiteSpace(_)) + .Select(_ => _.Trim()) + .Distinct(StringComparer.Ordinal); + + /// + /// Reads the issuer a request's bearer token claims to come from, without validating anything. + /// + /// The request. + /// The claimed issuer. + /// when the request carries a readable, unencrypted JWT naming an issuer; otherwise . + /// + /// Only good for deciding which rules apply to a token, never for trusting it: nothing about the value has + /// been checked. + /// + public static bool TryReadPresentedIssuer(HttpRequest request, out string issuer) + { + issuer = string.Empty; + return ReadBearerToken(request, out var token) == TokenPresence.Present + && TryReadUnvalidatedIssuer(token, out issuer); + } + + /// + public async Task Validate(HttpRequest request, ResolvedBearerRoute route, CancellationToken cancellationToken) + { + var header = ReadBearerToken(request, out var token); + if (header == TokenPresence.Absent) + { + return BearerTokenValidation.Refused(BearerTokenValidationStatus.Missing, "No bearer token was presented."); + } + + if (header == TokenPresence.Malformed) + { + return BearerTokenValidation.Refused(BearerTokenValidationStatus.Invalid, "The authorization header is malformed."); + } + + if (!TryReadUnvalidatedIssuer(token, out var presentedIssuer)) + { + return BearerTokenValidation.Refused(BearerTokenValidationStatus.Invalid, "The token is not a signed JWT."); + } + + var issuer = route.Issuers.FirstOrDefault(_ => string.Equals(_.Issuer, presentedIssuer, StringComparison.Ordinal)); + if (issuer is null) + { + return BearerTokenValidation.Refused(BearerTokenValidationStatus.Invalid, "The token's issuer is not accepted on this route."); + } + + var keys = await metadata.GetSigningKeys(issuer, cancellationToken); + if (keys is null) + { + return BearerTokenValidation.Refused(BearerTokenValidationStatus.IssuerUnavailable, "The issuer's signing keys are unavailable."); + } + + var result = await _handler.ValidateTokenAsync(token, CreateParameters(route, issuer, keys)); + if (!result.IsValid && result.Exception is SecurityTokenSignatureKeyNotFoundException) + { + // The token names a key this proxy has not seen — the issuer may have rotated. Ask for fresh keys once; + // refreshes are rate limited, so this cannot be turned into a flood of requests to the issuer. + metadata.RequestRefresh(issuer); + keys = await metadata.GetSigningKeys(issuer, cancellationToken); + if (keys is null) + { + return BearerTokenValidation.Refused(BearerTokenValidationStatus.IssuerUnavailable, "The issuer's signing keys are unavailable."); + } + + result = await _handler.ValidateTokenAsync(token, CreateParameters(route, issuer, keys)); + } + + if (!result.IsValid || result.ClaimsIdentity is null) + { + return BearerTokenValidation.Refused( + BearerTokenValidationStatus.Invalid, + $"The token failed validation ({result.Exception?.GetType().Name ?? "unknown"})."); + } + + var claims = result.ClaimsIdentity.Claims.ToArray(); + var scopes = claims + .Where(_ => string.Equals(_.Type, ScopeClaimType, StringComparison.Ordinal)) + .SelectMany(_ => _.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + var missingScopes = RequiredScopes(route).Where(_ => !scopes.Contains(_, StringComparer.Ordinal)).ToArray(); + if (missingScopes.Length > 0) + { + return BearerTokenValidation.Refused( + BearerTokenValidationStatus.InsufficientScope, + $"The token lacks the required scope(s) {string.Join(' ', missingScopes)}."); + } + + if (!TryGetSingleValue(claims, route.TenantClaimType, out var tenantId) || !IsUsableTenantId(tenantId)) + { + return BearerTokenValidation.Refused( + BearerTokenValidationStatus.MissingTenant, + $"The token carries no single usable '{route.TenantClaimType}' claim."); + } + + if (!TryGetSingleValue(claims, SubjectClaimType, out var subject)) + { + return BearerTokenValidation.Refused(BearerTokenValidationStatus.Invalid, "The token carries no single subject."); + } + + if (!TryBuildForwardedClaims(route, claims, out var forwarded, out var missingSource)) + { + return BearerTokenValidation.Refused( + BearerTokenValidationStatus.Invalid, + $"The token carries no '{missingSource}' claim for a configured claim mapping."); + } + + forwarded.Add(new Claim(BearerRouteClaims.Issuer, issuer.Issuer)); + forwarded.Add(new Claim(BearerRouteClaims.Subject, subject)); + if (TryGetSingleValue(claims, "azp", out var clientId) || TryGetSingleValue(claims, "client_id", out clientId)) + { + forwarded.Add(new Claim(BearerRouteClaims.ClientId, clientId)); + } + + forwarded.AddRange(scopes.Select(_ => new Claim(BearerRouteClaims.Scope, _))); + + var identity = new ClaimsIdentity(forwarded, route.IdentityProvider, "name", ClaimTypes.Role); + return BearerTokenValidation.Success(new ClaimsPrincipal(identity), tenantId); + } + + static TokenValidationParameters CreateParameters(ResolvedBearerRoute route, ResolvedBearerIssuer issuer, IReadOnlyCollection keys) => new() + { + ValidateIssuer = true, + ValidIssuer = issuer.Issuer, + ValidateAudience = true, + RequireAudience = true, + ValidAudiences = route.Route.Audiences.Where(_ => !string.IsNullOrWhiteSpace(_)).Select(_ => _.Trim()).ToArray(), + ValidateLifetime = true, + RequireExpirationTime = true, + ClockSkew = route.ClockSkew, + RequireSignedTokens = true, + ValidateIssuerSigningKey = true, + IssuerSigningKeys = keys, + ValidAlgorithms = BearerRouteDefaults.AllowedAlgorithms, + ValidTypes = issuer.TokenTypes, + SaveSigninToken = false, + }; + + static TokenPresence ReadBearerToken(HttpRequest request, out string token) + { + token = string.Empty; + + var values = request.Headers.Authorization; + if (values.Count == 0) + { + return TokenPresence.Absent; + } + + if (values.Count != 1 || values[0] is not { } value) + { + return TokenPresence.Malformed; + } + + // Another scheme is not a bearer token at all, which RFC 6750 answers with a bare challenge. + if (!value.StartsWith(BearerPrefix, StringComparison.OrdinalIgnoreCase)) + { + return TokenPresence.Absent; + } + + token = value[BearerPrefix.Length..]; + return token.Length > 0 && token.AsSpan().IndexOfAnyExcept(_tokenCharacters) < 0 + ? TokenPresence.Present + : TokenPresence.Malformed; + } + + static bool TryReadUnvalidatedIssuer(string token, out string issuer) + { + issuer = string.Empty; + if (!_handler.CanReadToken(token)) + { + return false; + } + + try + { + var jwt = _handler.ReadJsonWebToken(token); + if (jwt.IsEncrypted || string.IsNullOrEmpty(jwt.Issuer)) + { + return false; + } + + issuer = jwt.Issuer; + return true; + } + catch (Exception exception) when (exception is ArgumentException or SecurityTokenException or JsonException) + { + return false; + } + } + + static bool TryGetSingleValue(IEnumerable claims, string claimType, out string value) + { + var values = claims.Where(_ => string.Equals(_.Type, claimType, StringComparison.Ordinal)).Take(2).ToArray(); + value = values.Length == 1 ? values[0].Value.Trim() : string.Empty; + return value.Length > 0; + } + + static bool IsUsableTenantId(string tenantId) => + tenantId.Length <= MaximumTenantIdLength && tenantId.AsSpan().IndexOfAnyExcept(_tenantCharacters) < 0; + + static bool TryBuildForwardedClaims(ResolvedBearerRoute route, Claim[] tokenClaims, out List forwarded, out string missingSource) + { + missingSource = string.Empty; + + // AuthProxy's own namespaces are written by AuthProxy alone. A token that carries them does not get to + // speak for the proxy: canonical identity claims would make the forwarded principal unbuildable, and + // bearer-route claims would claim a client or scope the token was not validated for. + // Role claims are dropped too: the issuer vouches for who the caller is and what the client may attempt, + // and the forwarded principal carries no role the backend did not grant itself. + forwarded = [.. tokenClaims + .Where(_ => !CanonicalIdentityClaims.IsReserved(_.Type) && !BearerRouteClaims.IsReserved(_.Type) && !IsRoleClaim(_.Type)) + .Select(_ => new Claim(_.Type, _.Value, _.ValueType))]; + + foreach (var (target, source) in route.Route.ClaimMappings) + { + var values = tokenClaims + .Where(_ => string.Equals(_.Type, source, StringComparison.Ordinal)) + .Select(_ => _.Value) + .Where(_ => !string.IsNullOrWhiteSpace(_)) + .ToArray(); + if (values.Length == 0) + { + missingSource = source; + return false; + } + + forwarded.RemoveAll(_ => string.Equals(_.Type, target, StringComparison.Ordinal)); + forwarded.AddRange(values.Select(_ => new Claim(target, _))); + } + + return true; + } + + static bool IsRoleClaim(string claimType) => + string.Equals(claimType, ClaimTypes.Role, StringComparison.Ordinal) + || string.Equals(claimType, "role", StringComparison.Ordinal) + || string.Equals(claimType, "roles", StringComparison.Ordinal); +} diff --git a/Source/AuthProxy/BearerRoutes/IBearerIssuerMetadata.cs b/Source/AuthProxy/BearerRoutes/IBearerIssuerMetadata.cs new file mode 100644 index 00000000..8a62da25 --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/IBearerIssuerMetadata.cs @@ -0,0 +1,30 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.IdentityModel.Tokens; + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Defines a source of the signing keys a bearer-token issuer publishes. +/// +public interface IBearerIssuerMetadata +{ + /// + /// Gets the issuer's current signing keys, from its metadata document and JWKS. + /// + /// The issuer. + /// The request's cancellation token. + /// The signing keys, or when they could not be retrieved or the metadata names another issuer. + Task?> GetSigningKeys(ResolvedBearerIssuer issuer, CancellationToken cancellationToken); + + /// + /// Asks for the issuer's metadata and keys to be retrieved again, because a token names a key that is not known. + /// + /// The issuer. + /// + /// Refreshes are rate limited, so a flood of tokens naming unknown keys cannot turn into a flood of requests to + /// the issuer. + /// + void RequestRefresh(ResolvedBearerIssuer issuer); +} diff --git a/Source/AuthProxy/BearerRoutes/IBearerRouteForwarder.cs b/Source/AuthProxy/BearerRoutes/IBearerRouteForwarder.cs new file mode 100644 index 00000000..8cf6fabc --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/IBearerRouteForwarder.cs @@ -0,0 +1,22 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Defines the forwarding of a bearer-route request to the backend of the service declaring the route. +/// +public interface IBearerRouteForwarder +{ + /// + /// Forwards the request to the route's service backend. + /// + /// The current . + /// The route the request is on. + /// + /// What AuthProxy vouches for, or to forward without any identity — the + /// protected-resource metadata request. + /// + /// A that completes when the response has been relayed. + Task Forward(HttpContext context, ResolvedBearerRoute route, BearerForwardedIdentity? identity); +} diff --git a/Source/AuthProxy/BearerRoutes/IBearerTokenValidator.cs b/Source/AuthProxy/BearerRoutes/IBearerTokenValidator.cs new file mode 100644 index 00000000..99e89427 --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/IBearerTokenValidator.cs @@ -0,0 +1,19 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Defines the validation of the access token presented on a bearer route. +/// +public interface IBearerTokenValidator +{ + /// + /// Validates the access token the request carries against the route it targets. + /// + /// The request. + /// The bearer route the request targets. + /// The request's cancellation token. + /// The validation outcome. + Task Validate(HttpRequest request, ResolvedBearerRoute route, CancellationToken cancellationToken); +} diff --git a/Source/AuthProxy/BearerRoutes/ResolvedBearerIssuer.cs b/Source/AuthProxy/BearerRoutes/ResolvedBearerIssuer.cs new file mode 100644 index 00000000..dece4448 --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/ResolvedBearerIssuer.cs @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Represents a configured bearer-token issuer with its defaults applied. +/// +/// The exact issuer identifier a token and the issuer metadata must both name. +/// The address of the issuer's metadata document. +/// The accepted JWT typ header values. +/// Whether the metadata and JWKS documents must be retrieved over HTTPS. +public sealed record ResolvedBearerIssuer(string Issuer, string MetadataAddress, IReadOnlyList TokenTypes, bool RequireHttps); diff --git a/Source/AuthProxy/BearerRoutes/ResolvedBearerRoute.cs b/Source/AuthProxy/BearerRoutes/ResolvedBearerRoute.cs new file mode 100644 index 00000000..4ba634c6 --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/ResolvedBearerRoute.cs @@ -0,0 +1,52 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using C = Cratis.AuthProxy.Configuration; + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Represents a configured bearer route with its path prefix normalized and its defaults applied. +/// +/// The name of the service the route belongs to. +/// The base URL of the service backend the route is forwarded to. +/// The normalized path prefix. +/// The issuers whose tokens the route accepts. +/// The route configuration. +/// The protected-resource metadata URL named in challenges, when configured. +/// The normalized path of , when configured. +public sealed record ResolvedBearerRoute( + string ServiceName, + string BackendBaseUrl, + string Prefix, + IReadOnlyList Issuers, + C.BearerRoute Route, + Uri? ResourceMetadataUrl, + string? ResourceMetadataPath) +{ + /// + /// Gets the clock skew allowed when validating a token lifetime on this route, never more than + /// and never negative. + /// + public TimeSpan ClockSkew => Route.ClockSkew switch + { + null => C.BearerRoute.DefaultClockSkew, + { } skew when skew < TimeSpan.Zero => TimeSpan.Zero, + { } skew when skew > C.BearerRoute.MaximumClockSkew => C.BearerRoute.MaximumClockSkew, + { } skew => skew, + }; + + /// + /// Gets the token claim the tenant is read from. + /// + public string TenantClaimType => string.IsNullOrWhiteSpace(Route.TenantClaimType) + ? C.BearerRoute.DefaultTenantClaimType + : Route.TenantClaimType; + + /// + /// Gets the identity provider label of the forwarded principal. + /// + public string IdentityProvider => string.IsNullOrWhiteSpace(Route.IdentityProvider) + ? C.BearerRoute.DefaultIdentityProvider + : Route.IdentityProvider; +} diff --git a/Source/AuthProxy/Configuration/BearerIssuer.cs b/Source/AuthProxy/Configuration/BearerIssuer.cs new file mode 100644 index 00000000..cea49989 --- /dev/null +++ b/Source/AuthProxy/Configuration/BearerIssuer.cs @@ -0,0 +1,45 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Configuration; + +/// +/// Represents an authorization server whose access tokens a accepts. +/// +/// +/// AuthProxy only relies on the issuer here — it never issues these tokens. The signing keys are read from the +/// issuer's published metadata (RFC 8414, or OpenID Connect discovery) and its JWKS, cached, and refreshed when a +/// token names a key the cache does not know, so key rotation at the issuer is honored without a restart. +/// +public class BearerIssuer +{ + /// + /// The access-token media types accepted when is left unset: the RFC 9068 JWT access + /// token profile's at+jwt, in its short and its full form. + /// + public static readonly IReadOnlyList DefaultTokenTypes = ["at+jwt", "application/at+jwt"]; + + /// + /// Gets or sets the issuer identifier. A token is accepted only when its iss claim is exactly + /// this value, and the issuer's published metadata must name exactly this value too. + /// + /// + /// An absolute HTTPS URI without query, fragment or user information. Plain HTTP is accepted only for a + /// loopback development issuer. + /// + public string Issuer { get; set; } = string.Empty; + + /// + /// Gets or sets the metadata document address. Leave unset to use the RFC 8414 address derived from + /// : /.well-known/oauth-authorization-server inserted between the issuer's + /// host and path. + /// + public string? MetadataAddress { get; set; } + + /// + /// Gets or sets the JWT typ header values accepted from this issuer. Leave unset to accept the + /// RFC 9068 access-token types in only, which keeps an ID token or any other + /// JWT the issuer signs from being presented as an access token. + /// + public IList TokenTypes { get; set; } = []; +} diff --git a/Source/AuthProxy/Configuration/BearerRoute.cs b/Source/AuthProxy/Configuration/BearerRoute.cs new file mode 100644 index 00000000..69c3bad8 --- /dev/null +++ b/Source/AuthProxy/Configuration/BearerRoute.cs @@ -0,0 +1,96 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Configuration; + +/// +/// Represents a path prefix on a service that is authenticated by an access token from an external authorization +/// server instead of by a browser session. +/// +/// +/// On a bearer route a valid access token is the only credential: the session cookie is never read, no provider +/// selection or tenant selection is ever served, and every refusal is an API-style 401 or +/// 403 with an RFC 6750 challenge. Tokens from the configured issuers are refused on every path +/// that is not one of their bearer routes, so a token cannot reach a browser-only surface. +/// +public class BearerRoute +{ + /// + /// The identity provider label the forwarded principal carries when is unset. + /// + public const string DefaultIdentityProvider = "bearer"; + + /// + /// The token claim the tenant is read from when is unset. + /// + public const string DefaultTenantClaimType = "tid"; + + /// + /// The clock skew allowed when is unset. + /// + public static readonly TimeSpan DefaultClockSkew = TimeSpan.FromSeconds(30); + + /// + /// The largest clock skew a route may allow. + /// + public static readonly TimeSpan MaximumClockSkew = TimeSpan.FromMinutes(5); + + /// + /// Gets or sets the path prefix this route covers, for example /mcp or /v1. Matched + /// case-insensitively on segment boundaries, with the same rules as . + /// + public string PathPrefix { get; set; } = string.Empty; + + /// + /// Gets or sets the authorization servers whose tokens this route accepts. + /// + public IList Issuers { get; set; } = []; + + /// + /// Gets or sets the audiences this route accepts. A token is accepted only when its aud claim names + /// at least one of them. + /// + public IList Audiences { get; set; } = []; + + /// + /// Gets or sets the scopes a token must carry, every one of them, to be accepted on this route. Leave empty to + /// require none. + /// + public IList RequiredScopes { get; set; } = []; + + /// + /// Gets or sets the absolute URL of this route's RFC 9728 protected-resource metadata document. It is named in + /// the resource_metadata parameter of every challenge on this route, and its path is forwarded to + /// the service's backend without authentication. + /// + public string? ResourceMetadataUrl { get; set; } + + /// + /// Gets or sets the token claim that carries the tenant. Defaults to . + /// + public string TenantClaimType { get; set; } = DefaultTenantClaimType; + + /// + /// Gets or sets the claims to rewrite before the principal is forwarded: forwarded claim type to the token claim + /// it is read from. A mapped source claim the token does not carry refuses the token. + /// + public IDictionary ClaimMappings { get; set; } = new Dictionary(); + + /// + /// Gets or sets the identity provider label of the forwarded principal. Defaults to + /// . + /// + public string IdentityProvider { get; set; } = DefaultIdentityProvider; + + /// + /// Gets or sets a value indicating whether the Authorization header is forwarded to the backend. + /// Defaults to : the backend receives the principal AuthProxy vouches for, not the token. + /// + public bool ForwardAuthorizationHeader { get; set; } + + /// + /// Gets or sets the clock skew allowed when checking the token lifetime. Defaults to + /// ; at most . + /// + public TimeSpan? ClockSkew { get; set; } +} diff --git a/Source/AuthProxy/Configuration/Service.cs b/Source/AuthProxy/Configuration/Service.cs index 37b79981..3db06e02 100644 --- a/Source/AuthProxy/Configuration/Service.cs +++ b/Source/AuthProxy/Configuration/Service.cs @@ -166,4 +166,14 @@ public class Service /// When configured, AuthProxy can verify client credentials against the service and mint scoped bearer tokens. /// public ServiceClientCredentials? ClientCredentials { get; set; } + + /// + /// Gets or sets the path prefixes on this service that are authenticated by an access token from an external + /// authorization server rather than by a browser session. + /// + /// + /// Each route is forwarded to the service's . Leave empty to keep every path on the + /// browser-session model. + /// + public IList BearerRoutes { get; set; } = []; } diff --git a/Source/AuthProxy/Headers.cs b/Source/AuthProxy/Headers.cs index 11c064ee..4e1cf85c 100644 --- a/Source/AuthProxy/Headers.cs +++ b/Source/AuthProxy/Headers.cs @@ -41,6 +41,21 @@ public static class Headers /// public const string TenantId = "Tenant-ID"; + /// + /// The client an access token was issued to — its azp, or failing that its + /// client_id — sent only for a request authenticated on a bearer route. + /// + /// + /// This is the client's asserted identity: a public client cannot prove which program is using it. + /// + public const string TokenClientId = "x-cratis-token-client-id"; + + /// + /// The space-separated scopes granted to an access token, sent only for a request authenticated on a bearer + /// route. + /// + public const string TokenScope = "x-cratis-token-scope"; + /// /// Service identifier used to route requests to the appropriate service. /// diff --git a/Source/AuthProxy/Identity/InjectIdentityHeadersTransform.cs b/Source/AuthProxy/Identity/InjectIdentityHeadersTransform.cs index 8a9a3d65..3b5d4981 100644 --- a/Source/AuthProxy/Identity/InjectIdentityHeadersTransform.cs +++ b/Source/AuthProxy/Identity/InjectIdentityHeadersTransform.cs @@ -27,6 +27,10 @@ public override ValueTask ApplyAsync(RequestTransformContext context) context.ProxyRequest.Headers.Remove(Headers.PrincipalName); context.ProxyRequest.Headers.Remove(Headers.PrincipalNameExtended); + // Only a bearer route vouches for these, and bearer routes are never forwarded through here. + context.ProxyRequest.Headers.Remove(Headers.TokenClientId); + context.ProxyRequest.Headers.Remove(Headers.TokenScope); + var principal = httpContext.BuildClientPrincipal(); if (principal is not null) { diff --git a/Source/AuthProxy/IngressExtensions.cs b/Source/AuthProxy/IngressExtensions.cs index 1dc46d8c..3094eda2 100644 --- a/Source/AuthProxy/IngressExtensions.cs +++ b/Source/AuthProxy/IngressExtensions.cs @@ -4,6 +4,7 @@ using Cratis.AuthProxy.Admission; using Cratis.AuthProxy.Authentication; using Cratis.AuthProxy.Authorization; +using Cratis.AuthProxy.BearerRoutes; using Cratis.AuthProxy.ErrorPages; using Cratis.AuthProxy.Identity; using Cratis.AuthProxy.Ingress; @@ -151,6 +152,11 @@ public static WebApplication UseIngress(this WebApplication app) // circuits on its first line for every deployment that has not opted in. app.UseMiddleware(); + // Ahead of the pages, the static files and authentication, because a bearer route must reach none of + // them: a request on one is answered here, by a forward or an API-style refusal. It hands every request on + // untouched when no bearer route is configured. + app.UseMiddleware(); + app.Map(WellKnownPaths.Pages, pagesApp => ConfigurePagesPipeline(pagesApp, app.Environment, app.Services.GetRequiredService>())); app.UseStaticFiles(); diff --git a/Source/AuthProxy/Program.cs b/Source/AuthProxy/Program.cs index 5e5425ce..802a3a66 100644 --- a/Source/AuthProxy/Program.cs +++ b/Source/AuthProxy/Program.cs @@ -4,6 +4,7 @@ using Cratis.AuthProxy; using Cratis.AuthProxy.Authentication; using Cratis.AuthProxy.Authorization; +using Cratis.AuthProxy.BearerRoutes; using Cratis.AuthProxy.Identity; using Cratis.AuthProxy.Invites; using Cratis.AuthProxy.Links; @@ -25,6 +26,7 @@ builder.AddLinks(); builder.AddSignIns(); builder.SetupReverseProxy(); +builder.AddBearerRoutes(); builder.AddManagement(); var app = builder.Build(); diff --git a/Source/AuthProxy/TenancyMiddleware.cs b/Source/AuthProxy/TenancyMiddleware.cs index 8e5b6647..dab4c4d1 100644 --- a/Source/AuthProxy/TenancyMiddleware.cs +++ b/Source/AuthProxy/TenancyMiddleware.cs @@ -52,6 +52,8 @@ public async Task InvokeAsync(HttpContext context) context.Request.Headers.Remove(Headers.PrincipalName); context.Request.Headers.Remove(Headers.PrincipalNameExtended); context.Request.Headers.Remove(Headers.TenantId); + context.Request.Headers.Remove(Headers.TokenClientId); + context.Request.Headers.Remove(Headers.TokenScope); // 2. Resolve tenant. if (!tenantResolver.TryResolve(context, out string tenantId)) From 6a53ba6d742cff3e68b98e433abb1bc275ae8ce3 Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 29 Sep 2026 06:56:43 +0200 Subject: [PATCH 02/24] Specify bearer routes end to end against a stub issuer Refs #143 --- ...r_token_is_presented_on_a_browser_route.cs | 50 +++++ .../when_a_valid_token_is_presented.cs | 54 +++++ ...y_headers_are_spoofed_on_a_bearer_route.cs | 50 +++++ .../when_no_token_is_presented.cs | 44 ++++ ...when_the_resource_metadata_is_requested.cs | 45 ++++ .../when_the_token_has_expired.cs | 37 ++++ .../when_the_token_has_no_tenant.cs | 36 ++++ .../when_the_token_is_for_another_audience.cs | 38 ++++ .../when_the_token_is_from_another_issuer.cs | 37 ++++ ...when_the_token_is_signed_by_another_key.cs | 37 ++++ .../when_the_token_is_unsigned.cs | 37 ++++ .../when_the_token_lacks_a_required_scope.cs | 40 ++++ .../given/BearerRouteHarness.cs | 160 ++++++++++++++ .../given/BearerRouteSpecCollection.cs | 15 ++ .../given/StubIssuer.cs | 199 ++++++++++++++++++ .../when_validating_bearer_routes.cs | 73 +++++++ 16 files changed, 952 insertions(+) create mode 100644 Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_bearer_token_is_presented_on_a_browser_route.cs create mode 100644 Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_valid_token_is_presented.cs create mode 100644 Source/AuthProxy.Security.Specs/for_BearerRoutes/when_identity_headers_are_spoofed_on_a_bearer_route.cs create mode 100644 Source/AuthProxy.Security.Specs/for_BearerRoutes/when_no_token_is_presented.cs create mode 100644 Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_resource_metadata_is_requested.cs create mode 100644 Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_has_expired.cs create mode 100644 Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_has_no_tenant.cs create mode 100644 Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_is_for_another_audience.cs create mode 100644 Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_is_from_another_issuer.cs create mode 100644 Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_is_signed_by_another_key.cs create mode 100644 Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_is_unsigned.cs create mode 100644 Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_lacks_a_required_scope.cs create mode 100644 Source/AuthProxy.Security.Specs/given/BearerRouteHarness.cs create mode 100644 Source/AuthProxy.Security.Specs/given/BearerRouteSpecCollection.cs create mode 100644 Source/AuthProxy.Security.Specs/given/StubIssuer.cs create mode 100644 Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteConfigurationValidator/when_validating_bearer_routes.cs diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_bearer_token_is_presented_on_a_browser_route.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_bearer_token_is_presented_on_a_browser_route.cs new file mode 100644 index 00000000..6b083b5e --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_bearer_token_is_presented_on_a_browser_route.cs @@ -0,0 +1,50 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// A token accepted on a bearer route is accepted there and nowhere else. On a browser-only surface such as +/// /api it is refused outright, even alongside a browser session, so an API token can never reach +/// what only a person signed in through the browser may use. +/// +/// The running proxy, origin and issuer. +[Collection(BearerRouteSpecCollection.Name)] +public class when_a_bearer_token_is_presented_on_a_browser_route(BearerRouteHarness harness) : IAsyncLifetime +{ + const string Path = "/api/items"; + + HttpResponseMessage? _response; + HttpResponseMessage? _withSession; + HttpResponseMessage? _sessionOnly; + bool _forwardedWithToken; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + + _response = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, Path, harness.Issuer.Token())); + + var withSession = BearerRouteHarness.WithToken(HttpMethod.Get, Path, harness.Issuer.Token()); + withSession.Headers.TryAddWithoutValidation(SecurityHarness.AuthenticatedUserHeader, "bearer-spec-user"); + _withSession = await client.SendAsync(withSession); + _forwardedWithToken = harness.Origin.ReceivedAnythingFor(Path); + + _sessionOnly = await client.SendAsync(SecurityHarness.Authenticated(HttpMethod.Get, Path, "bearer-spec-user")); + } + + public Task DisposeAsync() + { + _response?.Dispose(); + _withSession?.Dispose(); + _sessionOnly?.Dispose(); + return Task.CompletedTask; + } + + [Fact] public void should_refuse_it() => Assert.Equal(HttpStatusCode.Unauthorized, _response!.StatusCode); + [Fact] public void should_refuse_it_alongside_a_browser_session() => Assert.Equal(HttpStatusCode.Unauthorized, _withSession!.StatusCode); + [Fact] public void should_not_forward_it() => Assert.False(_forwardedWithToken); + [Fact] public void should_challenge_with_invalid_token() => Assert.Equal("Bearer error=\"invalid_token\"", _response!.Headers.WwwAuthenticate.ToString()); + [Fact] public void should_still_serve_the_browser_session() => Assert.Equal(HttpStatusCode.OK, _sessionOnly!.StatusCode); +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_valid_token_is_presented.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_valid_token_is_presented.cs new file mode 100644 index 00000000..ca71f604 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_valid_token_is_presented.cs @@ -0,0 +1,54 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.Identity; + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// A valid access token on a bearer route is the whole credential: the request is forwarded with the identity a +/// browser session of the same deployment would carry — the GitHub id and login, through the route's claim +/// mappings — the tenant the token names, and the scopes and client the token was issued with. The token itself, +/// and any cookie, stay at the edge. +/// +/// The running proxy, origin and issuer. +[Collection(BearerRouteSpecCollection.Name)] +public class when_a_valid_token_is_presented(BearerRouteHarness harness) : IAsyncLifetime +{ + const string Path = $"{BearerRouteHarness.RoutePrefix}/tools"; + + HttpResponseMessage? _response; + ForwardedRequest? _forwarded; + ClientPrincipal? _principal; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + + var request = BearerRouteHarness.WithToken(HttpMethod.Post, Path, harness.Issuer.Token()); + request.Headers.TryAddWithoutValidation("Cookie", ".cratis-session=some-session"); + _response = await client.SendAsync(request); + _forwarded = harness.Origin.LastRequestTo(Path); + + ClientPrincipal.TryFromBase64(_forwarded?.Value(Headers.Principal), out _principal); + } + + public Task DisposeAsync() + { + _response?.Dispose(); + return Task.CompletedTask; + } + + [Fact] public void should_answer_with_the_origin_response() => Assert.Equal(HttpStatusCode.OK, _response!.StatusCode); + [Fact] public void should_forward_the_request() => Assert.NotNull(_forwarded); + [Fact] public void should_forward_the_github_id_as_the_principal_id() => Assert.Equal(BearerRouteHarness.GitHubId, _forwarded!.Value(Headers.PrincipalId)); + [Fact] public void should_forward_the_github_login_as_the_principal_name() => Assert.Equal(BearerRouteHarness.GitHubLogin, _forwarded!.Value(Headers.PrincipalName)); + [Fact] public void should_forward_the_tenant_from_the_token() => Assert.Equal(BearerRouteHarness.TenantId, _forwarded!.Value(Headers.TenantId)); + [Fact] public void should_forward_the_granted_scopes() => Assert.Equal("direct:read direct:work", _forwarded!.Value(Headers.TokenScope)); + [Fact] public void should_forward_the_client_the_token_was_issued_to() => Assert.Equal(BearerRouteHarness.ClientId, _forwarded!.Value(Headers.TokenClientId)); + [Fact] public void should_name_the_route_identity_provider() => Assert.Equal("github", _principal!.IdentityProvider); + [Fact] public void should_keep_the_account_id_in_the_principal() => Assert.Contains(_principal!.Claims, _ => _.Type == "urn:cratis:bearer:subject" && _.Value == BearerRouteHarness.AccountId); + [Fact] public void should_not_forward_the_token() => Assert.False(_forwarded!.Has("Authorization")); + [Fact] public void should_not_forward_the_cookie() => Assert.False(_forwarded!.Has("Cookie")); +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_identity_headers_are_spoofed_on_a_bearer_route.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_identity_headers_are_spoofed_on_a_bearer_route.cs new file mode 100644 index 00000000..b98af235 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_identity_headers_are_spoofed_on_a_bearer_route.cs @@ -0,0 +1,50 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// A caller with a valid token cannot add to what the token says: every inbound copy of a header the backend +/// trusts is replaced by what AuthProxy vouches for, and one the token does not supply is not forwarded at all. +/// +/// The running proxy, origin and issuer. +[Collection(BearerRouteSpecCollection.Name)] +public class when_identity_headers_are_spoofed_on_a_bearer_route(BearerRouteHarness harness) : IAsyncLifetime +{ + const string Path = $"{BearerRouteHarness.RoutePrefix}/tools"; + + ForwardedRequest? _forwarded; + string _forgedPrincipal = string.Empty; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + + _forgedPrincipal = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes( + /*lang=json,strict*/ """{"userId":"attacker","userRoles":["Administrator"]}""")); + + var request = BearerRouteHarness.WithToken(HttpMethod.Get, Path, harness.Issuer.Token(without: "azp")); + request.Headers.TryAddWithoutValidation(Headers.Principal, _forgedPrincipal); + request.Headers.TryAddWithoutValidation(Headers.PrincipalId, "attacker"); + request.Headers.TryAddWithoutValidation(Headers.PrincipalName, "attacker"); + request.Headers.TryAddWithoutValidation(Headers.PrincipalNameExtended, "UTF-8''attacker"); + request.Headers.TryAddWithoutValidation(Headers.TenantId, "victim-tenant"); + request.Headers.TryAddWithoutValidation(Headers.TokenScope, "direct:admin"); + request.Headers.TryAddWithoutValidation(Headers.TokenClientId, "trusted-client"); + + using var response = await client.SendAsync(request); + _forwarded = harness.Origin.LastRequestTo(Path); + } + + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] public void should_forward_the_request() => Assert.NotNull(_forwarded); + [Fact] public void should_replace_the_principal() => Assert.NotEqual(_forgedPrincipal, _forwarded!.Value(Headers.Principal)); + [Fact] public void should_replace_the_principal_id() => Assert.Equal(BearerRouteHarness.GitHubId, _forwarded!.Value(Headers.PrincipalId)); + [Fact] public void should_replace_the_principal_name() => Assert.Equal(BearerRouteHarness.GitHubLogin, _forwarded!.Value(Headers.PrincipalName)); + [Fact] public void should_drop_the_extended_principal_name() => Assert.False(_forwarded!.Has(Headers.PrincipalNameExtended)); + [Fact] public void should_replace_the_tenant() => Assert.Equal(BearerRouteHarness.TenantId, _forwarded!.Value(Headers.TenantId)); + [Fact] public void should_replace_the_scopes() => Assert.Equal("direct:read direct:work", _forwarded!.Value(Headers.TokenScope)); + [Fact] public void should_take_the_client_from_the_token_alone() => Assert.Equal(BearerRouteHarness.ClientId, _forwarded!.Value(Headers.TokenClientId)); +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_no_token_is_presented.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_no_token_is_presented.cs new file mode 100644 index 00000000..5e10aa3e --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_no_token_is_presented.cs @@ -0,0 +1,44 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// A bearer route is called by programs. Without a token the answer is an RFC 6750 challenge naming the RFC 9728 +/// resource metadata an MCP client discovers its authorization server from — never a redirect to provider +/// selection, and never a browser session standing in for the missing token. +/// +/// The running proxy, origin and issuer. +[Collection(BearerRouteSpecCollection.Name)] +public class when_no_token_is_presented(BearerRouteHarness harness) : IAsyncLifetime +{ + const string Path = $"{BearerRouteHarness.RoutePrefix}/tools"; + + HttpResponseMessage? _anonymous; + HttpResponseMessage? _withSession; + bool _forwarded; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + + _anonymous = await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, Path)); + _withSession = await client.SendAsync(SecurityHarness.Authenticated(HttpMethod.Get, Path)); + _forwarded = harness.Origin.ReceivedAnythingFor(Path); + } + + public Task DisposeAsync() + { + _anonymous?.Dispose(); + _withSession?.Dispose(); + return Task.CompletedTask; + } + + [Fact] public void should_refuse_as_unauthorized() => Assert.Equal(HttpStatusCode.Unauthorized, _anonymous!.StatusCode); + [Fact] public void should_challenge_with_the_resource_metadata() => + Assert.Equal($"Bearer resource_metadata=\"{BearerRouteHarness.ResourceMetadataUrl}\"", _anonymous!.Headers.WwwAuthenticate.ToString()); + [Fact] public void should_not_let_a_browser_session_stand_in_for_the_token() => Assert.Equal(HttpStatusCode.Unauthorized, _withSession!.StatusCode); + [Fact] public void should_not_forward_anything() => Assert.False(_forwarded); + [Fact] public void should_not_be_cached() => Assert.True(_anonymous!.Headers.CacheControl?.NoStore); +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_resource_metadata_is_requested.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_resource_metadata_is_requested.cs new file mode 100644 index 00000000..30bd3096 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_resource_metadata_is_requested.cs @@ -0,0 +1,45 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// The protected-resource metadata document is how a client learns which authorization server to get a token +/// from, so it is served before the client has one: it passes through to the backend without authentication, +/// and without any identity, spoofed or otherwise. +/// +/// The running proxy, origin and issuer. +[Collection(BearerRouteSpecCollection.Name)] +public class when_the_resource_metadata_is_requested(BearerRouteHarness harness) : IAsyncLifetime +{ + HttpResponseMessage? _response; + HttpResponseMessage? _post; + ForwardedRequest? _forwarded; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + + var request = new HttpRequestMessage(HttpMethod.Get, BearerRouteHarness.ResourceMetadataPath); + request.Headers.TryAddWithoutValidation(Headers.PrincipalId, "attacker"); + request.Headers.TryAddWithoutValidation(Headers.TenantId, "victim-tenant"); + _response = await client.SendAsync(request); + _forwarded = harness.Origin.LastRequestTo(BearerRouteHarness.ResourceMetadataPath); + + _post = await client.SendAsync(new HttpRequestMessage(HttpMethod.Post, BearerRouteHarness.ResourceMetadataPath)); + } + + public Task DisposeAsync() + { + _response?.Dispose(); + _post?.Dispose(); + return Task.CompletedTask; + } + + [Fact] public void should_answer_with_the_origin_response() => Assert.Equal(HttpStatusCode.OK, _response!.StatusCode); + [Fact] public void should_forward_it() => Assert.NotNull(_forwarded); + [Fact] public void should_forward_no_principal() => Assert.False(_forwarded!.Has(Headers.PrincipalId)); + [Fact] public void should_forward_no_tenant() => Assert.False(_forwarded!.Has(Headers.TenantId)); + [Fact] public void should_only_serve_reads() => Assert.Equal(HttpStatusCode.MethodNotAllowed, _post!.StatusCode); +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_has_expired.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_has_expired.cs new file mode 100644 index 00000000..0b030a93 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_has_expired.cs @@ -0,0 +1,37 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// An expired token is refused; the route allows only a small clock skew, not the lifetime of a session. +/// +/// The running proxy, origin and issuer. +[Collection(BearerRouteSpecCollection.Name)] +public class when_the_token_has_expired(BearerRouteHarness harness) : IAsyncLifetime +{ + const string Path = $"{BearerRouteHarness.RoutePrefix}/tools"; + + HttpResponseMessage? _response; + bool _forwarded; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + + _response = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, Path, harness.Issuer.Token(expires: DateTime.UtcNow.AddMinutes(-10)))); + _forwarded = harness.Origin.ReceivedAnythingFor(Path); + } + + public Task DisposeAsync() + { + _response?.Dispose(); + return Task.CompletedTask; + } + + [Fact] public void should_refuse_it() => Assert.Equal(HttpStatusCode.Unauthorized, _response!.StatusCode); + [Fact] public void should_not_forward_anything() => Assert.False(_forwarded); + [Fact] public void should_challenge_with_invalid_token() => + Assert.Equal($"Bearer error=\"invalid_token\", resource_metadata=\"{BearerRouteHarness.ResourceMetadataUrl}\"", _response!.Headers.WwwAuthenticate.ToString()); +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_has_no_tenant.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_has_no_tenant.cs new file mode 100644 index 00000000..0ef4dbd3 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_has_no_tenant.cs @@ -0,0 +1,36 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// The tenant comes from the token and from nowhere else. A token without tid is refused rather than +/// resolved through the browser tenant selection, and never falls back to a default tenant. +/// +/// The running proxy, origin and issuer. +[Collection(BearerRouteSpecCollection.Name)] +public class when_the_token_has_no_tenant(BearerRouteHarness harness) : IAsyncLifetime +{ + const string Path = $"{BearerRouteHarness.RoutePrefix}/tools"; + + HttpResponseMessage? _response; + bool _forwarded; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + + _response = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, Path, harness.Issuer.Token(without: "tid"))); + _forwarded = harness.Origin.ReceivedAnythingFor(Path); + } + + public Task DisposeAsync() + { + _response?.Dispose(); + return Task.CompletedTask; + } + + [Fact] public void should_refuse_it() => Assert.Equal(HttpStatusCode.Forbidden, _response!.StatusCode); + [Fact] public void should_not_forward_anything() => Assert.False(_forwarded); +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_is_for_another_audience.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_is_for_another_audience.cs new file mode 100644 index 00000000..19d71f15 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_is_for_another_audience.cs @@ -0,0 +1,38 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// A token issued for another resource server is not a credential here, even from the same issuer: the audience +/// is what binds a token to the resource it was granted for. +/// +/// The running proxy, origin and issuer. +[Collection(BearerRouteSpecCollection.Name)] +public class when_the_token_is_for_another_audience(BearerRouteHarness harness) : IAsyncLifetime +{ + const string Path = $"{BearerRouteHarness.RoutePrefix}/tools"; + + HttpResponseMessage? _response; + bool _forwarded; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + + _response = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, Path, harness.Issuer.Token(audience: "studio-api"))); + _forwarded = harness.Origin.ReceivedAnythingFor(Path); + } + + public Task DisposeAsync() + { + _response?.Dispose(); + return Task.CompletedTask; + } + + [Fact] public void should_refuse_it() => Assert.Equal(HttpStatusCode.Unauthorized, _response!.StatusCode); + [Fact] public void should_not_forward_anything() => Assert.False(_forwarded); + [Fact] public void should_challenge_with_invalid_token() => + Assert.Equal($"Bearer error=\"invalid_token\", resource_metadata=\"{BearerRouteHarness.ResourceMetadataUrl}\"", _response!.Headers.WwwAuthenticate.ToString()); +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_is_from_another_issuer.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_is_from_another_issuer.cs new file mode 100644 index 00000000..f1590d3b --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_is_from_another_issuer.cs @@ -0,0 +1,37 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// A token naming an issuer the route does not accept is refused before any key is looked for. +/// +/// The running proxy, origin and issuer. +[Collection(BearerRouteSpecCollection.Name)] +public class when_the_token_is_from_another_issuer(BearerRouteHarness harness) : IAsyncLifetime +{ + const string Path = $"{BearerRouteHarness.RoutePrefix}/tools"; + + HttpResponseMessage? _response; + bool _forwarded; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + + _response = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, Path, harness.Issuer.Token(issuer: "https://attacker.example.test/"))); + _forwarded = harness.Origin.ReceivedAnythingFor(Path); + } + + public Task DisposeAsync() + { + _response?.Dispose(); + return Task.CompletedTask; + } + + [Fact] public void should_refuse_it() => Assert.Equal(HttpStatusCode.Unauthorized, _response!.StatusCode); + [Fact] public void should_not_forward_anything() => Assert.False(_forwarded); + [Fact] public void should_challenge_with_invalid_token() => + Assert.Equal($"Bearer error=\"invalid_token\", resource_metadata=\"{BearerRouteHarness.ResourceMetadataUrl}\"", _response!.Headers.WwwAuthenticate.ToString()); +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_is_signed_by_another_key.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_is_signed_by_another_key.cs new file mode 100644 index 00000000..ef2d661f --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_is_signed_by_another_key.cs @@ -0,0 +1,37 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// A token naming the issuer and its key id but signed by a key the issuer does not publish is a forgery. +/// +/// The running proxy, origin and issuer. +[Collection(BearerRouteSpecCollection.Name)] +public class when_the_token_is_signed_by_another_key(BearerRouteHarness harness) : IAsyncLifetime +{ + const string Path = $"{BearerRouteHarness.RoutePrefix}/tools"; + + HttpResponseMessage? _response; + bool _forwarded; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + + _response = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, Path, harness.Issuer.TokenSignedByAnotherKey())); + _forwarded = harness.Origin.ReceivedAnythingFor(Path); + } + + public Task DisposeAsync() + { + _response?.Dispose(); + return Task.CompletedTask; + } + + [Fact] public void should_refuse_it() => Assert.Equal(HttpStatusCode.Unauthorized, _response!.StatusCode); + [Fact] public void should_not_forward_anything() => Assert.False(_forwarded); + [Fact] public void should_challenge_with_invalid_token() => + Assert.Equal($"Bearer error=\"invalid_token\", resource_metadata=\"{BearerRouteHarness.ResourceMetadataUrl}\"", _response!.Headers.WwwAuthenticate.ToString()); +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_is_unsigned.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_is_unsigned.cs new file mode 100644 index 00000000..0a5d2bf8 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_is_unsigned.cs @@ -0,0 +1,37 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// An alg: none token carries every claim a valid one does and proves none of them. +/// +/// The running proxy, origin and issuer. +[Collection(BearerRouteSpecCollection.Name)] +public class when_the_token_is_unsigned(BearerRouteHarness harness) : IAsyncLifetime +{ + const string Path = $"{BearerRouteHarness.RoutePrefix}/tools"; + + HttpResponseMessage? _response; + bool _forwarded; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + + _response = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, Path, harness.Issuer.UnsignedToken())); + _forwarded = harness.Origin.ReceivedAnythingFor(Path); + } + + public Task DisposeAsync() + { + _response?.Dispose(); + return Task.CompletedTask; + } + + [Fact] public void should_refuse_it() => Assert.Equal(HttpStatusCode.Unauthorized, _response!.StatusCode); + [Fact] public void should_not_forward_anything() => Assert.False(_forwarded); + [Fact] public void should_challenge_with_invalid_token() => + Assert.Equal($"Bearer error=\"invalid_token\", resource_metadata=\"{BearerRouteHarness.ResourceMetadataUrl}\"", _response!.Headers.WwwAuthenticate.ToString()); +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_lacks_a_required_scope.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_lacks_a_required_scope.cs new file mode 100644 index 00000000..b7149a0c --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_lacks_a_required_scope.cs @@ -0,0 +1,40 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// A valid token without a scope the route requires is refused with insufficient_scope, naming the +/// scope to ask for, so a client can request a token that has it. +/// +/// The running proxy, origin and issuer. +[Collection(BearerRouteSpecCollection.Name)] +public class when_the_token_lacks_a_required_scope(BearerRouteHarness harness) : IAsyncLifetime +{ + const string Path = $"{BearerRouteHarness.RoutePrefix}/tools"; + + HttpResponseMessage? _response; + bool _forwarded; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + + _response = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, Path, harness.Issuer.Token(new Dictionary { ["scope"] = "direct:work" }))); + _forwarded = harness.Origin.ReceivedAnythingFor(Path); + } + + public Task DisposeAsync() + { + _response?.Dispose(); + return Task.CompletedTask; + } + + [Fact] public void should_refuse_it() => Assert.Equal(HttpStatusCode.Forbidden, _response!.StatusCode); + [Fact] public void should_not_forward_anything() => Assert.False(_forwarded); + [Fact] public void should_challenge_with_insufficient_scope() => + Assert.Equal( + $"Bearer error=\"insufficient_scope\", scope=\"{BearerRouteHarness.RequiredScope}\", resource_metadata=\"{BearerRouteHarness.ResourceMetadataUrl}\"", + _response!.Headers.WwwAuthenticate.ToString()); +} diff --git a/Source/AuthProxy.Security.Specs/given/BearerRouteHarness.cs b/Source/AuthProxy.Security.Specs/given/BearerRouteHarness.cs new file mode 100644 index 00000000..a1396f2a --- /dev/null +++ b/Source/AuthProxy.Security.Specs/given/BearerRouteHarness.cs @@ -0,0 +1,160 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Net.Http.Headers; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Cratis.AuthProxy.Security.given; + +/// +/// A running AuthProxy with one bearer route, in front of a recording origin, trusting a stub issuer. +/// +/// +/// Configured the way Direct would be: /mcp accepts access tokens for the direct-api +/// audience from the issuer, requires direct:read, and forwards the GitHub id and login the browser +/// sessions of the same deployment carry. Everything else — /api, the frontend — stays on the +/// browser-session model, where a caller becomes authenticated by sending +/// . +/// +public class BearerRouteHarness : WebApplicationFactory +{ + /// The bearer route's path prefix. + public const string RoutePrefix = "/mcp"; + + /// The audience the route accepts. + public const string Audience = "direct-api"; + + /// The scope the route requires. + public const string RequiredScope = "direct:read"; + + /// The protected-resource metadata URL the route names in its challenges. + public const string ResourceMetadataUrl = "https://direct.example.test/.well-known/oauth-protected-resource/mcp"; + + /// The path of . + public const string ResourceMetadataPath = "/.well-known/oauth-protected-resource/mcp"; + + /// The Cratis account id the tokens carry as sub. + public const string AccountId = "7d4f9a52-1c1e-4bb8-9d0b-0f5c7b3e2a10"; + + /// The tenant the tokens carry as tid. + public const string TenantId = "22222222-2222-2222-2222-222222222222"; + + /// The GitHub id the tokens carry. + public const string GitHubId = "134365"; + + /// The GitHub login the tokens carry. + public const string GitHubLogin = "einari"; + + /// The client the tokens were issued to. + public const string ClientId = "cratis-cli"; + + /// The tenant a browser session resolves to. + public const string SessionTenantId = "11111111-1111-1111-1111-111111111111"; + + readonly string _pagesPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + + /// + /// Initializes a new instance of the class. + /// + public BearerRouteHarness() + { + Directory.CreateDirectory(_pagesPath); + File.WriteAllText(Path.Combine(_pagesPath, "select-provider.html"), "Select Provider"); + File.WriteAllText(Path.Combine(_pagesPath, "forbidden.html"), "Forbidden"); + + Origin = RecordingBackend.Start().GetAwaiter().GetResult(); + Issuer = StubIssuer.Start().GetAwaiter().GetResult(); + } + + /// + /// Gets the origin AuthProxy forwards to, and the record of what reached it. + /// + public RecordingBackend Origin { get; } + + /// + /// Gets the authorization server whose tokens the bearer route accepts. + /// + public StubIssuer Issuer { get; } + + /// + /// Builds a request carrying a bearer token. + /// + /// The HTTP method. + /// The path and query to request. + /// The token. + /// The request. + public static HttpRequestMessage WithToken(HttpMethod method, string pathAndQuery, string token) + { + var request = new HttpRequestMessage(method, pathAndQuery); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + return request; + } + + /// + /// Creates a client that neither follows redirects nor carries cookies between requests. + /// + /// A configured . + public HttpClient CreateBearerClient() => + CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false, HandleCookies = false }); + + /// + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + if (!disposing) + { + return; + } + + Origin.DisposeAsync().AsTask().GetAwaiter().GetResult(); + Issuer.DisposeAsync().AsTask().GetAwaiter().GetResult(); + + if (Directory.Exists(_pagesPath)) + { + Directory.Delete(_pagesPath, recursive: true); + } + } + + /// + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + const string route = $"{C.AuthProxy.SectionKey}:Services:app:BearerRoutes:0"; + + builder + .UseEnvironment("Production") + .ConfigureAppConfiguration((_, config) => config.AddInMemoryCollection(new Dictionary + { + [$"{C.AuthProxy.SectionKey}:Services:app:Backend:BaseUrl"] = Origin.BaseUrl, + [$"{C.AuthProxy.SectionKey}:Services:app:Frontend:BaseUrl"] = Origin.BaseUrl, + + [$"{route}:PathPrefix"] = RoutePrefix, + [$"{route}:Issuers:0:Issuer"] = Issuer.Issuer, + [$"{route}:Audiences:0"] = Audience, + [$"{route}:RequiredScopes:0"] = RequiredScope, + [$"{route}:ResourceMetadataUrl"] = ResourceMetadataUrl, + [$"{route}:IdentityProvider"] = "github", + [$"{route}:ClaimMappings:sub"] = "github_id", + [$"{route}:ClaimMappings:preferred_username"] = "github_login", + + [$"{C.AuthProxy.SectionKey}:PagesPath"] = _pagesPath, + + [$"{C.AuthProxy.SectionKey}:TenantResolutions:0:Strategy"] = nameof(C.TenantSourceIdentifierResolverType.Specified), + [$"{C.AuthProxy.SectionKey}:TenantResolutions:0:Options:TenantId"] = SessionTenantId, + + [$"{C.Authentication.SectionKey}:OidcProviders:0:Name"] = "Provider One", + [$"{C.Authentication.SectionKey}:OidcProviders:0:Authority"] = "https://login.example.test/one", + [$"{C.Authentication.SectionKey}:OidcProviders:0:ClientId"] = "client-one", + })) + .ConfigureTestServices(services => services + .AddAuthentication(HeaderAuthenticationHandler.Scheme) + .AddScheme( + HeaderAuthenticationHandler.Scheme, + _ => { })); + } +} diff --git a/Source/AuthProxy.Security.Specs/given/BearerRouteSpecCollection.cs b/Source/AuthProxy.Security.Specs/given/BearerRouteSpecCollection.cs new file mode 100644 index 00000000..dc7c9251 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/given/BearerRouteSpecCollection.cs @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.given; + +/// +/// Runs every bearer-route spec against one shared proxy, origin and issuer, in sequence, so no spec reads +/// another's traffic from the recording origin. +/// +[CollectionDefinition(Name, DisableParallelization = true)] +public class BearerRouteSpecCollection : ICollectionFixture +{ + /// The collection name every bearer-route spec joins. + public const string Name = "BearerRoutes"; +} diff --git a/Source/AuthProxy.Security.Specs/given/StubIssuer.cs b/Source/AuthProxy.Security.Specs/given/StubIssuer.cs new file mode 100644 index 00000000..0d3a3e71 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/given/StubIssuer.cs @@ -0,0 +1,199 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Security.Cryptography; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +namespace Cratis.AuthProxy.Security.given; + +/// +/// A stand-in authorization server on loopback: publishes RFC 8414 metadata and a JWKS for a local RSA key, +/// and signs access tokens shaped like the ones Cratis Identity issues. +/// +/// +/// It listens on a real socket because AuthProxy reads the metadata and keys over HTTP exactly as it would from +/// a deployed issuer; nothing about discovery is substituted. Plain HTTP is accepted only because the issuer is +/// on a loopback host. +/// +public sealed class StubIssuer : IAsyncDisposable +{ + /// The key id the published key carries. + public const string KeyId = "stub-issuer-key"; + + readonly WebApplication _app; + readonly RSA _rsa; + + StubIssuer(WebApplication app, RSA rsa, string issuer) + { + _app = app; + _rsa = rsa; + Issuer = issuer; + } + + /// + /// Gets the issuer identifier, with the trailing slash Cratis Identity uses. + /// + public string Issuer { get; } + + /// + /// Starts a new stub issuer. + /// + /// The started issuer. + public static async Task Start() + { + var rsa = RSA.Create(2048); + var builder = WebApplication.CreateSlimBuilder(); + builder.Logging.ClearProviders(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + var app = builder.Build(); + string? issuer = null; + + app.MapGet("/.well-known/oauth-authorization-server", () => Results.Json(new Dictionary + { + ["issuer"] = issuer!, + ["jwks_uri"] = $"{issuer}.well-known/jwks", + ["authorization_endpoint"] = $"{issuer}connect/authorize", + ["token_endpoint"] = $"{issuer}connect/token", + })); + + app.MapGet("/.well-known/jwks", () => + { + var parameters = rsa.ExportParameters(includePrivateParameters: false); + return Results.Json(new + { + keys = new[] + { + new Dictionary + { + ["kty"] = "RSA", + ["use"] = "sig", + ["alg"] = SecurityAlgorithms.RsaSha256, + ["kid"] = KeyId, + ["n"] = Base64UrlEncoder.Encode(parameters.Modulus), + ["e"] = Base64UrlEncoder.Encode(parameters.Exponent), + }, + }, + }); + }); + + await app.StartAsync(); + + var address = app.Services.GetRequiredService().Features + .Get()! + .Addresses + .First(); + issuer = $"{address.TrimEnd('/')}/"; + + return new StubIssuer(app, rsa, issuer); + } + + /// + /// Signs an access token with the published key. + /// + /// The claims, which replace the defaults of they name. + /// The audience. + /// When the token expires. Defaults to fifteen minutes from now. + /// The issuer to name. Defaults to this issuer. + /// Default claims to leave out. + /// The signed token. + public string Token( + IDictionary? claims = null, + string audience = BearerRouteHarness.Audience, + DateTime? expires = null, + string? issuer = null, + params string[] without) => + Sign(new RsaSecurityKey(_rsa) { KeyId = KeyId }, claims, audience, expires, issuer, without); + + /// + /// Signs an access token with a key this issuer does not publish, but names the published key id. + /// + /// The token. + public string TokenSignedByAnotherKey() + { + using var other = RSA.Create(2048); + return Sign(new RsaSecurityKey(other) { KeyId = KeyId }, null, BearerRouteHarness.Audience, null, null, []); + } + + /// + /// Builds an unsigned token (alg: none) carrying otherwise valid claims. + /// + /// The token. + public string UnsignedToken() + { + var now = DateTimeOffset.UtcNow; + var header = Base64UrlEncoder.Encode(/*lang=json,strict*/ """{"alg":"none","typ":"at+jwt"}"""); + var payload = new Dictionary(DefaultClaims()) + { + ["iss"] = Issuer, + ["aud"] = BearerRouteHarness.Audience, + ["iat"] = now.ToUnixTimeSeconds(), + ["nbf"] = now.ToUnixTimeSeconds(), + ["exp"] = now.AddMinutes(15).ToUnixTimeSeconds(), + }; + + return $"{header}.{Base64UrlEncoder.Encode(System.Text.Json.JsonSerializer.Serialize(payload))}."; + } + + /// + /// Gets the claims a Cratis Identity access token carries. + /// + /// The claims. + public static Dictionary DefaultClaims() => new() + { + ["sub"] = BearerRouteHarness.AccountId, + ["tid"] = BearerRouteHarness.TenantId, + ["scope"] = "direct:read direct:work", + ["azp"] = BearerRouteHarness.ClientId, + ["client_id"] = BearerRouteHarness.ClientId, + ["github_id"] = BearerRouteHarness.GitHubId, + ["github_login"] = BearerRouteHarness.GitHubLogin, + ["name"] = "Einar Ingebrigtsen", + ["preferred_username"] = BearerRouteHarness.GitHubLogin, + ["jti"] = Guid.NewGuid().ToString("N"), + }; + + /// + public async ValueTask DisposeAsync() + { + await _app.StopAsync(); + await _app.DisposeAsync(); + _rsa.Dispose(); + } + + string Sign(SecurityKey key, IDictionary? claims, string audience, DateTime? expires, string? issuer, string[] without) + { + var merged = DefaultClaims(); + foreach (var type in without) + { + merged.Remove(type); + } + + foreach (var (type, value) in claims ?? new Dictionary()) + { + merged[type] = value; + } + + var expiry = expires ?? DateTime.UtcNow.AddMinutes(15); + var issuedAt = expiry.AddMinutes(-15); + + return new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor + { + Issuer = issuer ?? Issuer, + Audience = audience, + Claims = merged, + IssuedAt = issuedAt, + NotBefore = issuedAt, + Expires = expiry, + TokenType = "at+jwt", + SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.RsaSha256), + }); + } +} diff --git a/Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteConfigurationValidator/when_validating_bearer_routes.cs b/Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteConfigurationValidator/when_validating_bearer_routes.cs new file mode 100644 index 00000000..dd801b8d --- /dev/null +++ b/Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteConfigurationValidator/when_validating_bearer_routes.cs @@ -0,0 +1,73 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.BearerRoutes.for_BearerRouteConfigurationValidator; + +/// +/// A bearer route AuthProxy cannot enforce as written is left on the browser-session model, which is closed but +/// invisible: the operator believes an API is served and every client is sent to sign in. Each such mistake is +/// refused at startup instead, and a deployment without bearer routes is not affected at all. +/// +public class when_validating_bearer_routes : Specification +{ + ValidateOptionsResult _valid; + ValidateOptionsResult _withoutRoutes; + ValidateOptionsResult _withoutAudience; + ValidateOptionsResult _withPlainHttpIssuer; + ValidateOptionsResult _overlappingAnonymousPath; + ValidateOptionsResult _withoutBackend; + ValidateOptionsResult _withLargeClockSkew; + ValidateOptionsResult _mappingIntoReservedClaim; + ValidateOptionsResult _withQuotedScope; + + void Because() + { + var validator = new BearerRouteConfigurationValidator(); + + _valid = validator.Validate(null, Configuration(_ => { })); + _withoutRoutes = validator.Validate(null, Configuration(_ => _.PathPrefix = string.Empty, declareRoute: false)); + _withoutAudience = validator.Validate(null, Configuration(_ => _.Audiences = [])); + _withPlainHttpIssuer = validator.Validate(null, Configuration(_ => _.Issuers = [new C.BearerIssuer { Issuer = "http://auth.example.test/" }])); + _overlappingAnonymousPath = validator.Validate(null, Configuration(_ => _.PathPrefix = "/public/mcp")); + _withoutBackend = validator.Validate(null, Configuration(_ => { }, backend: false)); + _withLargeClockSkew = validator.Validate(null, Configuration(_ => _.ClockSkew = TimeSpan.FromHours(1))); + _mappingIntoReservedClaim = validator.Validate(null, Configuration(_ => _.ClaimMappings = new Dictionary { ["urn:cratis:bearer:scope"] = "scope" })); + _withQuotedScope = validator.Validate(null, Configuration(_ => _.RequiredScopes = ["direct:\"read"])); + } + + [Fact] void should_accept_a_complete_route() => _valid.Succeeded.ShouldBeTrue(); + [Fact] void should_leave_a_deployment_without_bearer_routes_alone() => _withoutRoutes.Succeeded.ShouldBeTrue(); + [Fact] void should_refuse_a_route_without_an_audience() => _withoutAudience.Failed.ShouldBeTrue(); + [Fact] void should_refuse_a_plain_http_issuer_off_loopback() => _withPlainHttpIssuer.Failed.ShouldBeTrue(); + [Fact] void should_refuse_a_route_overlapping_an_anonymous_path() => _overlappingAnonymousPath.Failed.ShouldBeTrue(); + [Fact] void should_refuse_a_route_on_a_service_without_a_backend() => _withoutBackend.Failed.ShouldBeTrue(); + [Fact] void should_refuse_a_clock_skew_beyond_the_maximum() => _withLargeClockSkew.Failed.ShouldBeTrue(); + [Fact] void should_refuse_a_mapping_into_a_claim_authproxy_owns() => _mappingIntoReservedClaim.Failed.ShouldBeTrue(); + [Fact] void should_refuse_a_scope_that_is_not_a_scope_token() => _withQuotedScope.Failed.ShouldBeTrue(); + + static C.AuthProxy Configuration(Action adjust, bool backend = true, bool declareRoute = true) + { + var route = new C.BearerRoute + { + PathPrefix = "/mcp", + Issuers = [new C.BearerIssuer { Issuer = "https://auth.example.test/" }], + Audiences = ["direct-api"], + RequiredScopes = ["direct:read"], + ResourceMetadataUrl = "https://direct.example.test/.well-known/oauth-protected-resource/mcp", + }; + adjust(route); + + return new() + { + Services = new Dictionary + { + ["main"] = new() + { + Backend = backend ? new C.ServiceEndpoint { BaseUrl = "https://backend.example.test" } : null, + AnonymousPaths = ["/public"], + BearerRoutes = declareRoute ? [route] : [], + }, + }, + }; + } +} From 19777ec1940507a76517853c8811c3cdc68c36fe Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 29 Sep 2026 06:57:39 +0200 Subject: [PATCH 03/24] Document bearer routes for external access tokens Refs #143 --- Documentation/configuration/authentication.md | 68 ++++++++++++++ Documentation/configuration/index.md | 2 +- Documentation/configuration/services.md | 94 +++++++++++++++++++ Documentation/configuration/tenancy.md | 16 ++++ README.md | 2 +- 5 files changed, 180 insertions(+), 2 deletions(-) diff --git a/Documentation/configuration/authentication.md b/Documentation/configuration/authentication.md index c9ea5970..5926d297 100644 --- a/Documentation/configuration/authentication.md +++ b/Documentation/configuration/authentication.md @@ -544,6 +544,74 @@ For machine-to-machine calls, configure a JWT Bearer handler: } ``` +The handler applies to every path, but a token from an issuer configured on a +[bearer route](#bearer-routes-access-tokens-from-an-authorization-server) never reaches it: such a token is refused +on every path outside its bearer routes. + +--- + +## Bearer routes: access tokens from an authorization server + +A service can declare [bearer routes](services.md#bearer-routes): path prefixes such as `/mcp` or `/v1` that are +authenticated by an access token from an external authorization server — for example Cratis Identity — rather +than by a browser session. AuthProxy remains an edge and relying party: it validates the token and forwards the +request with the same trusted headers a browser session gets. + +### Validation + +A token is accepted only when all of these hold: + +- It is a signed JWT (JWS); encrypted tokens and `alg: none` are refused. The algorithm is `RS256` or `ES256`. +- Its `iss` is exactly one of the route's issuers. +- Its signature verifies against a key in the issuer's JWKS. The metadata document (RFC 8414, or OpenID Connect + discovery) must name exactly the configured issuer. Metadata and keys are cached and refreshed periodically, + and a token naming an unknown key triggers a rate-limited refresh, so key rotation needs no restart. If the + keys cannot be retrieved after the last good copy, tokens are refused. +- Its `typ` header is an access-token type (`at+jwt` by default). +- Its `aud` names one of the route's audiences. +- It has an `exp`, and it is within its lifetime allowing the route's clock skew (30 seconds by default). +- It carries every scope the route requires, a single `sub`, and a single usable tenant. + +### Responses + +| Situation | Response | +|-----------|----------| +| No bearer token (a browser session does not count) | `401`, `WWW-Authenticate: Bearer resource_metadata=""` | +| Token invalid, expired, wrongly signed, from another issuer or for another audience | `401`, `WWW-Authenticate: Bearer error="invalid_token", resource_metadata="…"` | +| Token lacks a required scope | `403`, `WWW-Authenticate: Bearer error="insufficient_scope", scope="", resource_metadata="…"` | +| Token carries no tenant, or the tenant fails verification | `403` | +| The issuer's metadata or keys cannot be retrieved | `503` with `Retry-After` | +| Bearer-route token on any other path | `401`, `WWW-Authenticate: Bearer error="invalid_token"` | + +`resource_metadata` is omitted when the route declares no `ResourceMetadataUrl`. Every refusal carries +`Cache-Control: no-store` and an empty body; the reason is logged, not returned. + +The path of `ResourceMetadataUrl` (for example `/.well-known/oauth-protected-resource/mcp`) is forwarded to the +service backend for `GET` and `HEAD` without authentication and without any identity headers, so a client can +discover the authorization server before it has a token. The backend serves the document. + +### Forwarded headers + +A request accepted on a bearer route is forwarded to the service backend with: + +| Header | Value | +|--------|-------| +| `x-ms-client-principal` | The principal: `identityProvider` from the route's `IdentityProvider`, `userId` from `sub`, `userDetails` from `preferred_username` or `name`, roles `anonymous` and `authenticated`, and the token's claims — after the route's `ClaimMappings` have applied. | +| `x-ms-client-principal-id`, `x-ms-client-principal-name` | As for a browser session, from the same principal. | +| `Tenant-ID` | The tenant from the token. | +| `x-cratis-token-scope` | The granted scopes, space-separated. | +| `x-cratis-token-client-id` | The client the token was issued to, from `azp` or else `client_id`. This is the client's *asserted* identity: a public client cannot prove which program is using it. | + +The principal also carries claims AuthProxy writes itself: `urn:cratis:bearer:issuer`, `urn:cratis:bearer:subject` +(the token's own `sub`, kept when a mapping rewrites `sub`), `urn:cratis:bearer:client-id` and one +`urn:cratis:bearer:scope` per scope. A token cannot supply any `urn:cratis:bearer:` or `urn:cratis:identity:` +claim, and role claims in the token are not forwarded: a token never grants a role. + +Every inbound copy of these headers is removed on every route, bearer or not. The `Cookie` header is not +forwarded on a bearer route, and neither is `Authorization` unless the route sets `ForwardAuthorizationHeader`. +Identity enrichment (`/.cratis/me`) is not called on a bearer route; the backend enforces tenant membership and +what the user may do with the scopes the client was granted. + --- ## Back-channel client credentials diff --git a/Documentation/configuration/index.md b/Documentation/configuration/index.md index 4a477fe1..01c70719 100644 --- a/Documentation/configuration/index.md +++ b/Documentation/configuration/index.md @@ -26,7 +26,7 @@ Cratis AuthProxy is configured entirely through the `Cratis:AuthProxy` section o | Topic | Description | |-------|-------------| -| [Authentication](authentication.md) | OIDC providers, OAuth 2.0 providers such as GitHub, and JWT Bearer configuration. | +| [Authentication](authentication.md) | OIDC providers, OAuth 2.0 providers such as GitHub, JWT Bearer configuration, and bearer routes for access tokens from an external authorization server. | | [Authorization](authorization.md) | Requiring a claim — a role, a group, a GitHub organization or team — before any request is forwarded. | | [Admission](admission.md) | Answering nothing at all until a caller presents a capability your own verifier admits, for a deployment whose existence is not meant to be discoverable. | | [Tenancy](tenancy.md) | How the auth proxy resolves the current tenant from each request, and how to verify tenant existence. | diff --git a/Documentation/configuration/services.md b/Documentation/configuration/services.md index dd2a4fa3..acc1cab2 100644 --- a/Documentation/configuration/services.md +++ b/Documentation/configuration/services.md @@ -42,6 +42,7 @@ Services are configured under `Cratis:AuthProxy:Services`, keyed by a friendly n | `IdentityVerificationTimeout` | `TimeSpan` | `00:00:10` under `Required`, unbounded under `BestEffort` | How long to wait for the answer. Zero or negative leaves the wait unbounded. See [Two settings, two questions](#two-settings-two-questions). | | `AnonymousPaths` | `string[]` | `[]` | Path prefixes on this service served to unauthenticated callers. See [Anonymous paths](#anonymous-paths). | | `ClientCredentials` | `ServiceClientCredentialsConfig` | `null` | Enables back-channel client-credentials verification and token minting for this service. | +| `BearerRoutes` | `BearerRouteConfig[]` | `[]` | Path prefixes authenticated by an access token from an external authorization server instead of a browser session. See [Bearer routes](#bearer-routes). | ### ServiceEndpointConfig properties @@ -396,3 +397,96 @@ The verification endpoint's response can optionally include a `tenant` property, carries on the issued tokens and can resolve into the `Tenant-ID` header on proxied requests. See [Back-channel client credentials](authentication.md#back-channel-client-credentials) for the full token, tenant-resolution, and refresh-token flow. + +--- + +## Bearer routes + +A bearer route is a path prefix on a service that is called by programs — a CLI, an MCP client, another +service — with an access token issued by an external authorization server such as Cratis Identity. AuthProxy +stays a relying party: it validates the token and forwards the request, and it never issues these tokens. + +```json +{ + "Cratis": { + "AuthProxy": { + "Services": { + "direct": { + "Backend": { "BaseUrl": "http://direct:8080/" }, + "Frontend": { "BaseUrl": "http://direct:8080/" }, + "BearerRoutes": [ + { + "PathPrefix": "/mcp", + "Issuers": [ { "Issuer": "https://auth.example/" } ], + "Audiences": [ "direct-api" ], + "RequiredScopes": [ "direct:read" ], + "ResourceMetadataUrl": "https://cratis.direct/.well-known/oauth-protected-resource/mcp", + "IdentityProvider": "github", + "ClaimMappings": { + "sub": "github_id", + "preferred_username": "github_login" + } + }, + { + "PathPrefix": "/v1", + "Issuers": [ { "Issuer": "https://auth.example/" } ], + "Audiences": [ "direct-api" ], + "ResourceMetadataUrl": "https://cratis.direct/.well-known/oauth-protected-resource/v1", + "IdentityProvider": "github", + "ClaimMappings": { + "sub": "github_id", + "preferred_username": "github_login" + } + } + ] + } + } + } + } +} +``` + +This is Direct's shape: Cratis Identity issues tokens with `aud=direct-api` for both `https://cratis.direct/mcp` +and `https://cratis.direct/v1`. The claim mappings make a token-authenticated request carry the same +`x-ms-client-principal-id` (the numeric GitHub id) and `x-ms-client-principal-name` (the GitHub login) as a +browser session signed in through Direct's GitHub provider, so Direct resolves the same user either way. The +Cratis account id from the token's `sub` is still forwarded, as the `urn:cratis:bearer:subject` claim. + +### BearerRouteConfig properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `PathPrefix` | `string` | — | The prefix this route covers, for example `/mcp`. Matched case-insensitively on segment boundaries, with the same rules as [anonymous paths](#what-a-valid-entry-looks-like). The longest matching prefix wins. | +| `Issuers` | `BearerIssuerConfig[]` | — | The authorization servers whose tokens are accepted. At least one. | +| `Audiences` | `string[]` | — | The token's `aud` must name at least one of these. At least one. | +| `RequiredScopes` | `string[]` | `[]` | Scopes the token must carry, every one of them. | +| `ResourceMetadataUrl` | `string` | `null` | Absolute URL of the RFC 9728 protected-resource metadata document. Named in every challenge; its path is forwarded to the backend without authentication. | +| `TenantClaimType` | `string` | `tid` | The token claim the tenant is read from. See [Tenancy](tenancy.md#bearer-routes). | +| `ClaimMappings` | `map` | `{}` | Forwarded claim type → token claim it is read from. A mapped source claim missing from the token refuses the token. Claim types containing `:` cannot be keys, because `:` separates configuration sections. | +| `IdentityProvider` | `string` | `bearer` | The identity provider named in the forwarded principal. | +| `ForwardAuthorizationHeader` | `bool` | `false` | Whether the backend also receives the `Authorization` header. | +| `ClockSkew` | `TimeSpan` | `00:00:30` | Allowed clock skew for `exp` and `nbf`. At most `00:05:00`. | + +### BearerIssuerConfig properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `Issuer` | `string` | — | The issuer identifier. The token's `iss` and the issuer metadata's `issuer` must both be exactly this value. HTTPS, or plain HTTP on a loopback host for development. | +| `MetadataAddress` | `string` | RFC 8414 address | The metadata document. Defaults to `/.well-known/oauth-authorization-server` inserted between the issuer's host and path. An OpenID Connect discovery document works too. | +| `TokenTypes` | `string[]` | `at+jwt`, `application/at+jwt` | Accepted JWT `typ` header values, so an ID token cannot be presented as an access token. | + +### What a bearer route changes + +- A request on a bearer route is answered before static files, authentication, provider selection, tenant + selection and identity enrichment — none of them apply. It is forwarded straight to the service **backend**, + whichever of the service's endpoints would otherwise serve that path, and without a `Service-ID` header. +- The session cookie is never read, and the `Cookie` header is not forwarded. +- Every refusal is an API-style `401`, `403` or `503` — never a redirect or a page. See + [Bearer routes](authentication.md#bearer-routes-access-tokens-from-an-authorization-server). +- A token from a bearer-route issuer is refused with `401` on every path that is **not** one of the issuer's + bearer routes, even where another bearer scheme (the [JWT Bearer](authentication.md#jwt-bearer-api) handler) + would accept it. A browser-only surface such as `/api` stays browser-only. +- A route that overlaps an anonymous path, repeats another route's prefix, names no issuer or audience, or + belongs to a service without a backend is refused at startup. +- With no bearer route configured, nothing changes. + diff --git a/Documentation/configuration/tenancy.md b/Documentation/configuration/tenancy.md index d4d430ed..9dd95f2e 100644 --- a/Documentation/configuration/tenancy.md +++ b/Documentation/configuration/tenancy.md @@ -247,3 +247,19 @@ If a strategy provides a strategy-specific verification URL template (for exampl When verification fails, AuthProxy serves `tenant-not-found.html`. See [Error pages](error-pages.md) to override this page. + +--- + +## Bearer routes + +On a [bearer route](services.md#bearer-routes) the tenant comes from the access token alone: the claim named by +`TenantClaimType` (`tid` by default). None of the resolution strategies above run, the tenant-selection cookie is +never read and the tenant-selection page is never served. + +- A token that does not carry exactly one tenant, or carries one that is not made of letters, digits, `-`, `.`, + `_` or `~` (at most 256 characters), is refused with `403`. There is no fallback to a default tenant. +- When [tenant verification](#tenant-verification) is configured, the tenant is verified the same way; a tenant + that fails verification is refused with `403` rather than the tenant-not-found page. +- The tenant is forwarded as `Tenant-ID`. It records which tenant the user chose when the token was granted; the + backend still decides whether the user is a member of it. + diff --git a/README.md b/README.md index eb91e45a..09de0d54 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Without a gateway, every service re-implements the same boilerplate: an OIDC han ## What it handles -- **Authentication** — OpenID Connect (single or multiple providers with a built-in provider-selection page; Microsoft Entra ID, Google, GitHub, Apple, or any custom OIDC provider), OAuth 2.0, JWT Bearer for machine-to-machine calls, and back-channel client credentials exchanged at `/.cratis/token`. +- **Authentication** — OpenID Connect (single or multiple providers with a built-in provider-selection page; Microsoft Entra ID, Google, GitHub, Apple, or any custom OIDC provider), OAuth 2.0, JWT Bearer for machine-to-machine calls, back-channel client credentials exchanged at `/.cratis/token`, and per-service bearer routes that accept access tokens from an external authorization server such as Cratis Identity. - **Authorization** — require a claim (a role, a group, a GitHub organization or team) before any request is forwarded. - **Multi-tenancy** — resolve the current tenant per request from the host, a subdomain, a claim, the route, a selection page, or a fixed value, with optional remote tenant verification. Downstream services receive the tenant on a `Tenant-ID` header. - **Identity enrichment** — calls a `/.cratis/me` endpoint on your service and attaches the enriched identity to forwarded requests as a trusted header. From eb1dd5532e706c7a9f4c8f08b137cb16ab91210b Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 29 Sep 2026 07:04:28 +0200 Subject: [PATCH 04/24] Register bearer routes with the ingress pipeline and keep their claims off sessions UseIngress always places the bearer-route gate, so its services are registered with AddIngressConfiguration like the admission gate's, and hosts composing the pipeline themselves keep starting. A browser session's claims in the urn:cratis:bearer: namespace are no longer forwarded, so only a bearer route can say a request was authenticated by an access token. Refs #143 --- ...n_a_session_carries_bearer_route_claims.cs | 36 +++++++++++++++++++ .../BearerRouteServiceCollectionExtensions.cs | 4 +-- .../BearerRoutes/BearerRouteTable.cs | 10 +++--- .../Identity/ClientPrincipalExtensions.cs | 5 ++- Source/AuthProxy/IngressExtensions.cs | 4 +++ Source/AuthProxy/Program.cs | 2 -- 6 files changed, 51 insertions(+), 10 deletions(-) create mode 100644 Source/AuthProxy.Specs/Identity/for_ClientPrincipalExtensions/when_a_session_carries_bearer_route_claims.cs diff --git a/Source/AuthProxy.Specs/Identity/for_ClientPrincipalExtensions/when_a_session_carries_bearer_route_claims.cs b/Source/AuthProxy.Specs/Identity/for_ClientPrincipalExtensions/when_a_session_carries_bearer_route_claims.cs new file mode 100644 index 00000000..dd2fc0e3 --- /dev/null +++ b/Source/AuthProxy.Specs/Identity/for_ClientPrincipalExtensions/when_a_session_carries_bearer_route_claims.cs @@ -0,0 +1,36 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.BearerRoutes; + +namespace Cratis.AuthProxy.Identity.for_ClientPrincipalExtensions; + +/// +/// The urn:cratis:bearer: claims tell a backend a request was authenticated on a bearer route, with +/// the scopes and client the token was validated for. A browser session's identity provider does not get to say +/// that, so those claims never reach the forwarded principal of a session. +/// +public class when_a_session_carries_bearer_route_claims : Specification +{ + DefaultHttpContext _context; + ClientPrincipal? _result; + + void Establish() + { + _context = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity( + [ + new Claim("sub", "subject-id"), + new Claim(BearerRouteClaims.Scope, "direct:admin"), + new Claim(BearerRouteClaims.ClientId, "trusted-client"), + ], + "cookie")), + }; + } + + void Because() => _result = _context.BuildClientPrincipal(); + + [Fact] void should_keep_the_session_claims() => Assert.Contains(_result!.Claims, _ => _.Type == "sub"); + [Fact] void should_not_forward_bearer_route_claims() => Assert.DoesNotContain(_result!.Claims, _ => BearerRouteClaims.IsReserved(_.Type)); +} diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteServiceCollectionExtensions.cs b/Source/AuthProxy/BearerRoutes/BearerRouteServiceCollectionExtensions.cs index 3e3fc316..0ea36994 100644 --- a/Source/AuthProxy/BearerRoutes/BearerRouteServiceCollectionExtensions.cs +++ b/Source/AuthProxy/BearerRoutes/BearerRouteServiceCollectionExtensions.cs @@ -23,8 +23,8 @@ public static class BearerRouteServiceCollectionExtensions /// The to configure. /// The same for chaining. /// - /// Must follow the reverse-proxy registration, whose forwarder and transform builder the bearer-route - /// forwarder uses. + /// The bearer-route forwarder uses the reverse proxy's forwarder, client factory and transform builder, which + /// the reverse-proxy registration provides; they are resolved when the pipeline is built, not here. /// public static WebApplicationBuilder AddBearerRoutes(this WebApplicationBuilder builder) { diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteTable.cs b/Source/AuthProxy/BearerRoutes/BearerRouteTable.cs index cc74460c..b42801ef 100644 --- a/Source/AuthProxy/BearerRoutes/BearerRouteTable.cs +++ b/Source/AuthProxy/BearerRoutes/BearerRouteTable.cs @@ -10,11 +10,11 @@ namespace Cratis.AuthProxy.BearerRoutes; /// Resolves the bearer routes declared in . /// /// -/// The authentication scheme selector, the bearer gate, the tenancy and identity middlewares, the anonymous-path -/// matcher and the reverse-proxy route table all have to agree on which paths are bearer routes, so they all -/// resolve through here. A route that cannot be resolved — an unusable prefix or metadata URL, an issuer that is -/// not an absolute HTTPS URI, a service without a backend — is left out, which leaves its path on the -/// browser-session model; refuses such a configuration at startup. +/// The bearer gate and the configuration validator have to agree on which paths are bearer routes and which +/// issuers they accept, so both resolve through here. A route that cannot be resolved — an unusable prefix or +/// metadata URL, an issuer that is not an absolute HTTPS URI, a service without a backend — is left out, which +/// leaves its path on the browser-session model; refuses such a +/// configuration at startup. /// public static class BearerRouteTable { diff --git a/Source/AuthProxy/Identity/ClientPrincipalExtensions.cs b/Source/AuthProxy/Identity/ClientPrincipalExtensions.cs index 0d7716b6..3527d356 100644 --- a/Source/AuthProxy/Identity/ClientPrincipalExtensions.cs +++ b/Source/AuthProxy/Identity/ClientPrincipalExtensions.cs @@ -3,6 +3,7 @@ using System.Security.Claims; using Cratis.AuthProxy.Authentication; +using Cratis.AuthProxy.BearerRoutes; using Microsoft.AspNetCore.Authentication; namespace Cratis.AuthProxy.Identity; @@ -76,8 +77,10 @@ public static class ClientPrincipalExtensions .Concat(["anonymous", "authenticated"]) .Distinct(); + // The bearer-route namespace means "authenticated on a bearer route". A browser session's provider does not + // get to say that, so its claims in that namespace are never forwarded. var claims = user.Claims - .Where(c => !IsRoleClaim(c.Type, isCanonical)) + .Where(c => !IsRoleClaim(c.Type, isCanonical) && !BearerRouteClaims.IsReserved(c.Type)) .Select(c => new ClientPrincipalClaim { Type = c.Type, Value = c.Value }); return new ClientPrincipal diff --git a/Source/AuthProxy/IngressExtensions.cs b/Source/AuthProxy/IngressExtensions.cs index 3094eda2..cc273389 100644 --- a/Source/AuthProxy/IngressExtensions.cs +++ b/Source/AuthProxy/IngressExtensions.cs @@ -105,6 +105,10 @@ public static WebApplicationBuilder AddIngressConfiguration(this WebApplicationB // that never opts in. builder.AddAdmission(); + // The same holds for the bearer-route gate: UseIngress always places it, and it hands every request on + // untouched for a deployment that declares no bearer route. + builder.AddBearerRoutes(); + var dataProtectionBuilder = builder.Services.AddDataProtection().SetApplicationName("Cratis.AuthProxy"); var dataProtectionKeysPath = builder.Configuration[$"{C.AuthProxy.SectionKey}:DataProtectionKeysPath"]; if (!string.IsNullOrWhiteSpace(dataProtectionKeysPath)) diff --git a/Source/AuthProxy/Program.cs b/Source/AuthProxy/Program.cs index 802a3a66..5e5425ce 100644 --- a/Source/AuthProxy/Program.cs +++ b/Source/AuthProxy/Program.cs @@ -4,7 +4,6 @@ using Cratis.AuthProxy; using Cratis.AuthProxy.Authentication; using Cratis.AuthProxy.Authorization; -using Cratis.AuthProxy.BearerRoutes; using Cratis.AuthProxy.Identity; using Cratis.AuthProxy.Invites; using Cratis.AuthProxy.Links; @@ -26,7 +25,6 @@ builder.AddLinks(); builder.AddSignIns(); builder.SetupReverseProxy(); -builder.AddBearerRoutes(); builder.AddManagement(); var app = builder.Build(); From babfc3faeba703fdbd66ea04bfe5d085693fdfcf Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 29 Sep 2026 07:21:16 +0200 Subject: [PATCH 05/24] Refuse a bearer-route token off its routes however the Authorization header is written The off-route refusal read the header strictly, so 'Bearer ' or a second Authorization header passed it as malformed while the JWT Bearer handler trims and accepts the token. The refusal now reads every value leniently. Refs #143 --- Documentation/configuration/authentication.md | 2 +- ..._off_its_routes_with_unusual_formatting.cs | 76 ++++++++++++ .../given/BearerRouteHarness.cs | 108 +++++++++++++----- .../JwtBearerAlongsideBearerRoutesHarness.cs | 44 +++++++ ...arerAlongsideBearerRoutesSpecCollection.cs | 14 +++ .../BearerRoutes/BearerRouteMiddleware.cs | 3 +- .../BearerRoutes/BearerTokenValidator.cs | 56 ++++++++- 7 files changed, 268 insertions(+), 35 deletions(-) create mode 100644 Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_bearer_token_is_presented_off_its_routes_with_unusual_formatting.cs create mode 100644 Source/AuthProxy.Security.Specs/given/JwtBearerAlongsideBearerRoutesHarness.cs create mode 100644 Source/AuthProxy.Security.Specs/given/JwtBearerAlongsideBearerRoutesSpecCollection.cs diff --git a/Documentation/configuration/authentication.md b/Documentation/configuration/authentication.md index 5926d297..faf45582 100644 --- a/Documentation/configuration/authentication.md +++ b/Documentation/configuration/authentication.md @@ -581,7 +581,7 @@ A token is accepted only when all of these hold: | Token lacks a required scope | `403`, `WWW-Authenticate: Bearer error="insufficient_scope", scope="", resource_metadata="…"` | | Token carries no tenant, or the tenant fails verification | `403` | | The issuer's metadata or keys cannot be retrieved | `503` with `Retry-After` | -| Bearer-route token on any other path | `401`, `WWW-Authenticate: Bearer error="invalid_token"` | +| Bearer-route token on any other path — whatever the case of the scheme or the whitespace after it, and in any of several `Authorization` headers | `401`, `WWW-Authenticate: Bearer error="invalid_token"` | `resource_metadata` is omitted when the route declares no `ResourceMetadataUrl`. Every refusal carries `Cache-Control: no-store` and an empty body; the reason is logged, not returned. diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_bearer_token_is_presented_off_its_routes_with_unusual_formatting.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_bearer_token_is_presented_off_its_routes_with_unusual_formatting.cs new file mode 100644 index 00000000..1a7b3e95 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_bearer_token_is_presented_off_its_routes_with_unusual_formatting.cs @@ -0,0 +1,76 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// A bearer-route token stays off browser-only surfaces however the Authorization header is +/// written. The JWT Bearer handler trims what follows the scheme, so a second space or a tab would otherwise carry +/// the token past the refusal and into a handler that trusts its issuer. The same holds when the token is one of +/// several Authorization headers. +/// +/// The headers are set on the server-side request directly: an would parse and re-write +/// them into the canonical form this spec is about avoiding. +/// +/// +/// The running proxy, with the JWT Bearer handler trusting the bearer-route issuer. +[Collection(JwtBearerAlongsideBearerRoutesSpecCollection.Name)] +public class when_a_bearer_token_is_presented_off_its_routes_with_unusual_formatting(JwtBearerAlongsideBearerRoutesHarness harness) : IAsyncLifetime +{ + const string Path = "/api/items"; + + HttpResponseMessage? _control; + HttpContext? _doubleSpace; + HttpContext? _tab; + HttpContext? _spaceAndTab; + HttpContext? _lowerCase; + HttpContext? _secondHeader; + bool _forwarded; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + + // A token the JWT Bearer handler accepts and no bearer route names: the handler is live on this path. + _control = await client.SendAsync(BearerRouteHarness.WithToken( + HttpMethod.Get, + Path, + harness.Issuer.Token(issuer: JwtBearerAlongsideBearerRoutesHarness.OtherIssuer))); + + harness.Origin.Clear(); + var token = harness.Issuer.Token(); + _doubleSpace = await SendWithRawAuthorization($"Bearer {token}"); + _tab = await SendWithRawAuthorization($"Bearer\t{token}"); + _spaceAndTab = await SendWithRawAuthorization($"Bearer \t{token} "); + _lowerCase = await SendWithRawAuthorization($"bearer {token}"); + _secondHeader = await SendWithRawAuthorization("Basic dXNlcjpwYXNz", $"Bearer {token}"); + _forwarded = harness.Origin.ReceivedAnythingFor(Path); + } + + public Task DisposeAsync() + { + _control?.Dispose(); + return Task.CompletedTask; + } + + [Fact] public void should_let_the_jwt_bearer_handler_accept_a_token_from_another_issuer() => Assert.Equal(HttpStatusCode.OK, _control!.StatusCode); + [Fact] public void should_refuse_it_after_two_spaces() => Assert.Equal(HttpStatusCode.Unauthorized, (HttpStatusCode)_doubleSpace!.Response.StatusCode); + [Fact] public void should_refuse_it_after_a_tab() => Assert.Equal(HttpStatusCode.Unauthorized, (HttpStatusCode)_tab!.Response.StatusCode); + [Fact] public void should_refuse_it_after_a_space_and_a_tab() => Assert.Equal(HttpStatusCode.Unauthorized, (HttpStatusCode)_spaceAndTab!.Response.StatusCode); + [Fact] public void should_refuse_it_under_a_lower_case_scheme() => Assert.Equal(HttpStatusCode.Unauthorized, (HttpStatusCode)_lowerCase!.Response.StatusCode); + [Fact] public void should_refuse_it_as_one_of_several_headers() => Assert.Equal(HttpStatusCode.Unauthorized, (HttpStatusCode)_secondHeader!.Response.StatusCode); + [Fact] public void should_challenge_with_invalid_token() => Assert.Equal("Bearer error=\"invalid_token\"", _doubleSpace!.Response.Headers.WWWAuthenticate.ToString()); + [Fact] public void should_not_forward_any_of_them() => Assert.False(_forwarded); + + Task SendWithRawAuthorization(params string[] values) => + harness.Server.SendAsync(context => + { + context.Request.Method = HttpMethods.Get; + context.Request.Path = Path; + context.Request.Headers.Authorization = new StringValues(values); + }); +} diff --git a/Source/AuthProxy.Security.Specs/given/BearerRouteHarness.cs b/Source/AuthProxy.Security.Specs/given/BearerRouteHarness.cs index a1396f2a..06d5b38e 100644 --- a/Source/AuthProxy.Security.Specs/given/BearerRouteHarness.cs +++ b/Source/AuthProxy.Security.Specs/given/BearerRouteHarness.cs @@ -56,6 +56,15 @@ public class BearerRouteHarness : WebApplicationFactory /// The tenant a browser session resolves to. public const string SessionTenantId = "11111111-1111-1111-1111-111111111111"; + /// A second bearer route, which forwards the Authorization header to the backend. + public const string ForwardingRoutePrefix = "/v1"; + + /// A third bearer route, whose issuer cannot be reached. + public const string UnreachableRoutePrefix = "/offline"; + + /// The issuer of : a loopback port nothing listens on. + public const string UnreachableIssuer = "http://127.0.0.1:1/"; + readonly string _pagesPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); /// @@ -124,37 +133,84 @@ protected override void Dispose(bool disposing) /// protected override void ConfigureWebHost(IWebHostBuilder builder) { - const string route = $"{C.AuthProxy.SectionKey}:Services:app:BearerRoutes:0"; - builder .UseEnvironment("Production") - .ConfigureAppConfiguration((_, config) => config.AddInMemoryCollection(new Dictionary - { - [$"{C.AuthProxy.SectionKey}:Services:app:Backend:BaseUrl"] = Origin.BaseUrl, - [$"{C.AuthProxy.SectionKey}:Services:app:Frontend:BaseUrl"] = Origin.BaseUrl, - - [$"{route}:PathPrefix"] = RoutePrefix, - [$"{route}:Issuers:0:Issuer"] = Issuer.Issuer, - [$"{route}:Audiences:0"] = Audience, - [$"{route}:RequiredScopes:0"] = RequiredScope, - [$"{route}:ResourceMetadataUrl"] = ResourceMetadataUrl, - [$"{route}:IdentityProvider"] = "github", - [$"{route}:ClaimMappings:sub"] = "github_id", - [$"{route}:ClaimMappings:preferred_username"] = "github_login", - - [$"{C.AuthProxy.SectionKey}:PagesPath"] = _pagesPath, - - [$"{C.AuthProxy.SectionKey}:TenantResolutions:0:Strategy"] = nameof(C.TenantSourceIdentifierResolverType.Specified), - [$"{C.AuthProxy.SectionKey}:TenantResolutions:0:Options:TenantId"] = SessionTenantId, - - [$"{C.Authentication.SectionKey}:OidcProviders:0:Name"] = "Provider One", - [$"{C.Authentication.SectionKey}:OidcProviders:0:Authority"] = "https://login.example.test/one", - [$"{C.Authentication.SectionKey}:OidcProviders:0:ClientId"] = "client-one", - })) - .ConfigureTestServices(services => services + .ConfigureAppConfiguration((_, config) => config.AddInMemoryCollection(Settings())); + + if (UsesHeaderAuthentication) + { + builder.ConfigureTestServices(services => services .AddAuthentication(HeaderAuthenticationHandler.Scheme) .AddScheme( HeaderAuthenticationHandler.Scheme, _ => { })); + } + + ConfigureHost(builder); + } + + /// + /// Gets whether browser sessions are stood in for by , replacing the + /// proxy's own default authentication scheme. A harness that needs the real scheme selection turns it off. + /// + protected virtual bool UsesHeaderAuthentication => true; + + /// + /// Adds configuration a derived harness needs on top of the shared bearer-route deployment. + /// + /// The settings to add to. + protected virtual void AddSettings(IDictionary settings) + { + } + + /// + /// Configures the host further, for settings that must be visible while the application is being built. + /// + /// The web host builder. + protected virtual void ConfigureHost(IWebHostBuilder builder) + { + } + + Dictionary Settings() + { + const string route = $"{C.AuthProxy.SectionKey}:Services:app:BearerRoutes:0"; + const string forwarding = $"{C.AuthProxy.SectionKey}:Services:app:BearerRoutes:1"; + const string unreachable = $"{C.AuthProxy.SectionKey}:Services:app:BearerRoutes:2"; + + var settings = new Dictionary + { + [$"{C.AuthProxy.SectionKey}:Services:app:Backend:BaseUrl"] = Origin.BaseUrl, + [$"{C.AuthProxy.SectionKey}:Services:app:Frontend:BaseUrl"] = Origin.BaseUrl, + + [$"{route}:PathPrefix"] = RoutePrefix, + [$"{route}:Issuers:0:Issuer"] = Issuer.Issuer, + [$"{route}:Audiences:0"] = Audience, + [$"{route}:RequiredScopes:0"] = RequiredScope, + [$"{route}:ResourceMetadataUrl"] = ResourceMetadataUrl, + [$"{route}:IdentityProvider"] = "github", + [$"{route}:ClaimMappings:sub"] = "github_id", + [$"{route}:ClaimMappings:preferred_username"] = "github_login", + + [$"{C.AuthProxy.SectionKey}:PagesPath"] = _pagesPath, + + [$"{C.AuthProxy.SectionKey}:TenantResolutions:0:Strategy"] = nameof(C.TenantSourceIdentifierResolverType.Specified), + [$"{C.AuthProxy.SectionKey}:TenantResolutions:0:Options:TenantId"] = SessionTenantId, + + [$"{C.Authentication.SectionKey}:OidcProviders:0:Name"] = "Provider One", + [$"{C.Authentication.SectionKey}:OidcProviders:0:Authority"] = "https://login.example.test/one", + [$"{C.Authentication.SectionKey}:OidcProviders:0:ClientId"] = "client-one", + + [$"{forwarding}:PathPrefix"] = ForwardingRoutePrefix, + [$"{forwarding}:Issuers:0:Issuer"] = Issuer.Issuer, + [$"{forwarding}:Audiences:0"] = Audience, + [$"{forwarding}:ForwardAuthorizationHeader"] = "true", + + [$"{unreachable}:PathPrefix"] = UnreachableRoutePrefix, + [$"{unreachable}:Issuers:0:Issuer"] = UnreachableIssuer, + [$"{unreachable}:Audiences:0"] = Audience, + }; + + AddSettings(settings); + return settings; } } diff --git a/Source/AuthProxy.Security.Specs/given/JwtBearerAlongsideBearerRoutesHarness.cs b/Source/AuthProxy.Security.Specs/given/JwtBearerAlongsideBearerRoutesHarness.cs new file mode 100644 index 00000000..5dc35ee6 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/given/JwtBearerAlongsideBearerRoutesHarness.cs @@ -0,0 +1,44 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.AspNetCore.Hosting; + +namespace Cratis.AuthProxy.Security.given; + +/// +/// The bearer-route deployment of , with the general JWT Bearer handler also +/// configured to trust the bearer routes' issuer. +/// +/// +/// This is the deployment in which a bearer-route token could reach a browser-only surface at all: the JWT Bearer +/// handler would accept it on any path. The proxy's own authentication scheme selection is kept — no stand-in +/// session handler — so the scheme that would accept the token is the one that actually runs. +/// +/// The JWT Bearer settings are given with UseSetting because the proxy decides whether to +/// register the handler while the application is being built. +/// +/// +public class JwtBearerAlongsideBearerRoutesHarness : BearerRouteHarness +{ + /// + /// An issuer the JWT Bearer handler also trusts and no bearer route names, so a spec can show the handler + /// accepts a token at all. + /// + public const string OtherIssuer = "https://machine-to-machine.example.test/"; + + /// + protected override bool UsesHeaderAuthentication => false; + + /// + protected override void ConfigureHost(IWebHostBuilder builder) + { + const string jwtBearer = $"{C.Authentication.SectionKey}:JwtBearer"; + + builder + .UseSetting($"{jwtBearer}:Authority", Issuer.Issuer) + .UseSetting($"{jwtBearer}:MetadataAddress", $"{Issuer.Issuer}.well-known/oauth-authorization-server") + .UseSetting($"{jwtBearer}:RequireHttpsMetadata", "false") + .UseSetting($"{jwtBearer}:Audience", Audience) + .UseSetting($"{jwtBearer}:TokenValidationParameters:ValidIssuers:0", OtherIssuer); + } +} diff --git a/Source/AuthProxy.Security.Specs/given/JwtBearerAlongsideBearerRoutesSpecCollection.cs b/Source/AuthProxy.Security.Specs/given/JwtBearerAlongsideBearerRoutesSpecCollection.cs new file mode 100644 index 00000000..8d1c4b41 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/given/JwtBearerAlongsideBearerRoutesSpecCollection.cs @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.given; + +/// +/// Runs every spec against the deployment where the JWT Bearer handler trusts a bearer-route issuer, in sequence. +/// +[CollectionDefinition(Name, DisableParallelization = true)] +public class JwtBearerAlongsideBearerRoutesSpecCollection : ICollectionFixture +{ + /// The collection name every spec against that deployment joins. + public const string Name = "JwtBearerAlongsideBearerRoutes"; +} diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteMiddleware.cs b/Source/AuthProxy/BearerRoutes/BearerRouteMiddleware.cs index 5f84c1a4..4b7762de 100644 --- a/Source/AuthProxy/BearerRoutes/BearerRouteMiddleware.cs +++ b/Source/AuthProxy/BearerRoutes/BearerRouteMiddleware.cs @@ -58,8 +58,7 @@ public async Task InvokeAsync(HttpContext context) return; } - if (BearerTokenValidator.TryReadPresentedIssuer(context.Request, out var issuer) - && BearerRouteTable.IsBearerRouteIssuer(issuer, current)) + if (BearerTokenValidator.TryFindPresentedIssuer(context.Request, _ => BearerRouteTable.IsBearerRouteIssuer(_, current), out var issuer)) { logger.BearerTokenOutsideItsRoutes(issuer, RequestPathRedaction.Redact(context.Request.Path)); BearerChallenge.Unauthorized(context, resourceMetadataUrl: null, "invalid_token"); diff --git a/Source/AuthProxy/BearerRoutes/BearerTokenValidator.cs b/Source/AuthProxy/BearerRoutes/BearerTokenValidator.cs index 376aa903..770fd5ac 100644 --- a/Source/AuthProxy/BearerRoutes/BearerTokenValidator.cs +++ b/Source/AuthProxy/BearerRoutes/BearerTokenValidator.cs @@ -59,20 +59,47 @@ public static IEnumerable RequiredScopes(ResolvedBearerRoute route) => .Distinct(StringComparer.Ordinal); /// - /// Reads the issuer a request's bearer token claims to come from, without validating anything. + /// Finds a bearer token in a request whose claimed issuer matches, without validating anything. /// /// The request. - /// The claimed issuer. - /// when the request carries a readable, unencrypted JWT naming an issuer; otherwise . + /// Decides whether a claimed issuer is one being looked for. + /// The first matching claimed issuer. + /// when some bearer token in the request names a matching issuer; otherwise . /// + /// This is the reading used to refuse a token, so it is deliberately more lenient than any scheme + /// that might accept one: every Authorization value is looked at, and every comma-separated + /// part of each, since the JWT Bearer handler reads the values joined by commas; the scheme is matched + /// case-insensitively and may be followed by any run of whitespace; and the token is trimmed. A header the + /// strict reading of would call malformed must not escape a refusal that a more + /// forgiving handler downstream would then not make. + /// /// Only good for deciding which rules apply to a token, never for trusting it: nothing about the value has /// been checked. + /// /// - public static bool TryReadPresentedIssuer(HttpRequest request, out string issuer) + public static bool TryFindPresentedIssuer(HttpRequest request, Func matches, out string issuer) { issuer = string.Empty; - return ReadBearerToken(request, out var token) == TokenPresence.Present - && TryReadUnvalidatedIssuer(token, out issuer); + foreach (var value in request.Headers.Authorization) + { + if (string.IsNullOrEmpty(value)) + { + continue; + } + + foreach (var part in value.Split(',')) + { + if (TryReadLenientBearerToken(part, out var token) + && TryReadUnvalidatedIssuer(token, out var candidate) + && matches(candidate)) + { + issuer = candidate; + return true; + } + } + } + + return false; } /// @@ -220,6 +247,23 @@ static TokenPresence ReadBearerToken(HttpRequest request, out string token) : TokenPresence.Malformed; } + static bool TryReadLenientBearerToken(string value, out string token) + { + const string scheme = "Bearer"; + + token = string.Empty; + var trimmed = value.AsSpan().Trim(); + if (trimmed.Length <= scheme.Length + || !trimmed.StartsWith(scheme, StringComparison.OrdinalIgnoreCase) + || !char.IsWhiteSpace(trimmed[scheme.Length])) + { + return false; + } + + token = trimmed[scheme.Length..].Trim().ToString(); + return token.Length > 0; + } + static bool TryReadUnvalidatedIssuer(string token, out string issuer) { issuer = string.Empty; From 3325cce3e8f437c12e2581f00aedd7268df4a1c5 Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 29 Sep 2026 07:29:04 +0200 Subject: [PATCH 06/24] Apply the deployment's claim requirements and identity-verification rule to bearer routes Authorization.RequiredClaims (proxy-wide and the route's service's) are evaluated against the token's principal after claim mappings and refused with 403. A bearer route never calls /.cratis/me, so a deployment requiring identity verification now refuses to start with one unless the route sets AcceptWithoutIdentityVerification. Refs #143 --- Documentation/configuration/authentication.md | 10 +- Documentation/configuration/authorization.md | 5 + Documentation/configuration/services.md | 14 ++- ..._without_required_identity_verification.cs | 35 +++++++ .../when_the_deployment_requires_claims.cs | 66 +++++++++++++ ...ployment_requires_identity_verification.cs | 40 ++++++++ .../given/GatedBearerRouteHarness.cs | 41 ++++++++ .../given/GatedBearerRouteSpecCollection.cs | 15 +++ .../when_evaluating_for_a_named_service.cs | 59 ++++++++++++ .../when_validating_bearer_routes.cs | 95 +++++++++++++++---- .../AuthProxy/Authorization/AccessPolicy.cs | 30 +++--- .../AuthProxy/Authorization/IAccessPolicy.cs | 14 +++ .../BearerRouteConfigurationValidator.cs | 9 ++ .../BearerRoutes/BearerRouteMiddleware.cs | 25 ++++- .../BearerRoutes/BearerRoutesLogging.cs | 3 + Source/AuthProxy/Configuration/BearerRoute.cs | 13 +++ 16 files changed, 437 insertions(+), 37 deletions(-) create mode 100644 Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_bearer_route_does_not_accept_callers_without_required_identity_verification.cs create mode 100644 Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_deployment_requires_claims.cs create mode 100644 Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_deployment_requires_identity_verification.cs create mode 100644 Source/AuthProxy.Security.Specs/given/GatedBearerRouteHarness.cs create mode 100644 Source/AuthProxy.Security.Specs/given/GatedBearerRouteSpecCollection.cs create mode 100644 Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_evaluating_for_a_named_service.cs diff --git a/Documentation/configuration/authentication.md b/Documentation/configuration/authentication.md index faf45582..95b90f75 100644 --- a/Documentation/configuration/authentication.md +++ b/Documentation/configuration/authentication.md @@ -572,6 +572,11 @@ A token is accepted only when all of these hold: - It has an `exp`, and it is within its lifetime allowing the route's clock skew (30 seconds by default). - It carries every scope the route requires, a single `sub`, and a single usable tenant. +Then the deployment's [claim requirements](authorization.md) apply — the proxy-wide `Authorization` section and +the route's service's own — to the principal after the route's `ClaimMappings`, exactly as they apply to a +browser session. Role claims are dropped from the token first, so a requirement on a role can never be met by a +bearer token. + ### Responses | Situation | Response | @@ -579,6 +584,7 @@ A token is accepted only when all of these hold: | No bearer token (a browser session does not count) | `401`, `WWW-Authenticate: Bearer resource_metadata=""` | | Token invalid, expired, wrongly signed, from another issuer or for another audience | `401`, `WWW-Authenticate: Bearer error="invalid_token", resource_metadata="…"` | | Token lacks a required scope | `403`, `WWW-Authenticate: Bearer error="insufficient_scope", scope="", resource_metadata="…"` | +| Token does not satisfy the deployment's claim requirements | `403` | | Token carries no tenant, or the tenant fails verification | `403` | | The issuer's metadata or keys cannot be retrieved | `503` with `Retry-After` | | Bearer-route token on any other path — whatever the case of the scheme or the whitespace after it, and in any of several `Authorization` headers | `401`, `WWW-Authenticate: Bearer error="invalid_token"` | @@ -610,7 +616,9 @@ claim, and role claims in the token are not forwarded: a token never grants a ro Every inbound copy of these headers is removed on every route, bearer or not. The `Cookie` header is not forwarded on a bearer route, and neither is `Authorization` unless the route sets `ForwardAuthorizationHeader`. Identity enrichment (`/.cratis/me`) is not called on a bearer route; the backend enforces tenant membership and -what the user may do with the scopes the client was granted. +what the user may do with the scopes the client was granted. A deployment where a service declares +`IdentityVerification: Required` therefore refuses to start with a bearer route unless the route sets +`AcceptWithoutIdentityVerification` — see [Bearer routes](services.md#bearer-routes). --- diff --git a/Documentation/configuration/authorization.md b/Documentation/configuration/authorization.md index 20b4f5ab..923250c8 100644 --- a/Documentation/configuration/authorization.md +++ b/Documentation/configuration/authorization.md @@ -224,6 +224,11 @@ GitHub Enterprise works without further configuration: the membership endpoints ## What a refused caller sees +On a [bearer route](services.md#bearer-routes) the requirements apply to the access token's principal, after the +route's claim mappings, and a refusal is a bare `403` with no page — the caller is a program, not a person. + +For a browser session: + The [`not-authorized.html`](well-known-pages.md) page, at `403`, with a **Sign out** link. Both halves are deliberate. A redirect back to the identity provider — the reflex for "not allowed" — is diff --git a/Documentation/configuration/services.md b/Documentation/configuration/services.md index acc1cab2..2c49bc10 100644 --- a/Documentation/configuration/services.md +++ b/Documentation/configuration/services.md @@ -466,6 +466,7 @@ Cratis account id from the token's `sub` is still forwarded, as the `urn:cratis: | `IdentityProvider` | `string` | `bearer` | The identity provider named in the forwarded principal. | | `ForwardAuthorizationHeader` | `bool` | `false` | Whether the backend also receives the `Authorization` header. | | `ClockSkew` | `TimeSpan` | `00:00:30` | Allowed clock skew for `exp` and `nbf`. At most `00:05:00`. | +| `AcceptWithoutIdentityVerification` | `bool` | `false` | Accept this route's callers in a deployment where a service declares `IdentityVerification: Required`. See [What a bearer route changes](#what-a-bearer-route-changes). | ### BearerIssuerConfig properties @@ -478,7 +479,15 @@ Cratis account id from the token's `sub` is still forwarded, as the `urn:cratis: ### What a bearer route changes - A request on a bearer route is answered before static files, authentication, provider selection, tenant - selection and identity enrichment — none of them apply. It is forwarded straight to the service **backend**, + selection and identity enrichment — none of them apply. [Admission](admission.md) and the trusted-proxy + boundary run first and apply as to any request. +- The deployment's [claim requirements](authorization.md) — proxy-wide and the route's service's — apply to + the token's principal after `ClaimMappings`. A token that does not satisfy them is refused with `403`. +- A bearer route never calls `/.cratis/me`, so it cannot obtain the verdict `IdentityVerification: Required` + asks for. When any service declares `Required`, AuthProxy **refuses to start** with a bearer route unless the + route sets `AcceptWithoutIdentityVerification: true` — the operator's statement that, on this route, the + validated token, its scopes and the claim requirements are the whole decision at the edge and the backend + answers for the rest. It is forwarded straight to the service **backend**, whichever of the service's endpoints would otherwise serve that path, and without a `Service-ID` header. - The session cookie is never read, and the `Cookie` header is not forwarded. - Every refusal is an API-style `401`, `403` or `503` — never a redirect or a page. See @@ -487,6 +496,7 @@ Cratis account id from the token's `sub` is still forwarded, as the `urn:cratis: bearer routes, even where another bearer scheme (the [JWT Bearer](authentication.md#jwt-bearer-api) handler) would accept it. A browser-only surface such as `/api` stays browser-only. - A route that overlaps an anonymous path, repeats another route's prefix, names no issuer or audience, or - belongs to a service without a backend is refused at startup. + belongs to a service without a backend is refused at startup, as is one that does not accept callers without + identity verification in a deployment that requires it. - With no bearer route configured, nothing changes. diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_bearer_route_does_not_accept_callers_without_required_identity_verification.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_bearer_route_does_not_accept_callers_without_required_identity_verification.cs new file mode 100644 index 00000000..473676de --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_bearer_route_does_not_accept_callers_without_required_identity_verification.cs @@ -0,0 +1,35 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Extensions.Options; + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// A deployment that requires identity verification of every forwarded request does not start with a bearer route +/// that would forward requests without it, unless the route says so. The mistake is named at startup rather than +/// found in production. +/// +public class when_a_bearer_route_does_not_accept_callers_without_required_identity_verification : IDisposable +{ + readonly VerificationRequiringHarness _harness = new(); + readonly Exception? _startup; + + public when_a_bearer_route_does_not_accept_callers_without_required_identity_verification() => + _startup = Record.Exception(() => _harness.CreateClient().Dispose()); + + [Fact] public void should_refuse_to_start() => Assert.IsType(_startup); + [Fact] public void should_name_the_setting_that_accepts_it() => Assert.Contains(nameof(C.BearerRoute.AcceptWithoutIdentityVerification), _startup!.Message, StringComparison.Ordinal); + + public void Dispose() + { + _harness.Dispose(); + GC.SuppressFinalize(this); + } + + sealed class VerificationRequiringHarness : BearerRouteHarness + { + protected override void AddSettings(IDictionary settings) => + settings[$"{C.AuthProxy.SectionKey}:Services:app:IdentityVerification"] = nameof(C.IdentityVerificationMode.Required); + } +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_deployment_requires_claims.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_deployment_requires_claims.cs new file mode 100644 index 00000000..5112b879 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_deployment_requires_claims.cs @@ -0,0 +1,66 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// A deployment's claim requirements apply to every forwarded request, and a bearer route is no exception: a valid +/// token whose principal does not satisfy them is refused with a bare 403 and never forwarded. +/// They are checked against the principal the backend would receive — after the route's claim mappings — so a +/// token cannot satisfy a requirement with a raw claim the mapping replaces. +/// +/// The running proxy, with claim requirements declared. +[Collection(GatedBearerRouteSpecCollection.Name)] +public class when_the_deployment_requires_claims(GatedBearerRouteHarness harness) : IAsyncLifetime +{ + const string QualifiedPath = $"{BearerRouteHarness.RoutePrefix}/qualified"; + const string WithoutOrganizationPath = $"{BearerRouteHarness.RoutePrefix}/without-organization"; + const string OtherOrganizationPath = $"{BearerRouteHarness.RoutePrefix}/other-organization"; + const string MappedPath = $"{BearerRouteHarness.RoutePrefix}/mapped"; + const string UnmappedPath = $"{BearerRouteHarness.RoutePrefix}/unmapped"; + + HttpResponseMessage? _qualified; + HttpResponseMessage? _withoutOrganization; + HttpResponseMessage? _otherOrganization; + HttpResponseMessage? _mapped; + HttpResponseMessage? _unmapped; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + + var organization = new Dictionary { [GatedBearerRouteHarness.RequiredClaim] = GatedBearerRouteHarness.RequiredValue }; + + _qualified = await Send(client, QualifiedPath, harness.Issuer.Token(organization)); + _withoutOrganization = await Send(client, WithoutOrganizationPath, harness.Issuer.Token()); + _otherOrganization = await Send(client, OtherOrganizationPath, harness.Issuer.Token(new Dictionary { [GatedBearerRouteHarness.RequiredClaim] = "Elsewhere" })); + + // The token's own preferred_username is someone else; the route maps it from github_login. + _mapped = await Send(client, MappedPath, harness.Issuer.Token(new Dictionary(organization) { ["preferred_username"] = "someone-else" })); + + // The token's own preferred_username qualifies, but the route replaces it with github_login. + _unmapped = await Send(client, UnmappedPath, harness.Issuer.Token(new Dictionary(organization) { ["github_login"] = "mallory" })); + } + + public Task DisposeAsync() + { + _qualified?.Dispose(); + _withoutOrganization?.Dispose(); + _otherOrganization?.Dispose(); + _mapped?.Dispose(); + _unmapped?.Dispose(); + return Task.CompletedTask; + } + + [Fact] public void should_forward_a_token_satisfying_every_requirement() => Assert.Equal(HttpStatusCode.OK, _qualified!.StatusCode); + [Fact] public void should_refuse_a_token_without_the_required_claim() => Assert.Equal(HttpStatusCode.Forbidden, _withoutOrganization!.StatusCode); + [Fact] public void should_refuse_a_token_with_an_unaccepted_value() => Assert.Equal(HttpStatusCode.Forbidden, _otherOrganization!.StatusCode); + [Fact] public void should_refuse_without_a_challenge() => Assert.Empty(_withoutOrganization!.Headers.WwwAuthenticate); + [Fact] public void should_not_forward_a_refused_token() => Assert.False(harness.Origin.ReceivedAnythingFor(WithoutOrganizationPath) || harness.Origin.ReceivedAnythingFor(OtherOrganizationPath)); + [Fact] public void should_check_the_service_requirement_after_the_claim_mappings() => Assert.Equal(HttpStatusCode.OK, _mapped!.StatusCode); + [Fact] public void should_not_let_a_mapped_away_claim_satisfy_the_service_requirement() => Assert.Equal(HttpStatusCode.Forbidden, _unmapped!.StatusCode); + + static Task Send(HttpClient client, string path, string token) => + client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, path, token)); +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_deployment_requires_identity_verification.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_deployment_requires_identity_verification.cs new file mode 100644 index 00000000..092d29a2 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_deployment_requires_identity_verification.cs @@ -0,0 +1,40 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// A bearer route has no browser session to verify through /.cratis/me. In a deployment that +/// requires identity verification it serves requests only because the route says it accepts callers without it, and +/// it then forwards without asking the backend for a verdict. +/// +/// The running proxy, with identity verification required. +[Collection(GatedBearerRouteSpecCollection.Name)] +public class when_the_deployment_requires_identity_verification(GatedBearerRouteHarness harness) : IAsyncLifetime +{ + const string Path = $"{BearerRouteHarness.RoutePrefix}/tools"; + + HttpResponseMessage? _response; + bool _verified; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + + _response = await client.SendAsync(BearerRouteHarness.WithToken( + HttpMethod.Get, + Path, + harness.Issuer.Token(new Dictionary { [GatedBearerRouteHarness.RequiredClaim] = GatedBearerRouteHarness.RequiredValue }))); + _verified = harness.Origin.ReceivedAnythingFor(WellKnownPaths.IdentityDetails); + } + + public Task DisposeAsync() + { + _response?.Dispose(); + return Task.CompletedTask; + } + + [Fact] public void should_forward_the_request() => Assert.Equal(HttpStatusCode.OK, _response!.StatusCode); + [Fact] public void should_not_ask_the_backend_for_a_verdict() => Assert.False(_verified); +} diff --git a/Source/AuthProxy.Security.Specs/given/GatedBearerRouteHarness.cs b/Source/AuthProxy.Security.Specs/given/GatedBearerRouteHarness.cs new file mode 100644 index 00000000..c0f149d6 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/given/GatedBearerRouteHarness.cs @@ -0,0 +1,41 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.given; + +/// +/// The bearer-route deployment of , with the gates the deployment declares for every +/// forwarded request: a proxy-wide claim requirement, a requirement of the service, and identity verification +/// required of the service. +/// +/// +/// The service requires preferred_username to be . The +/// bearer route maps preferred_username from the token's github_login, so what is +/// checked is the principal after the mapping, not the token as issued. Every bearer route states that it +/// accepts callers without identity verification, which a deployment requiring verification must say. +/// +public class GatedBearerRouteHarness : BearerRouteHarness +{ + /// The claim the whole deployment requires. + public const string RequiredClaim = "org"; + + /// The value of the deployment accepts. + public const string RequiredValue = "Cratis"; + + /// + protected override void AddSettings(IDictionary settings) + { + const string service = $"{C.AuthProxy.SectionKey}:Services:app"; + + settings[$"{C.Authorization.SectionKey}:RequiredClaims:0:Claim"] = RequiredClaim; + settings[$"{C.Authorization.SectionKey}:RequiredClaims:0:AnyOf:0"] = RequiredValue; + settings[$"{service}:Authorization:RequiredClaims:0:Claim"] = "preferred_username"; + settings[$"{service}:Authorization:RequiredClaims:0:AnyOf:0"] = GitHubLogin; + settings[$"{service}:IdentityVerification"] = nameof(C.IdentityVerificationMode.Required); + + for (var route = 0; route < 3; route++) + { + settings[$"{service}:BearerRoutes:{route}:{nameof(C.BearerRoute.AcceptWithoutIdentityVerification)}"] = "true"; + } + } +} diff --git a/Source/AuthProxy.Security.Specs/given/GatedBearerRouteSpecCollection.cs b/Source/AuthProxy.Security.Specs/given/GatedBearerRouteSpecCollection.cs new file mode 100644 index 00000000..b6cb20cf --- /dev/null +++ b/Source/AuthProxy.Security.Specs/given/GatedBearerRouteSpecCollection.cs @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.given; + +/// +/// Runs every spec against the bearer-route deployment that declares claim requirements and identity verification, +/// in sequence. +/// +[CollectionDefinition(Name, DisableParallelization = true)] +public class GatedBearerRouteSpecCollection : ICollectionFixture +{ + /// The collection name every spec against that deployment joins. + public const string Name = "GatedBearerRoutes"; +} diff --git a/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_evaluating_for_a_named_service.cs b/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_evaluating_for_a_named_service.cs new file mode 100644 index 00000000..d08c138c --- /dev/null +++ b/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_evaluating_for_a_named_service.cs @@ -0,0 +1,59 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Authorization.for_AccessPolicy; + +/// +/// A request whose service is known without reading the request — a bearer route belongs to one service — is held +/// to the root requirements and that service's, never to another service's, and never to fewer because the +/// request names some other service. +/// +public class when_evaluating_for_a_named_service : given.an_access_policy +{ + C.AuthProxy _config; + AccessDecision _qualified; + AccessDecision _missingTheServiceClaim; + AccessDecision _missingTheRootClaim; + AccessDecision _forAnotherService; + AccessDecision _namingAnotherServiceInTheRequest; + + void Establish() => _config = new C.AuthProxy + { + Authorization = new C.Authorization { RequiredClaims = [Claiming("org", "Cratis")] }, + Services = new Dictionary + { + ["admin"] = new() + { + Backend = new C.ServiceEndpoint { BaseUrl = "http://admin.test/" }, + Authorization = new C.Authorization { RequiredClaims = [Claiming("team", "operations")] }, + }, + ["portal"] = new() + { + Backend = new C.ServiceEndpoint { BaseUrl = "http://portal.test/" }, + }, + }, + }; + + void Because() + { + var qualified = Principal(new Claim("org", "cratis"), new Claim("team", "Operations")); + var orgOnly = Principal(new Claim("org", "Cratis")); + var teamOnly = Principal(new Claim("team", "operations")); + + _qualified = _policy.Evaluate(qualified, _config, "admin"); + _missingTheServiceClaim = _policy.Evaluate(orgOnly, _config, "admin"); + _missingTheRootClaim = _policy.Evaluate(teamOnly, _config, "portal"); + _forAnotherService = _policy.Evaluate(orgOnly, _config, "portal"); + + _context.Request.Headers[Headers.ServiceId] = "portal"; + _namingAnotherServiceInTheRequest = _policy.Evaluate(orgOnly, _config, "ADMIN"); + } + + [Fact] void should_grant_a_caller_satisfying_the_root_and_the_service() => _qualified.IsGranted.ShouldBeTrue(); + [Fact] void should_deny_a_caller_missing_the_service_requirement() => _missingTheServiceClaim.UnsatisfiedClaim.ShouldEqual("team"); + [Fact] void should_deny_a_caller_missing_the_root_requirement() => _missingTheRootClaim.UnsatisfiedClaim.ShouldEqual("org"); + [Fact] void should_not_apply_another_service_requirements() => _forAnotherService.IsGranted.ShouldBeTrue(); + [Fact] void should_ignore_what_the_request_names() => _namingAnotherServiceInTheRequest.IsGranted.ShouldBeFalse(); + + static ClaimsPrincipal Principal(params Claim[] claims) => new(new ClaimsIdentity(claims, "spec")); +} diff --git a/Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteConfigurationValidator/when_validating_bearer_routes.cs b/Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteConfigurationValidator/when_validating_bearer_routes.cs index dd801b8d..7347255c 100644 --- a/Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteConfigurationValidator/when_validating_bearer_routes.cs +++ b/Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteConfigurationValidator/when_validating_bearer_routes.cs @@ -19,6 +19,18 @@ public class when_validating_bearer_routes : Specification ValidateOptionsResult _withLargeClockSkew; ValidateOptionsResult _mappingIntoReservedClaim; ValidateOptionsResult _withQuotedScope; + ValidateOptionsResult _withoutIssuers; + ValidateOptionsResult _withRootPrefix; + ValidateOptionsResult _withProxyOwnedPrefix; + ValidateOptionsResult _withPlainHttpResourceMetadata; + ValidateOptionsResult _prefixOfAnotherService; + ValidateOptionsResult _resourceMetadataOfAnotherService; + ValidateOptionsResult _withEmptyMapping; + ValidateOptionsResult _mappingIntoRoles; + ValidateOptionsResult _verificationRequired; + ValidateOptionsResult _verificationRequiredByAnotherService; + ValidateOptionsResult _verificationRequiredAndAcceptedWithout; + ValidateOptionsResult _verificationRequiredOfANonParticipant; void Because() { @@ -33,6 +45,18 @@ void Because() _withLargeClockSkew = validator.Validate(null, Configuration(_ => _.ClockSkew = TimeSpan.FromHours(1))); _mappingIntoReservedClaim = validator.Validate(null, Configuration(_ => _.ClaimMappings = new Dictionary { ["urn:cratis:bearer:scope"] = "scope" })); _withQuotedScope = validator.Validate(null, Configuration(_ => _.RequiredScopes = ["direct:\"read"])); + _withoutIssuers = validator.Validate(null, Configuration(_ => _.Issuers = [])); + _withRootPrefix = validator.Validate(null, Configuration(_ => _.PathPrefix = "/")); + _withProxyOwnedPrefix = validator.Validate(null, Configuration(_ => _.PathPrefix = "/.cratis/mcp")); + _withPlainHttpResourceMetadata = validator.Validate(null, Configuration(_ => _.ResourceMetadataUrl = "http://direct.example.test/.well-known/oauth-protected-resource/mcp")); + _prefixOfAnotherService = validator.Validate(null, Configuration(_ => { }, adjustConfiguration: _ => _.Services["other"] = Service(Route()))); + _resourceMetadataOfAnotherService = validator.Validate(null, Configuration(_ => { }, adjustConfiguration: _ => _.Services["other"] = Service(Route(prefix: "/v1")))); + _withEmptyMapping = validator.Validate(null, Configuration(_ => _.ClaimMappings = new Dictionary { ["sub"] = " " })); + _mappingIntoRoles = validator.Validate(null, Configuration(_ => _.ClaimMappings = new Dictionary { ["roles"] = "groups" })); + _verificationRequired = validator.Validate(null, Configuration(_ => { }, adjustConfiguration: _ => _.Services["main"].IdentityVerification = C.IdentityVerificationMode.Required)); + _verificationRequiredByAnotherService = validator.Validate(null, Configuration(_ => { }, adjustConfiguration: _ => _.Services["other"] = RequiringVerification(new C.Service { Backend = new C.ServiceEndpoint { BaseUrl = "https://other.example.test" } }))); + _verificationRequiredAndAcceptedWithout = validator.Validate(null, Configuration(_ => _.AcceptWithoutIdentityVerification = true, adjustConfiguration: _ => _.Services["main"].IdentityVerification = C.IdentityVerificationMode.Required)); + _verificationRequiredOfANonParticipant = validator.Validate(null, Configuration(_ => { }, adjustConfiguration: _ => _.Services["other"] = RequiringVerification(new C.Service { Backend = new C.ServiceEndpoint { BaseUrl = "https://other.example.test" }, ResolveIdentityDetails = false }))); } [Fact] void should_accept_a_complete_route() => _valid.Succeeded.ShouldBeTrue(); @@ -44,30 +68,63 @@ void Because() [Fact] void should_refuse_a_clock_skew_beyond_the_maximum() => _withLargeClockSkew.Failed.ShouldBeTrue(); [Fact] void should_refuse_a_mapping_into_a_claim_authproxy_owns() => _mappingIntoReservedClaim.Failed.ShouldBeTrue(); [Fact] void should_refuse_a_scope_that_is_not_a_scope_token() => _withQuotedScope.Failed.ShouldBeTrue(); + [Fact] void should_refuse_a_route_without_an_issuer() => _withoutIssuers.Failed.ShouldBeTrue(); + [Fact] void should_refuse_the_root_as_a_prefix() => _withRootPrefix.Failed.ShouldBeTrue(); + [Fact] void should_refuse_a_prefix_under_a_path_the_proxy_owns() => _withProxyOwnedPrefix.Failed.ShouldBeTrue(); + [Fact] void should_refuse_a_plain_http_resource_metadata_url_off_loopback() => _withPlainHttpResourceMetadata.Failed.ShouldBeTrue(); + [Fact] void should_refuse_a_prefix_another_service_already_routes() => _prefixOfAnotherService.Failed.ShouldBeTrue(); + [Fact] void should_refuse_a_resource_metadata_path_another_service_already_serves() => _resourceMetadataOfAnotherService.Failed.ShouldBeTrue(); + [Fact] void should_refuse_a_mapping_with_an_empty_claim_type() => _withEmptyMapping.Failed.ShouldBeTrue(); + [Fact] void should_refuse_a_mapping_into_a_role_claim() => _mappingIntoRoles.Failed.ShouldBeTrue(); + [Fact] void should_refuse_a_route_when_its_service_requires_identity_verification() => _verificationRequired.Failed.ShouldBeTrue(); + [Fact] void should_name_the_setting_that_accepts_it() => _verificationRequired.FailureMessage.ShouldContain(nameof(C.BearerRoute.AcceptWithoutIdentityVerification)); + [Fact] void should_refuse_a_route_when_another_service_requires_identity_verification() => _verificationRequiredByAnotherService.Failed.ShouldBeTrue(); + [Fact] void should_accept_a_route_that_accepts_callers_without_identity_verification() => _verificationRequiredAndAcceptedWithout.Succeeded.ShouldBeTrue(); + [Fact] void should_ignore_a_verification_requirement_of_a_service_that_resolves_no_identity() => _verificationRequiredOfANonParticipant.Succeeded.ShouldBeTrue(); - static C.AuthProxy Configuration(Action adjust, bool backend = true, bool declareRoute = true) + static C.AuthProxy Configuration( + Action adjust, + bool backend = true, + bool declareRoute = true, + Action? adjustConfiguration = null) { - var route = new C.BearerRoute - { - PathPrefix = "/mcp", - Issuers = [new C.BearerIssuer { Issuer = "https://auth.example.test/" }], - Audiences = ["direct-api"], - RequiredScopes = ["direct:read"], - ResourceMetadataUrl = "https://direct.example.test/.well-known/oauth-protected-resource/mcp", - }; + var route = Route(); adjust(route); - return new() + var service = Service(declareRoute ? route : null); + service.AnonymousPaths = ["/public"]; + if (!backend) + { + service.Backend = null; + } + + var configuration = new C.AuthProxy { - Services = new Dictionary - { - ["main"] = new() - { - Backend = backend ? new C.ServiceEndpoint { BaseUrl = "https://backend.example.test" } : null, - AnonymousPaths = ["/public"], - BearerRoutes = declareRoute ? [route] : [], - }, - }, + Services = new Dictionary { ["main"] = service }, }; + adjustConfiguration?.Invoke(configuration); + + return configuration; + } + + static C.BearerRoute Route(string prefix = "/mcp") => new() + { + PathPrefix = prefix, + Issuers = [new C.BearerIssuer { Issuer = "https://auth.example.test/" }], + Audiences = ["direct-api"], + RequiredScopes = ["direct:read"], + ResourceMetadataUrl = "https://direct.example.test/.well-known/oauth-protected-resource/mcp", + }; + + static C.Service Service(C.BearerRoute? route) => new() + { + Backend = new C.ServiceEndpoint { BaseUrl = "https://backend.example.test" }, + BearerRoutes = route is null ? [] : [route], + }; + + static C.Service RequiringVerification(C.Service service) + { + service.IdentityVerification = C.IdentityVerificationMode.Required; + return service; } } diff --git a/Source/AuthProxy/Authorization/AccessPolicy.cs b/Source/AuthProxy/Authorization/AccessPolicy.cs index 3a73a25e..dcb44e67 100644 --- a/Source/AuthProxy/Authorization/AccessPolicy.cs +++ b/Source/AuthProxy/Authorization/AccessPolicy.cs @@ -31,11 +31,18 @@ public bool IsConfigured(C.AuthProxy config) => || config.Services.Values.Any(_ => _.Authorization?.HasRequirements == true); /// - public AccessDecision Evaluate(HttpContext context, C.AuthProxy config) + public AccessDecision Evaluate(HttpContext context, C.AuthProxy config) => + Evaluate(context.User, RequirementsFor(config, ResolveService(context, config))); + + /// + public AccessDecision Evaluate(ClaimsPrincipal user, C.AuthProxy config, string serviceName) => + Evaluate(user, RequirementsFor(config, FindService(config, serviceName))); + + static AccessDecision Evaluate(ClaimsPrincipal user, IEnumerable requirements) { - foreach (var requirement in RequirementsFor(context, config)) + foreach (var requirement in requirements) { - if (!IsSatisfied(requirement, context.User)) + if (!IsSatisfied(requirement, user)) { return AccessDecision.Denied(requirement.Claim); } @@ -47,17 +54,16 @@ public AccessDecision Evaluate(HttpContext context, C.AuthProxy config) /// /// Gets every requirement that applies to a request: the root's, then the target service's. /// - /// The current . /// The auth proxy configuration to read. + /// The targeted service, if any. /// The applicable requirements, root-first. - static IEnumerable RequirementsFor(HttpContext context, C.AuthProxy config) + static IEnumerable RequirementsFor(C.AuthProxy config, C.Service? service) { foreach (var requirement in config.Authorization.RequiredClaims) { yield return requirement; } - var service = ResolveService(context, config); if (service?.Authorization is null) { yield break; @@ -100,16 +106,14 @@ public AccessDecision Evaluate(HttpContext context, C.AuthProxy config) serviceId = context.Request.Query[ServiceQueryParameter].FirstOrDefault(); } - if (string.IsNullOrWhiteSpace(serviceId)) - { - return null; - } + return string.IsNullOrWhiteSpace(serviceId) ? null : FindService(config, serviceId); + } - return config.Services - .Where(_ => string.Equals(_.Key, serviceId.Trim(), StringComparison.OrdinalIgnoreCase)) + static C.Service? FindService(C.AuthProxy config, string serviceName) => + config.Services + .Where(_ => string.Equals(_.Key, serviceName.Trim(), StringComparison.OrdinalIgnoreCase)) .Select(_ => _.Value) .FirstOrDefault(); - } /// /// Determines whether a principal satisfies a single requirement. diff --git a/Source/AuthProxy/Authorization/IAccessPolicy.cs b/Source/AuthProxy/Authorization/IAccessPolicy.cs index 0dbb8e84..102e1872 100644 --- a/Source/AuthProxy/Authorization/IAccessPolicy.cs +++ b/Source/AuthProxy/Authorization/IAccessPolicy.cs @@ -1,6 +1,7 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using System.Security.Claims; using C = Cratis.AuthProxy.Configuration; namespace Cratis.AuthProxy.Authorization; @@ -29,4 +30,17 @@ public interface IAccessPolicy /// The auth proxy configuration to read. /// The for this caller and request. AccessDecision Evaluate(HttpContext context, C.AuthProxy config); + + /// + /// Evaluates the configured claim requirements against a principal for a request known to target a service. + /// + /// The authenticated principal. + /// The auth proxy configuration to read. + /// The name of the service the request targets. + /// The for this principal and service. + /// + /// For a request whose target is not worked out from the request itself — a bearer route belongs to exactly + /// one service, whatever the request names. The root requirements and the service's are applied the same way. + /// + AccessDecision Evaluate(ClaimsPrincipal user, C.AuthProxy config, string serviceName); } diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteConfigurationValidator.cs b/Source/AuthProxy/BearerRoutes/BearerRouteConfigurationValidator.cs index bb68338e..6ed73e6a 100644 --- a/Source/AuthProxy/BearerRoutes/BearerRouteConfigurationValidator.cs +++ b/Source/AuthProxy/BearerRoutes/BearerRouteConfigurationValidator.cs @@ -41,6 +41,15 @@ public ValidateOptionsResult Validate(string? name, C.AuthProxy options) var route = service.BearerRoutes[index]; var at = $"{C.AuthProxy.SectionKey}:{nameof(C.AuthProxy.Services)}:{serviceName}:{nameof(C.Service.BearerRoutes)}:{index}"; ValidateRoute(at, serviceName, service, route, prefixes, metadataPaths, anonymousPaths, failures); + + if (options.RequiresIdentityVerification && !route.AcceptWithoutIdentityVerification) + { + failures.Add( + $"{at}: the deployment requires identity verification ({nameof(C.IdentityVerificationMode)}.{nameof(C.IdentityVerificationMode.Required)}), " + + "which a bearer route does not perform: it has no browser session to verify through /.cratis/me. Set " + + $"{nameof(C.BearerRoute.AcceptWithoutIdentityVerification)} on the route to accept its callers on the token and " + + "the claim requirements alone, or remove the route."); + } } } diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteMiddleware.cs b/Source/AuthProxy/BearerRoutes/BearerRouteMiddleware.cs index 4b7762de..c0c25801 100644 --- a/Source/AuthProxy/BearerRoutes/BearerRouteMiddleware.cs +++ b/Source/AuthProxy/BearerRoutes/BearerRouteMiddleware.cs @@ -1,6 +1,7 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using Cratis.AuthProxy.Authorization; using Cratis.AuthProxy.Tenancy; using Microsoft.Extensions.Options; using Microsoft.Net.Http.Headers; @@ -15,6 +16,7 @@ namespace Cratis.AuthProxy.BearerRoutes; /// The next middleware in the pipeline. /// The auth proxy configuration monitor. /// The access-token validator. +/// The deployment's claim requirements, applied to the token's principal. /// The tenant existence verifier, applied when tenant verification is configured. /// The forwarder to the service backend. /// The logger. @@ -25,6 +27,13 @@ namespace Cratis.AuthProxy.BearerRoutes; /// refuse a token from a bearer-route issuer on a path that is not one of the bearer routes, so such a token /// cannot authenticate a browser-only surface through any other bearer support the deployment has configured. /// +/// The deployment's claim requirements ( and the route's service's own) +/// apply here as they do to a browser session, to the principal after the route's claim mappings. Identity +/// verification through /.cratis/me does not run here; a deployment that requires it may declare a +/// bearer route only when the route says it accepts callers without it +/// (), which startup validation enforces. +/// +/// /// With no bearer route configured it hands every request on untouched. /// /// @@ -32,6 +41,7 @@ public class BearerRouteMiddleware( RequestDelegate next, IOptionsMonitor config, IBearerTokenValidator validator, + IAccessPolicy accessPolicy, ITenantVerifier tenantVerifier, IBearerRouteForwarder forwarder, ILogger logger) @@ -54,7 +64,7 @@ public async Task InvokeAsync(HttpContext context) if (BearerRouteTable.TryMatch(context.Request.Path, current, out var route)) { - await Authenticate(context, route); + await Authenticate(context, route, current); return; } @@ -80,7 +90,7 @@ async Task ServeResourceMetadata(HttpContext context, ResolvedBearerRoute route) await forwarder.Forward(context, route, identity: null); } - async Task Authenticate(HttpContext context, ResolvedBearerRoute route) + async Task Authenticate(HttpContext context, ResolvedBearerRoute route, C.AuthProxy current) { var validation = await validator.Validate(context.Request, route, context.RequestAborted); if (!validation.Succeeded) @@ -90,6 +100,17 @@ async Task Authenticate(HttpContext context, ResolvedBearerRoute route) return; } + if (accessPolicy.IsConfigured(current)) + { + var decision = accessPolicy.Evaluate(validation.Principal!, current, route.ServiceName); + if (!decision.IsGranted) + { + logger.BearerAccessDenied(route.Prefix, route.ServiceName, decision.UnsatisfiedClaim); + BearerChallenge.Forbidden(context); + return; + } + } + if (!await tenantVerifier.VerifyAsync(validation.TenantId!)) { logger.BearerTenantNotVerified(validation.TenantId!, RequestPathRedaction.Redact(context.Request.Path)); diff --git a/Source/AuthProxy/BearerRoutes/BearerRoutesLogging.cs b/Source/AuthProxy/BearerRoutes/BearerRoutesLogging.cs index da21d9f5..e57ca100 100644 --- a/Source/AuthProxy/BearerRoutes/BearerRoutesLogging.cs +++ b/Source/AuthProxy/BearerRoutes/BearerRoutesLogging.cs @@ -17,6 +17,9 @@ internal static partial class BearerRoutesLogging [LoggerMessage(LogLevel.Information, "A token from bearer-route issuer '{Issuer}' was presented on {Path}, which is not one of its bearer routes. Refused.")] internal static partial void BearerTokenOutsideItsRoutes(this ILogger logger, string issuer, string path); + [LoggerMessage(LogLevel.Information, "A valid bearer token on route '{Route}' of service '{Service}' does not satisfy the required claim '{Claim}'. Refused.")] + internal static partial void BearerAccessDenied(this ILogger logger, string route, string service, string claim); + [LoggerMessage(LogLevel.Warning, "Tenant '{TenantId}' named by a bearer token on {Path} could not be verified. Refused.")] internal static partial void BearerTenantNotVerified(this ILogger logger, string tenantId, string path); diff --git a/Source/AuthProxy/Configuration/BearerRoute.cs b/Source/AuthProxy/Configuration/BearerRoute.cs index 69c3bad8..afd47d78 100644 --- a/Source/AuthProxy/Configuration/BearerRoute.cs +++ b/Source/AuthProxy/Configuration/BearerRoute.cs @@ -93,4 +93,17 @@ public class BearerRoute /// ; at most . /// public TimeSpan? ClockSkew { get; set; } + + /// + /// Gets or sets a value indicating whether this route accepts callers although the deployment requires identity + /// verification. Defaults to . + /// + /// + /// A bearer route never calls /.cratis/me: it has no browser session to verify. When any service + /// declares , every forwarded request is meant to carry a positive + /// verdict, so a bearer route in that deployment is refused at startup unless it sets this — stating that for + /// this route the validated token, its scopes and the claim requirements are the whole decision, and that the + /// backend answers for everything else. + /// + public bool AcceptWithoutIdentityVerification { get; set; } } From ae38965d58ff8c23c38bdbdc3c6e8b82e17756f7 Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 29 Sep 2026 07:48:53 +0200 Subject: [PATCH 07/24] Refuse bearer-route paths a backend could decode into another path A path on a bearer route that still carries percent-encoding after the server decoded it, a backslash, or a dot segment is refused with 400 before the token is read, so a principal is never vouched for at a path a backend would normalize into one that is not a bearer route. Specify prefix matching on case and segment boundaries alongside it. --- Documentation/configuration/authentication.md | 1 + Documentation/configuration/services.md | 6 +- .../when_bearer_route_paths_are_matched.cs | 78 +++++++++++++++++++ .../AuthProxy/BearerRoutes/BearerChallenge.cs | 10 +++ .../BearerRoutes/BearerRouteMiddleware.cs | 7 ++ .../BearerRoutes/BearerRouteTable.cs | 31 ++++++++ .../BearerRoutes/BearerRoutesLogging.cs | 3 + 7 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 Source/AuthProxy.Security.Specs/for_BearerRoutes/when_bearer_route_paths_are_matched.cs diff --git a/Documentation/configuration/authentication.md b/Documentation/configuration/authentication.md index 95b90f75..4ee5476e 100644 --- a/Documentation/configuration/authentication.md +++ b/Documentation/configuration/authentication.md @@ -581,6 +581,7 @@ bearer token. | Situation | Response | |-----------|----------| +| Path on the route still percent-encoded after decoding, or containing a backslash or a `.`/`..` segment | `400`, no challenge | | No bearer token (a browser session does not count) | `401`, `WWW-Authenticate: Bearer resource_metadata=""` | | Token invalid, expired, wrongly signed, from another issuer or for another audience | `401`, `WWW-Authenticate: Bearer error="invalid_token", resource_metadata="…"` | | Token lacks a required scope | `403`, `WWW-Authenticate: Bearer error="insufficient_scope", scope="", resource_metadata="…"` | diff --git a/Documentation/configuration/services.md b/Documentation/configuration/services.md index 2c49bc10..a1666a68 100644 --- a/Documentation/configuration/services.md +++ b/Documentation/configuration/services.md @@ -490,7 +490,11 @@ Cratis account id from the token's `sub` is still forwarded, as the `urn:cratis: answers for the rest. It is forwarded straight to the service **backend**, whichever of the service's endpoints would otherwise serve that path, and without a `Service-ID` header. - The session cookie is never read, and the `Cookie` header is not forwarded. -- Every refusal is an API-style `401`, `403` or `503` — never a redirect or a page. See +- A request whose path on the route still carries percent-encoding after the server has decoded it (such as an + encoded `/`), a backslash, or a `.` or `..` segment is refused with `400` before its token is read: a backend + that decoded or normalized it differently would receive a principal vouched for at a path that is not a + bearer route. +- Every refusal is an API-style `400`, `401`, `403` or `503` — never a redirect or a page. See [Bearer routes](authentication.md#bearer-routes-access-tokens-from-an-authorization-server). - A token from a bearer-route issuer is refused with `401` on every path that is **not** one of the issuer's bearer routes, even where another bearer scheme (the [JWT Bearer](authentication.md#jwt-bearer-api) handler) diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_bearer_route_paths_are_matched.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_bearer_route_paths_are_matched.cs new file mode 100644 index 00000000..373f5d72 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_bearer_route_paths_are_matched.cs @@ -0,0 +1,78 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.AspNetCore.Http; + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// A bearer route covers its prefix case-insensitively and on segment boundaries only: /mcpx and +/// /api/mcp are not /mcp, so a token presented there is refused as off its routes. A +/// path on a bearer route that a backend could decode or normalize into some other path — an encoded separator, any +/// other remaining percent-encoding, a backslash, a dot segment — is refused before the token is looked at, because +/// the principal forwarded with it would be vouched for at a path that is not a bearer route. +/// +/// Paths are set on the server-side request directly, as the server would hand them to the gate after decoding; +/// an would normalize some of them away first. +/// +/// +/// The running proxy, origin and issuer. +[Collection(BearerRouteSpecCollection.Name)] +public class when_bearer_route_paths_are_matched(BearerRouteHarness harness) : IAsyncLifetime +{ + HttpContext? _upperCase; + HttpContext? _exactPrefix; + HttpContext? _trailingSeparator; + HttpContext? _longerSegment; + HttpContext? _nestedUnderAnotherPath; + HttpContext? _encodedSeparator; + HttpContext? _doubleEncodedDot; + HttpContext? _dotSegment; + HttpContext? _backslash; + HttpContext? _backslashAfterThePrefix; + + public async Task InitializeAsync() + { + harness.Origin.Clear(); + + _upperCase = await Send("/MCP/upper-case"); + _exactPrefix = await Send(BearerRouteHarness.RoutePrefix); + _trailingSeparator = await Send($"{BearerRouteHarness.RoutePrefix}/"); + _longerSegment = await Send("/mcpx/tools"); + _nestedUnderAnotherPath = await Send("/api/mcp/tools"); + _encodedSeparator = await Send("/mcp/..%2Fapi/items"); + _doubleEncodedDot = await Send("/mcp/%2E%2E/api/items"); + _dotSegment = await Send("/mcp/../api/items"); + _backslash = await Send("/mcp/tools\\..\\..\\api\\items"); + _backslashAfterThePrefix = await Send("/mcp\\..\\api\\items"); + } + + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] public void should_match_the_prefix_case_insensitively() => Assert.Equal(StatusCodes.Status200OK, _upperCase!.Response.StatusCode); + [Fact] public void should_forward_the_case_insensitive_match() => Assert.True(harness.Origin.ReceivedAnythingFor("/MCP/upper-case")); + [Fact] public void should_match_the_prefix_itself() => Assert.Equal(StatusCodes.Status200OK, _exactPrefix!.Response.StatusCode); + [Fact] public void should_match_the_prefix_with_a_trailing_separator() => Assert.Equal(StatusCodes.Status200OK, _trailingSeparator!.Response.StatusCode); + [Fact] public void should_not_match_a_longer_segment() => Assert.Equal(StatusCodes.Status401Unauthorized, _longerSegment!.Response.StatusCode); + [Fact] public void should_refuse_the_token_off_its_routes_on_a_longer_segment() => Assert.Equal("Bearer error=\"invalid_token\"", _longerSegment!.Response.Headers.WWWAuthenticate.ToString()); + [Fact] public void should_not_match_the_prefix_under_another_path() => Assert.Equal(StatusCodes.Status401Unauthorized, _nestedUnderAnotherPath!.Response.StatusCode); + [Fact] public void should_refuse_an_encoded_separator() => Assert.Equal(StatusCodes.Status400BadRequest, _encodedSeparator!.Response.StatusCode); + [Fact] public void should_not_let_the_refusal_be_cached() => Assert.Equal("no-store", _encodedSeparator!.Response.Headers.CacheControl.ToString()); + [Fact] public void should_refuse_without_a_challenge() => Assert.False(_encodedSeparator!.Response.Headers.ContainsKey("WWW-Authenticate")); + [Fact] public void should_refuse_remaining_percent_encoding() => Assert.Equal(StatusCodes.Status400BadRequest, _doubleEncodedDot!.Response.StatusCode); + [Fact] public void should_refuse_a_dot_segment() => Assert.Equal(StatusCodes.Status400BadRequest, _dotSegment!.Response.StatusCode); + [Fact] public void should_refuse_a_backslash() => Assert.Equal(StatusCodes.Status400BadRequest, _backslash!.Response.StatusCode); + [Fact] public void should_not_match_the_prefix_followed_by_a_backslash() => Assert.Equal(StatusCodes.Status401Unauthorized, _backslashAfterThePrefix!.Response.StatusCode); + [Fact] public void should_forward_none_of_the_refused_paths() => Assert.DoesNotContain(harness.Origin.Received, _ => _.Path.Contains("api", StringComparison.OrdinalIgnoreCase) || _.Path.Contains("mcpx", StringComparison.OrdinalIgnoreCase)); + + Task Send(string path) + { + var token = harness.Issuer.Token(); + return harness.Server.SendAsync(context => + { + context.Request.Method = HttpMethods.Get; + context.Request.Path = new PathString(path); + context.Request.Headers.Authorization = $"Bearer {token}"; + }); + } +} diff --git a/Source/AuthProxy/BearerRoutes/BearerChallenge.cs b/Source/AuthProxy/BearerRoutes/BearerChallenge.cs index da3f8d7b..b332df3b 100644 --- a/Source/AuthProxy/BearerRoutes/BearerChallenge.cs +++ b/Source/AuthProxy/BearerRoutes/BearerChallenge.cs @@ -83,6 +83,16 @@ public static void Forbidden(HttpContext context) context.Response.StatusCode = StatusCodes.Status403Forbidden; } + /// + /// Writes a bare 400: the request path is not one AuthProxy will vouch for a principal on. + /// + /// The current . + public static void BadRequest(HttpContext context) + { + NoStore(context); + context.Response.StatusCode = StatusCodes.Status400BadRequest; + } + /// /// Builds the value of a WWW-Authenticate bearer challenge. /// diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteMiddleware.cs b/Source/AuthProxy/BearerRoutes/BearerRouteMiddleware.cs index c0c25801..2b94a5ea 100644 --- a/Source/AuthProxy/BearerRoutes/BearerRouteMiddleware.cs +++ b/Source/AuthProxy/BearerRoutes/BearerRouteMiddleware.cs @@ -64,6 +64,13 @@ public async Task InvokeAsync(HttpContext context) if (BearerRouteTable.TryMatch(context.Request.Path, current, out var route)) { + if (!BearerRouteTable.IsUnambiguous(context.Request.Path)) + { + logger.BearerRoutePathAmbiguous(route.Prefix, route.ServiceName); + BearerChallenge.BadRequest(context); + return; + } + await Authenticate(context, route, current); return; } diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteTable.cs b/Source/AuthProxy/BearerRoutes/BearerRouteTable.cs index b42801ef..2e03d955 100644 --- a/Source/AuthProxy/BearerRoutes/BearerRouteTable.cs +++ b/Source/AuthProxy/BearerRoutes/BearerRouteTable.cs @@ -77,6 +77,37 @@ public static bool TryMatch(PathString path, C.AuthProxy config, out ResolvedBea return false; } + /// + /// Determines whether a request path means the same thing to AuthProxy and to any backend that reads it. + /// + /// The request path, as decoded by the server. + /// when the path carries nothing a backend could decode or normalize differently; otherwise . + /// + /// The server decodes a path before the gate sees it, except for an encoded /, and a double-encoded + /// character arrives still encoded. Either leaves a % in the path, as does anything else the + /// backend might decode once more. A backslash is a separator to some servers, and a dot segment is removed by + /// most. A backend that decoded /mcp/..%2Fapi into /api would receive a principal + /// vouched for on a bearer route at a path that is not one, so a bearer route accepts none of these. + /// + public static bool IsUnambiguous(PathString path) + { + var value = path.Value ?? string.Empty; + if (value.Contains('%', StringComparison.Ordinal) || value.Contains('\\', StringComparison.Ordinal)) + { + return false; + } + + foreach (var segment in value.Split('/')) + { + if (string.Equals(segment, ".", StringComparison.Ordinal) || string.Equals(segment, "..", StringComparison.Ordinal)) + { + return false; + } + } + + return true; + } + /// /// Finds the bearer route whose protected-resource metadata document a request path names. /// diff --git a/Source/AuthProxy/BearerRoutes/BearerRoutesLogging.cs b/Source/AuthProxy/BearerRoutes/BearerRoutesLogging.cs index e57ca100..c87463e1 100644 --- a/Source/AuthProxy/BearerRoutes/BearerRoutesLogging.cs +++ b/Source/AuthProxy/BearerRoutes/BearerRoutesLogging.cs @@ -17,6 +17,9 @@ internal static partial class BearerRoutesLogging [LoggerMessage(LogLevel.Information, "A token from bearer-route issuer '{Issuer}' was presented on {Path}, which is not one of its bearer routes. Refused.")] internal static partial void BearerTokenOutsideItsRoutes(this ILogger logger, string issuer, string path); + [LoggerMessage(LogLevel.Information, "A request on bearer route '{Route}' of service '{Service}' has an encoded character, a backslash or a dot segment in its path. Refused.")] + internal static partial void BearerRoutePathAmbiguous(this ILogger logger, string route, string service); + [LoggerMessage(LogLevel.Information, "A valid bearer token on route '{Route}' of service '{Service}' does not satisfy the required claim '{Claim}'. Refused.")] internal static partial void BearerAccessDenied(this ILogger logger, string route, string service, string claim); From f9bad9953ca28990449c9caf2154ac69450681b1 Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 29 Sep 2026 07:48:53 +0200 Subject: [PATCH 08/24] Specify that ID, HMAC, expiry-less and encrypted tokens are refused Let the stub issuer reshape a token and sign one with its public key as an HMAC secret, and specify end to end that tokens of an unacceptable shape, missing a mapped claim or naming an unusable tenant are refused. --- ...token_is_not_an_acceptable_access_token.cs | 72 +++++++++++++++++++ .../given/StubIssuer.cs | 40 +++++++++-- 2 files changed, 108 insertions(+), 4 deletions(-) create mode 100644 Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_is_not_an_acceptable_access_token.cs diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_is_not_an_acceptable_access_token.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_is_not_an_acceptable_access_token.cs new file mode 100644 index 00000000..74f57d0f --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_is_not_an_acceptable_access_token.cs @@ -0,0 +1,72 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Security.Cryptography; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// Everything the validation rules say about the token's shape holds end to end: an ID token (any +/// typ but an access-token type), an HMAC token keyed with the issuer's public key, a token without an +/// expiry, an encrypted token and a token missing a claim a mapping reads are refused as invalid; a token naming +/// more than one tenant, or a tenant that is not a plain identifier, is refused as forbidden. None is forwarded. +/// +/// The running proxy, origin and issuer. +[Collection(BearerRouteSpecCollection.Name)] +public class when_the_token_is_not_an_acceptable_access_token(BearerRouteHarness harness) : IAsyncLifetime +{ + const string Path = $"{BearerRouteHarness.RoutePrefix}/unacceptable"; + + HttpResponseMessage? _idToken; + HttpResponseMessage? _publicKeyAsSecret; + HttpResponseMessage? _withoutExpiry; + HttpResponseMessage? _encrypted; + HttpResponseMessage? _withoutMappedSource; + HttpResponseMessage? _twoTenants; + HttpResponseMessage? _unusableTenant; + bool _forwarded; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + + _idToken = await Send(client, harness.Issuer.TokenShaped(_ => _.TokenType = JwtConstants.HeaderType)); + _publicKeyAsSecret = await Send(client, harness.Issuer.TokenSignedWithThePublicKeyAsASecret()); + _withoutExpiry = await Send(client, harness.Issuer.TokenShaped(_ => _.Expires = null, setDefaultTimes: false)); + _encrypted = await Send(client, harness.Issuer.TokenShaped(_ => _.EncryptingCredentials = new EncryptingCredentials( + new SymmetricSecurityKey(RandomNumberGenerator.GetBytes(64)), + JwtConstants.DirectKeyUseAlg, + SecurityAlgorithms.Aes256CbcHmacSha512))); + _withoutMappedSource = await Send(client, harness.Issuer.Token(without: "github_id")); + _twoTenants = await Send(client, harness.Issuer.Token(new Dictionary { ["tid"] = new[] { BearerRouteHarness.TenantId, "33333333-3333-3333-3333-333333333333" } })); + _unusableTenant = await Send(client, harness.Issuer.Token(new Dictionary { ["tid"] = "../other tenant" })); + _forwarded = harness.Origin.ReceivedAnythingFor(Path); + } + + public Task DisposeAsync() + { + _idToken?.Dispose(); + _publicKeyAsSecret?.Dispose(); + _withoutExpiry?.Dispose(); + _encrypted?.Dispose(); + _withoutMappedSource?.Dispose(); + _twoTenants?.Dispose(); + _unusableTenant?.Dispose(); + return Task.CompletedTask; + } + + [Fact] public void should_refuse_an_id_token() => Assert.Equal(HttpStatusCode.Unauthorized, _idToken!.StatusCode); + [Fact] public void should_refuse_an_hmac_token_keyed_with_the_public_key() => Assert.Equal(HttpStatusCode.Unauthorized, _publicKeyAsSecret!.StatusCode); + [Fact] public void should_refuse_a_token_without_an_expiry() => Assert.Equal(HttpStatusCode.Unauthorized, _withoutExpiry!.StatusCode); + [Fact] public void should_refuse_an_encrypted_token() => Assert.Equal(HttpStatusCode.Unauthorized, _encrypted!.StatusCode); + [Fact] public void should_refuse_a_token_missing_a_mapped_claim() => Assert.Equal(HttpStatusCode.Unauthorized, _withoutMappedSource!.StatusCode); + [Fact] public void should_refuse_a_token_naming_two_tenants() => Assert.Equal(HttpStatusCode.Forbidden, _twoTenants!.StatusCode); + [Fact] public void should_refuse_a_tenant_that_is_not_a_plain_identifier() => Assert.Equal(HttpStatusCode.Forbidden, _unusableTenant!.StatusCode); + [Fact] public void should_forward_none_of_them() => Assert.False(_forwarded); + + static Task Send(HttpClient client, string token) => + client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, Path, token)); +} diff --git a/Source/AuthProxy.Security.Specs/given/StubIssuer.cs b/Source/AuthProxy.Security.Specs/given/StubIssuer.cs index 0d3a3e71..5d641fd1 100644 --- a/Source/AuthProxy.Security.Specs/given/StubIssuer.cs +++ b/Source/AuthProxy.Security.Specs/given/StubIssuer.cs @@ -112,6 +112,26 @@ public string Token( params string[] without) => Sign(new RsaSecurityKey(_rsa) { KeyId = KeyId }, claims, audience, expires, issuer, without); + /// + /// Signs an access token with the published key, then lets the caller reshape it before it is written. + /// + /// Changes the descriptor: the type, the signing or encrypting credentials, the lifetime. + /// Whether the handler fills in lifetimes the descriptor leaves unset. + /// The token. + public string TokenShaped(Action shape, bool setDefaultTimes = true) => + Sign(new RsaSecurityKey(_rsa) { KeyId = KeyId }, null, BearerRouteHarness.Audience, null, null, [], shape, setDefaultTimes); + + /// + /// Signs a token with HMAC, keyed with this issuer's published public key — the algorithm-confusion attack on + /// a validator that lets the token pick its algorithm. + /// + /// The token. + public string TokenSignedWithThePublicKeyAsASecret() + { + var publicKey = _rsa.ExportSubjectPublicKeyInfo(); + return Sign(new SymmetricSecurityKey(publicKey) { KeyId = KeyId }, null, BearerRouteHarness.Audience, null, null, [], algorithm: SecurityAlgorithms.HmacSha256); + } + /// /// Signs an access token with a key this issuer does not publish, but names the published key id. /// @@ -168,7 +188,16 @@ public async ValueTask DisposeAsync() _rsa.Dispose(); } - string Sign(SecurityKey key, IDictionary? claims, string audience, DateTime? expires, string? issuer, string[] without) + string Sign( + SecurityKey key, + IDictionary? claims, + string audience, + DateTime? expires, + string? issuer, + string[] without, + Action? shape = null, + bool setDefaultTimes = true, + string algorithm = SecurityAlgorithms.RsaSha256) { var merged = DefaultClaims(); foreach (var type in without) @@ -184,7 +213,7 @@ string Sign(SecurityKey key, IDictionary? claims, string audienc var expiry = expires ?? DateTime.UtcNow.AddMinutes(15); var issuedAt = expiry.AddMinutes(-15); - return new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor + var descriptor = new SecurityTokenDescriptor { Issuer = issuer ?? Issuer, Audience = audience, @@ -193,7 +222,10 @@ string Sign(SecurityKey key, IDictionary? claims, string audienc NotBefore = issuedAt, Expires = expiry, TokenType = "at+jwt", - SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.RsaSha256), - }); + SigningCredentials = new SigningCredentials(key, algorithm), + }; + shape?.Invoke(descriptor); + + return new JsonWebTokenHandler { SetDefaultTimesOnTokenCreation = setDefaultTimes }.CreateToken(descriptor); } } From 0ab7adc6e563f3bd59908753849659f501b76ba9 Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 29 Sep 2026 07:48:53 +0200 Subject: [PATCH 09/24] Specify what bearer and browser routes forward to the backend Spoofed token headers are stripped on browser routes, role and AuthProxy-namespace claims in a token are dropped, and a route that forwards the Authorization header still keeps cookies at the edge. --- ..._headers_are_spoofed_on_a_browser_route.cs | 46 +++++++++++++++ ...route_forwards_the_authorization_header.cs | 38 +++++++++++++ ...token_carries_claims_it_may_not_forward.cs | 57 +++++++++++++++++++ 3 files changed, 141 insertions(+) create mode 100644 Source/AuthProxy.Security.Specs/for_BearerRoutes/when_identity_headers_are_spoofed_on_a_browser_route.cs create mode 100644 Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_route_forwards_the_authorization_header.cs create mode 100644 Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_carries_claims_it_may_not_forward.cs diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_identity_headers_are_spoofed_on_a_browser_route.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_identity_headers_are_spoofed_on_a_browser_route.cs new file mode 100644 index 00000000..dec97d6f --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_identity_headers_are_spoofed_on_a_browser_route.cs @@ -0,0 +1,46 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// The token headers mean "authenticated on a bearer route", so only a bearer route may send them. On a browser +/// route a caller's own copies are removed with every other identity header, and the principal forwarded is the +/// session's. +/// +/// The running proxy, origin and issuer. +[Collection(BearerRouteSpecCollection.Name)] +public class when_identity_headers_are_spoofed_on_a_browser_route(BearerRouteHarness harness) : IAsyncLifetime +{ + const string Path = "/api/spoofed"; + + HttpResponseMessage? _response; + ForwardedRequest? _forwarded; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + + var request = SecurityHarness.Authenticated(HttpMethod.Get, Path, "browser-user"); + request.Headers.TryAddWithoutValidation(Headers.TokenScope, "direct:admin"); + request.Headers.TryAddWithoutValidation(Headers.TokenClientId, "trusted-client"); + request.Headers.TryAddWithoutValidation(Headers.PrincipalId, "attacker"); + request.Headers.TryAddWithoutValidation(Headers.TenantId, "victim-tenant"); + + _response = await client.SendAsync(request); + _forwarded = harness.Origin.LastRequestTo(Path); + } + + public Task DisposeAsync() + { + _response?.Dispose(); + return Task.CompletedTask; + } + + [Fact] public void should_forward_the_request() => Assert.NotNull(_forwarded); + [Fact] public void should_not_forward_the_scope_header() => Assert.False(_forwarded!.Has(Headers.TokenScope)); + [Fact] public void should_not_forward_the_client_header() => Assert.False(_forwarded!.Has(Headers.TokenClientId)); + [Fact] public void should_forward_the_session_principal() => Assert.Equal("browser-user", _forwarded!.Value(Headers.PrincipalId)); + [Fact] public void should_forward_the_session_tenant() => Assert.Equal(BearerRouteHarness.SessionTenantId, _forwarded!.Value(Headers.TenantId)); +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_route_forwards_the_authorization_header.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_route_forwards_the_authorization_header.cs new file mode 100644 index 00000000..619533a9 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_route_forwards_the_authorization_header.cs @@ -0,0 +1,38 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// A route that sets ForwardAuthorizationHeader sends the token on to the backend alongside the +/// principal AuthProxy vouches for. It is the only thing that changes: cookies still stay at the edge. +/// +/// The running proxy, origin and issuer. +[Collection(BearerRouteSpecCollection.Name)] +public class when_the_route_forwards_the_authorization_header(BearerRouteHarness harness) : IAsyncLifetime +{ + const string Path = $"{BearerRouteHarness.ForwardingRoutePrefix}/items"; + + string _token = string.Empty; + ForwardedRequest? _forwarded; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + + _token = harness.Issuer.Token(); + var request = BearerRouteHarness.WithToken(HttpMethod.Get, Path, _token); + request.Headers.TryAddWithoutValidation("Cookie", ".cratis-session=some-session"); + + using var response = await client.SendAsync(request); + _forwarded = harness.Origin.LastRequestTo(Path); + } + + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] public void should_forward_the_request() => Assert.NotNull(_forwarded); + [Fact] public void should_forward_the_token() => Assert.Equal($"Bearer {_token}", _forwarded!.Value("Authorization")); + [Fact] public void should_still_forward_the_principal() => Assert.Equal(BearerRouteHarness.AccountId, _forwarded!.Value(Headers.PrincipalId)); + [Fact] public void should_not_forward_the_cookie() => Assert.False(_forwarded!.Has("Cookie")); +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_carries_claims_it_may_not_forward.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_carries_claims_it_may_not_forward.cs new file mode 100644 index 00000000..2d935280 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_carries_claims_it_may_not_forward.cs @@ -0,0 +1,57 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.Identity; + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// A token cannot grant a role or speak in AuthProxy's own claim namespaces: its role claims are dropped, the only +/// roles forwarded are anonymous and authenticated, and urn:cratis:bearer: +/// and urn:cratis:identity: claims carry only what AuthProxy itself wrote. A token that names no client +/// forwards no client header, whatever the caller sent. +/// +/// The running proxy, origin and issuer. +[Collection(BearerRouteSpecCollection.Name)] +public class when_the_token_carries_claims_it_may_not_forward(BearerRouteHarness harness) : IAsyncLifetime +{ + const string Path = $"{BearerRouteHarness.RoutePrefix}/claims"; + + ForwardedRequest? _forwarded; + ClientPrincipal? _principal; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + + var token = harness.Issuer.Token( + new Dictionary + { + ["roles"] = new[] { "Administrator" }, + ["role"] = "Administrator", + ["urn:cratis:bearer:scope"] = "direct:admin", + ["urn:cratis:bearer:client-id"] = "trusted-client", + ["urn:cratis:identity:subject"] = "someone-else", + }, + without: ["azp", "client_id"]); + + var request = BearerRouteHarness.WithToken(HttpMethod.Get, Path, token); + request.Headers.TryAddWithoutValidation(Headers.TokenClientId, "trusted-client"); + + using var response = await client.SendAsync(request); + _forwarded = harness.Origin.LastRequestTo(Path); + ClientPrincipal.TryFromBase64(_forwarded?.Value(Headers.Principal), out _principal); + } + + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] public void should_forward_the_request() => Assert.NotNull(_principal); + [Fact] public void should_grant_no_role() => Assert.Equal(["anonymous", "authenticated"], _principal!.UserRoles); + [Fact] public void should_drop_the_role_claims() => Assert.DoesNotContain(_principal!.Claims, _ => string.Equals(_.Type, "roles", StringComparison.Ordinal) || string.Equals(_.Type, "role", StringComparison.Ordinal) || _.Type.EndsWith("/role", StringComparison.Ordinal)); + [Fact] public void should_forward_only_the_scopes_it_validated() => + Assert.Equal(["direct:read", "direct:work"], _principal!.Claims.Where(_ => _.Type == "urn:cratis:bearer:scope").Select(_ => _.Value).Order()); + [Fact] public void should_forward_no_client_claim() => Assert.DoesNotContain(_principal!.Claims, _ => _.Type == "urn:cratis:bearer:client-id"); + [Fact] public void should_drop_the_canonical_identity_claims() => Assert.DoesNotContain(_principal!.Claims, _ => _.Type.StartsWith("urn:cratis:identity:", StringComparison.Ordinal)); + [Fact] public void should_forward_no_client_header() => Assert.False(_forwarded!.Has(Headers.TokenClientId)); +} From f344ae403d059cb852a1837347ca5cfb0f11d713 Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 29 Sep 2026 07:48:53 +0200 Subject: [PATCH 10/24] Specify metadata methods, unreachable issuers and startup refusals The protected-resource metadata path serves only GET and HEAD, exactly; an unreachable issuer yields 503 with Retry-After and no challenge; and a misconfigured bearer route stops the host from starting. --- .../when_a_bearer_route_is_misconfigured.cs | 41 ++++++++++ .../when_the_issuer_cannot_be_reached.cs | 43 +++++++++++ ...esource_metadata_is_requested_otherwise.cs | 74 +++++++++++++++++++ 3 files changed, 158 insertions(+) create mode 100644 Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_bearer_route_is_misconfigured.cs create mode 100644 Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_issuer_cannot_be_reached.cs create mode 100644 Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_resource_metadata_is_requested_otherwise.cs diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_bearer_route_is_misconfigured.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_bearer_route_is_misconfigured.cs new file mode 100644 index 00000000..1cfbd6bf --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_bearer_route_is_misconfigured.cs @@ -0,0 +1,41 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Extensions.Options; + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// A bearer route AuthProxy could not enforce as written stops the host from starting, rather than silently leaving +/// its path on the browser-session model: a route overlapping an anonymous path, repeating another route's prefix, +/// or naming no issuer or audience is named in the startup failure. +/// +public class when_a_bearer_route_is_misconfigured : IDisposable +{ + readonly MisconfiguredHarness _harness = new(); + readonly Exception? _startup; + + public when_a_bearer_route_is_misconfigured() => + _startup = Record.Exception(() => _harness.CreateClient().Dispose()); + + [Fact] public void should_refuse_to_start() => Assert.IsType(_startup); + [Fact] public void should_name_the_overlap_with_an_anonymous_path() => Assert.Contains("overlaps the anonymous path", _startup!.Message, StringComparison.Ordinal); + [Fact] public void should_name_the_repeated_prefix() => Assert.Contains("is already a bearer route", _startup!.Message, StringComparison.Ordinal); + [Fact] public void should_name_the_missing_issuer() => Assert.Contains($"{nameof(C.BearerRoute.Issuers)} is empty", _startup!.Message, StringComparison.Ordinal); + [Fact] public void should_name_the_missing_audience() => Assert.Contains($"{nameof(C.BearerRoute.Audiences)} is empty", _startup!.Message, StringComparison.Ordinal); + + public void Dispose() + { + _harness.Dispose(); + GC.SuppressFinalize(this); + } + + sealed class MisconfiguredHarness : BearerRouteHarness + { + protected override void AddSettings(IDictionary settings) + { + settings[$"{C.AuthProxy.SectionKey}:Services:app:AnonymousPaths:0"] = $"{RoutePrefix}/public"; + settings[$"{C.AuthProxy.SectionKey}:Services:app:BearerRoutes:3:PathPrefix"] = ForwardingRoutePrefix; + } + } +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_issuer_cannot_be_reached.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_issuer_cannot_be_reached.cs new file mode 100644 index 00000000..8e5dc413 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_issuer_cannot_be_reached.cs @@ -0,0 +1,43 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// A token that cannot be checked is not a token that is wrong. When the issuer's metadata and keys cannot be +/// retrieved the request is refused with 503 and a Retry-After, never forwarded, and +/// not answered with an invalid_token challenge that would send the client to sign in again. +/// +/// The running proxy, origin and issuer. +[Collection(BearerRouteSpecCollection.Name)] +public class when_the_issuer_cannot_be_reached(BearerRouteHarness harness) : IAsyncLifetime +{ + const string Path = $"{BearerRouteHarness.UnreachableRoutePrefix}/items"; + + HttpResponseMessage? _response; + bool _forwarded; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + + _response = await client.SendAsync(BearerRouteHarness.WithToken( + HttpMethod.Get, + Path, + harness.Issuer.Token(issuer: BearerRouteHarness.UnreachableIssuer))); + _forwarded = harness.Origin.ReceivedAnythingFor(Path); + } + + public Task DisposeAsync() + { + _response?.Dispose(); + return Task.CompletedTask; + } + + [Fact] public void should_answer_service_unavailable() => Assert.Equal(HttpStatusCode.ServiceUnavailable, _response!.StatusCode); + [Fact] public void should_ask_the_client_to_retry_later() => Assert.Equal(TimeSpan.FromSeconds(30), _response!.Headers.RetryAfter?.Delta); + [Fact] public void should_not_challenge() => Assert.Empty(_response!.Headers.WwwAuthenticate); + [Fact] public void should_not_be_cached() => Assert.True(_response!.Headers.CacheControl?.NoStore); + [Fact] public void should_not_forward_anything() => Assert.False(_forwarded); +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_resource_metadata_is_requested_otherwise.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_resource_metadata_is_requested_otherwise.cs new file mode 100644 index 00000000..dfc434de --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_resource_metadata_is_requested_otherwise.cs @@ -0,0 +1,74 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// The protected-resource metadata path is the one path of a bearer route served without a token, so it is exactly +/// that path and nothing more: read with GET or HEAD, matched case-insensitively and +/// with or without a trailing separator, and every other method refused with 405 without reaching +/// the backend. Nothing beneath the path is served without a session. +/// +/// The running proxy, origin and issuer. +[Collection(BearerRouteSpecCollection.Name)] +public class when_the_resource_metadata_is_requested_otherwise(BearerRouteHarness harness) : IAsyncLifetime +{ + const string UpperCasePath = "/.WELL-KNOWN/oauth-protected-resource/MCP"; + const string BeneathPath = $"{BearerRouteHarness.ResourceMetadataPath}/beneath"; + + HttpResponseMessage? _head; + HttpResponseMessage? _put; + HttpResponseMessage? _delete; + HttpResponseMessage? _options; + HttpResponseMessage? _upperCase; + HttpResponseMessage? _trailingSeparator; + HttpResponseMessage? _beneath; + bool _headForwarded; + bool _writesForwarded; + bool _beneathForwarded; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + + _head = await client.SendAsync(new HttpRequestMessage(HttpMethod.Head, BearerRouteHarness.ResourceMetadataPath)); + _headForwarded = harness.Origin.ReceivedAnythingFor(BearerRouteHarness.ResourceMetadataPath); + + harness.Origin.Clear(); + _put = await client.SendAsync(new HttpRequestMessage(HttpMethod.Put, BearerRouteHarness.ResourceMetadataPath)); + _delete = await client.SendAsync(new HttpRequestMessage(HttpMethod.Delete, BearerRouteHarness.ResourceMetadataPath)); + _options = await client.SendAsync(new HttpRequestMessage(HttpMethod.Options, BearerRouteHarness.ResourceMetadataPath)); + _writesForwarded = harness.Origin.ReceivedAnythingFor(BearerRouteHarness.ResourceMetadataPath); + + _upperCase = await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, UpperCasePath)); + _trailingSeparator = await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, $"{BearerRouteHarness.ResourceMetadataPath}/")); + + _beneath = await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, BeneathPath)); + _beneathForwarded = harness.Origin.ReceivedAnythingFor(BeneathPath); + } + + public Task DisposeAsync() + { + _head?.Dispose(); + _put?.Dispose(); + _delete?.Dispose(); + _options?.Dispose(); + _upperCase?.Dispose(); + _trailingSeparator?.Dispose(); + _beneath?.Dispose(); + return Task.CompletedTask; + } + + [Fact] public void should_serve_head() => Assert.Equal(HttpStatusCode.OK, _head!.StatusCode); + [Fact] public void should_forward_head() => Assert.True(_headForwarded); + [Fact] public void should_refuse_put() => Assert.Equal(HttpStatusCode.MethodNotAllowed, _put!.StatusCode); + [Fact] public void should_refuse_delete() => Assert.Equal(HttpStatusCode.MethodNotAllowed, _delete!.StatusCode); + [Fact] public void should_refuse_options() => Assert.Equal(HttpStatusCode.MethodNotAllowed, _options!.StatusCode); + [Fact] public void should_name_the_methods_it_allows() => Assert.Equal("GET, HEAD", _put!.Content.Headers.Allow.Count > 0 ? string.Join(", ", _put.Content.Headers.Allow) : string.Empty); + [Fact] public void should_not_forward_other_methods() => Assert.False(_writesForwarded); + [Fact] public void should_match_case_insensitively() => Assert.Equal(HttpStatusCode.OK, _upperCase!.StatusCode); + [Fact] public void should_match_with_a_trailing_separator() => Assert.Equal(HttpStatusCode.OK, _trailingSeparator!.StatusCode); + [Fact] public void should_not_serve_anything_beneath_it_without_a_session() => Assert.False(_beneathForwarded); + [Fact] public void should_not_answer_beneath_it_with_success() => Assert.NotEqual(HttpStatusCode.OK, _beneath!.StatusCode); +} From 1528b811f0785e56f029101985fedacc303663d7 Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 29 Sep 2026 08:00:47 +0200 Subject: [PATCH 11/24] Refuse bearer-route paths carrying a path parameter A ';' starts a path parameter that Tomcat, Jetty and Spring strip before resolving dot segments, so /mcp/..;/api/items matched /mcp here and reached the backend as /api/items with a principal vouched for on the bearer route. Any ';' on a bearer-route path is now refused with 400, the same rule a declared prefix is held to. Refs #143 --- Documentation/configuration/authentication.md | 2 +- Documentation/configuration/services.md | 7 ++++--- .../when_bearer_route_paths_are_matched.cs | 13 ++++++++++++- Source/AuthProxy/BearerRoutes/BearerRouteTable.cs | 11 ++++++++--- .../AuthProxy/BearerRoutes/BearerRoutesLogging.cs | 2 +- 5 files changed, 26 insertions(+), 9 deletions(-) diff --git a/Documentation/configuration/authentication.md b/Documentation/configuration/authentication.md index 4ee5476e..e2c49f09 100644 --- a/Documentation/configuration/authentication.md +++ b/Documentation/configuration/authentication.md @@ -581,7 +581,7 @@ bearer token. | Situation | Response | |-----------|----------| -| Path on the route still percent-encoded after decoding, or containing a backslash or a `.`/`..` segment | `400`, no challenge | +| Path on the route still percent-encoded after decoding, or containing a backslash, a `;` (path parameter, as in `/mcp/..;/api`) or a `.`/`..` segment | `400`, no challenge | | No bearer token (a browser session does not count) | `401`, `WWW-Authenticate: Bearer resource_metadata=""` | | Token invalid, expired, wrongly signed, from another issuer or for another audience | `401`, `WWW-Authenticate: Bearer error="invalid_token", resource_metadata="…"` | | Token lacks a required scope | `403`, `WWW-Authenticate: Bearer error="insufficient_scope", scope="", resource_metadata="…"` | diff --git a/Documentation/configuration/services.md b/Documentation/configuration/services.md index a1666a68..d0b5f072 100644 --- a/Documentation/configuration/services.md +++ b/Documentation/configuration/services.md @@ -491,9 +491,10 @@ Cratis account id from the token's `sub` is still forwarded, as the `urn:cratis: whichever of the service's endpoints would otherwise serve that path, and without a `Service-ID` header. - The session cookie is never read, and the `Cookie` header is not forwarded. - A request whose path on the route still carries percent-encoding after the server has decoded it (such as an - encoded `/`), a backslash, or a `.` or `..` segment is refused with `400` before its token is read: a backend - that decoded or normalized it differently would receive a principal vouched for at a path that is not a - bearer route. + encoded `/`), a backslash, a `;` anywhere in it, or a `.` or `..` segment is refused with `400` before its token + is read: a backend that decoded or normalized it differently would receive a principal vouched for at a path + that is not a bearer route. The `;` starts a path parameter, which Tomcat, Jetty and Spring strip before + resolving dot segments, so they read `/mcp/..;/api/items` as `/api/items`. - Every refusal is an API-style `400`, `401`, `403` or `503` — never a redirect or a page. See [Bearer routes](authentication.md#bearer-routes-access-tokens-from-an-authorization-server). - A token from a bearer-route issuer is refused with `401` on every path that is **not** one of the issuer's diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_bearer_route_paths_are_matched.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_bearer_route_paths_are_matched.cs index 373f5d72..b804dbf0 100644 --- a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_bearer_route_paths_are_matched.cs +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_bearer_route_paths_are_matched.cs @@ -9,7 +9,8 @@ namespace Cratis.AuthProxy.Security.for_BearerRoutes; /// A bearer route covers its prefix case-insensitively and on segment boundaries only: /mcpx and /// /api/mcp are not /mcp, so a token presented there is refused as off its routes. A /// path on a bearer route that a backend could decode or normalize into some other path — an encoded separator, any -/// other remaining percent-encoding, a backslash, a dot segment — is refused before the token is looked at, because +/// other remaining percent-encoding, a backslash, a dot segment, a path parameter (;), which +/// Tomcat, Jetty and Spring strip before resolving the dot segment it hides in — is refused before the token is looked at, because /// the principal forwarded with it would be vouched for at a path that is not a bearer route. /// /// Paths are set on the server-side request directly, as the server would hand them to the gate after decoding; @@ -30,6 +31,9 @@ public class when_bearer_route_paths_are_matched(BearerRouteHarness harness) : I HttpContext? _dotSegment; HttpContext? _backslash; HttpContext? _backslashAfterThePrefix; + HttpContext? _parentSegmentWithPathParameter; + HttpContext? _currentSegmentWithPathParameter; + HttpContext? _pathParameterOnAnOrdinarySegment; public async Task InitializeAsync() { @@ -45,6 +49,9 @@ public async Task InitializeAsync() _dotSegment = await Send("/mcp/../api/items"); _backslash = await Send("/mcp/tools\\..\\..\\api\\items"); _backslashAfterThePrefix = await Send("/mcp\\..\\api\\items"); + _parentSegmentWithPathParameter = await Send("/mcp/..;/api/items"); + _currentSegmentWithPathParameter = await Send("/mcp/.;/x"); + _pathParameterOnAnOrdinarySegment = await Send("/mcp/tools;jsessionid=abc"); } public Task DisposeAsync() => Task.CompletedTask; @@ -63,6 +70,10 @@ public async Task InitializeAsync() [Fact] public void should_refuse_a_dot_segment() => Assert.Equal(StatusCodes.Status400BadRequest, _dotSegment!.Response.StatusCode); [Fact] public void should_refuse_a_backslash() => Assert.Equal(StatusCodes.Status400BadRequest, _backslash!.Response.StatusCode); [Fact] public void should_not_match_the_prefix_followed_by_a_backslash() => Assert.Equal(StatusCodes.Status401Unauthorized, _backslashAfterThePrefix!.Response.StatusCode); + [Fact] public void should_refuse_a_parent_segment_hidden_by_a_path_parameter() => Assert.Equal(StatusCodes.Status400BadRequest, _parentSegmentWithPathParameter!.Response.StatusCode); + [Fact] public void should_refuse_a_current_segment_hidden_by_a_path_parameter() => Assert.Equal(StatusCodes.Status400BadRequest, _currentSegmentWithPathParameter!.Response.StatusCode); + [Fact] public void should_refuse_a_path_parameter_on_any_segment() => Assert.Equal(StatusCodes.Status400BadRequest, _pathParameterOnAnOrdinarySegment!.Response.StatusCode); + [Fact] public void should_forward_none_of_the_path_parameter_paths() => Assert.DoesNotContain(harness.Origin.Received, _ => _.Path.Contains(';', StringComparison.Ordinal)); [Fact] public void should_forward_none_of_the_refused_paths() => Assert.DoesNotContain(harness.Origin.Received, _ => _.Path.Contains("api", StringComparison.OrdinalIgnoreCase) || _.Path.Contains("mcpx", StringComparison.OrdinalIgnoreCase)); Task Send(string path) diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteTable.cs b/Source/AuthProxy/BearerRoutes/BearerRouteTable.cs index 2e03d955..b6db656c 100644 --- a/Source/AuthProxy/BearerRoutes/BearerRouteTable.cs +++ b/Source/AuthProxy/BearerRoutes/BearerRouteTable.cs @@ -86,13 +86,18 @@ public static bool TryMatch(PathString path, C.AuthProxy config, out ResolvedBea /// The server decodes a path before the gate sees it, except for an encoded /, and a double-encoded /// character arrives still encoded. Either leaves a % in the path, as does anything else the /// backend might decode once more. A backslash is a separator to some servers, and a dot segment is removed by - /// most. A backend that decoded /mcp/..%2Fapi into /api would receive a principal - /// vouched for on a bearer route at a path that is not one, so a bearer route accepts none of these. + /// most. A semicolon starts a path parameter, which some servers strip before resolving dot segments — Tomcat, + /// Jetty and Spring read /mcp/..;/api as /api — and others keep, the same + /// rule applies to a declared prefix. A backend that decoded + /// /mcp/..%2Fapi into /api would receive a principal vouched for on a bearer route + /// at a path that is not one, so a bearer route accepts none of these. /// public static bool IsUnambiguous(PathString path) { var value = path.Value ?? string.Empty; - if (value.Contains('%', StringComparison.Ordinal) || value.Contains('\\', StringComparison.Ordinal)) + if (value.Contains('%', StringComparison.Ordinal) + || value.Contains('\\', StringComparison.Ordinal) + || value.Contains(';', StringComparison.Ordinal)) { return false; } diff --git a/Source/AuthProxy/BearerRoutes/BearerRoutesLogging.cs b/Source/AuthProxy/BearerRoutes/BearerRoutesLogging.cs index c87463e1..55ee7289 100644 --- a/Source/AuthProxy/BearerRoutes/BearerRoutesLogging.cs +++ b/Source/AuthProxy/BearerRoutes/BearerRoutesLogging.cs @@ -17,7 +17,7 @@ internal static partial class BearerRoutesLogging [LoggerMessage(LogLevel.Information, "A token from bearer-route issuer '{Issuer}' was presented on {Path}, which is not one of its bearer routes. Refused.")] internal static partial void BearerTokenOutsideItsRoutes(this ILogger logger, string issuer, string path); - [LoggerMessage(LogLevel.Information, "A request on bearer route '{Route}' of service '{Service}' has an encoded character, a backslash or a dot segment in its path. Refused.")] + [LoggerMessage(LogLevel.Information, "A request on bearer route '{Route}' of service '{Service}' has an encoded character, a backslash, a semicolon or a dot segment in its path. Refused.")] internal static partial void BearerRoutePathAmbiguous(this ILogger logger, string route, string service); [LoggerMessage(LogLevel.Information, "A valid bearer token on route '{Route}' of service '{Service}' does not satisfy the required claim '{Claim}'. Refused.")] From bcb7c0cea2f5479f5a4040bac52db12b72379437 Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 29 Sep 2026 08:08:48 +0200 Subject: [PATCH 12/24] Let a bearer route state which claim requirements apply to its tokens Claim requirements apply to bearer principals, so a deployment requiring a claim only its browser sign-in produces - Direct requires urn:github:team, read from the GitHub API - would refuse every token its issuer mints. A bearer route can now declare RequiredClaims of its own, always applied on top, and set IgnoreDeploymentRequiredClaims to leave the proxy-wide and service requirements out. The default still applies all of them. Leaving them out is logged as a startup warning naming the requirements left out, and a route requirement naming no claim or a role is refused at startup. The Direct example documents the flag and how to replace it once Cratis Identity mints a team or membership claim. Refs #143 --- Documentation/configuration/authentication.md | 8 ++- Documentation/configuration/authorization.md | 4 +- Documentation/configuration/services.md | 32 +++++++++- ...ute_ignores_the_deployment_requirements.cs | 54 ++++++++++++++++ .../given/GatedBearerRouteHarness.cs | 15 +++++ ...ute_ignores_the_deployment_requirements.cs | 56 +++++++++++++++++ ... => when_evaluating_for_a_bearer_route.cs} | 17 +++--- .../when_validating_bearer_routes.cs | 13 ++++ ...ute_ignores_the_deployment_requirements.cs | 61 +++++++++++++++++++ .../AuthProxy/Authorization/AccessPolicy.cs | 10 ++- .../AuthProxy/Authorization/IAccessPolicy.cs | 17 +++--- .../BearerRouteConfigurationValidator.cs | 17 ++++++ .../BearerRoutes/BearerRouteMiddleware.cs | 17 +++--- .../BearerRouteServiceCollectionExtensions.cs | 1 + .../BearerRoutes/BearerRouteStartupReport.cs | 35 +++++++++++ .../BearerRoutes/BearerRouteWarning.cs | 17 ++++++ .../BearerRoutes/BearerRouteWarningKind.cs | 17 ++++++ .../BearerRoutes/BearerRouteWarnings.cs | 52 ++++++++++++++++ .../BearerRoutes/BearerRoutesLogging.cs | 3 + Source/AuthProxy/Configuration/BearerRoute.cs | 25 ++++++++ 20 files changed, 437 insertions(+), 34 deletions(-) create mode 100644 Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_bearer_route_ignores_the_deployment_requirements.cs create mode 100644 Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_a_bearer_route_ignores_the_deployment_requirements.cs rename Source/AuthProxy.Specs/Authorization/for_AccessPolicy/{when_evaluating_for_a_named_service.cs => when_evaluating_for_a_bearer_route.cs} (84%) create mode 100644 Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteWarnings/when_a_route_ignores_the_deployment_requirements.cs create mode 100644 Source/AuthProxy/BearerRoutes/BearerRouteStartupReport.cs create mode 100644 Source/AuthProxy/BearerRoutes/BearerRouteWarning.cs create mode 100644 Source/AuthProxy/BearerRoutes/BearerRouteWarningKind.cs create mode 100644 Source/AuthProxy/BearerRoutes/BearerRouteWarnings.cs diff --git a/Documentation/configuration/authentication.md b/Documentation/configuration/authentication.md index e2c49f09..f76d9101 100644 --- a/Documentation/configuration/authentication.md +++ b/Documentation/configuration/authentication.md @@ -574,8 +574,10 @@ A token is accepted only when all of these hold: Then the deployment's [claim requirements](authorization.md) apply — the proxy-wide `Authorization` section and the route's service's own — to the principal after the route's `ClaimMappings`, exactly as they apply to a -browser session. Role claims are dropped from the token first, so a requirement on a role can never be met by a -bearer token. +browser session, together with the route's own `RequiredClaims`. A route that sets +`IgnoreDeploymentRequiredClaims` is held to its own requirements only, for a deployment whose requirements name a +claim the issuer does not mint; AuthProxy logs a warning at startup for it. Role claims are dropped from the token +first, so a requirement on a role can never be met by a bearer token. ### Responses @@ -585,7 +587,7 @@ bearer token. | No bearer token (a browser session does not count) | `401`, `WWW-Authenticate: Bearer resource_metadata=""` | | Token invalid, expired, wrongly signed, from another issuer or for another audience | `401`, `WWW-Authenticate: Bearer error="invalid_token", resource_metadata="…"` | | Token lacks a required scope | `403`, `WWW-Authenticate: Bearer error="insufficient_scope", scope="", resource_metadata="…"` | -| Token does not satisfy the deployment's claim requirements | `403` | +| Token does not satisfy the claim requirements that apply on the route | `403` | | Token carries no tenant, or the tenant fails verification | `403` | | The issuer's metadata or keys cannot be retrieved | `503` with `Retry-After` | | Bearer-route token on any other path — whatever the case of the scheme or the whitespace after it, and in any of several `Authorization` headers | `401`, `WWW-Authenticate: Bearer error="invalid_token"` | diff --git a/Documentation/configuration/authorization.md b/Documentation/configuration/authorization.md index 923250c8..856f0ef0 100644 --- a/Documentation/configuration/authorization.md +++ b/Documentation/configuration/authorization.md @@ -225,7 +225,9 @@ GitHub Enterprise works without further configuration: the membership endpoints ## What a refused caller sees On a [bearer route](services.md#bearer-routes) the requirements apply to the access token's principal, after the -route's claim mappings, and a refusal is a bare `403` with no page — the caller is a program, not a person. +route's claim mappings, and a refusal is a bare `403` with no page — the caller is a program, not a person. A route +can declare requirements of its own, and can leave the deployment's out with `IgnoreDeploymentRequiredClaims` when +they name a claim the token issuer does not mint — see [BearerRouteConfig properties](services.md#bearerrouteconfig-properties). For a browser session: diff --git a/Documentation/configuration/services.md b/Documentation/configuration/services.md index d0b5f072..c278c74a 100644 --- a/Documentation/configuration/services.md +++ b/Documentation/configuration/services.md @@ -410,6 +410,11 @@ stays a relying party: it validates the token and forwards the request, and it n { "Cratis": { "AuthProxy": { + "Authorization": { + "RequiredClaims": [ + { "Claim": "urn:github:team", "AnyOf": [ "Cratis/direct" ] } + ] + }, "Services": { "direct": { "Backend": { "BaseUrl": "http://direct:8080/" }, @@ -425,7 +430,8 @@ stays a relying party: it validates the token and forwards the request, and it n "ClaimMappings": { "sub": "github_id", "preferred_username": "github_login" - } + }, + "IgnoreDeploymentRequiredClaims": true }, { "PathPrefix": "/v1", @@ -436,7 +442,8 @@ stays a relying party: it validates the token and forwards the request, and it n "ClaimMappings": { "sub": "github_id", "preferred_username": "github_login" - } + }, + "IgnoreDeploymentRequiredClaims": true } ] } @@ -452,6 +459,21 @@ and `https://cratis.direct/v1`. The claim mappings make a token-authenticated re browser session signed in through Direct's GitHub provider, so Direct resolves the same user either way. The Cratis account id from the token's `sub` is still forwarded, as the `urn:cratis:bearer:subject` claim. +Direct's deployment also requires the `urn:github:team` claim. A browser session gets it from Direct's GitHub +sign-in, which reads team membership from the GitHub API; a Cratis Identity access token does not carry it. Claim +requirements apply to bearer routes by default, so without `IgnoreDeploymentRequiredClaims` every token on these +routes would be refused with `403`. With it, the proxy-wide and service requirements are left out on the route, +AuthProxy logs a warning at startup naming them, and Direct's backend is what decides whether the caller is a member +of the tenant. When Cratis Identity mints a team or membership claim, either remove +`IgnoreDeploymentRequiredClaims` (if the claim is `urn:github:team` itself), or keep it and require the new claim +on the route: + +```json +"RequiredClaims": [ + { "Claim": "urn:cratis:membership", "AnyOf": [ "direct" ] } +] +``` + ### BearerRouteConfig properties | Property | Type | Default | Description | @@ -466,6 +488,8 @@ Cratis account id from the token's `sub` is still forwarded, as the `urn:cratis: | `IdentityProvider` | `string` | `bearer` | The identity provider named in the forwarded principal. | | `ForwardAuthorizationHeader` | `bool` | `false` | Whether the backend also receives the `Authorization` header. | | `ClockSkew` | `TimeSpan` | `00:00:30` | Allowed clock skew for `exp` and `nbf`. At most `00:05:00`. | +| `RequiredClaims` | `{ Claim, AnyOf }[]` | `[]` | Claim requirements of the route's own, checked against the token's principal after `ClaimMappings`, in addition to the deployment's. Same shape and rules as [`Authorization:RequiredClaims`](authorization.md); a requirement on a role claim is refused at startup, because a token never carries a role. | +| `IgnoreDeploymentRequiredClaims` | `bool` | `false` | Leave the deployment's claim requirements — proxy-wide and the service's — out on this route. For a deployment whose requirements name a claim the token issuer does not mint. Logged as a warning at startup. | | `AcceptWithoutIdentityVerification` | `bool` | `false` | Accept this route's callers in a deployment where a service declares `IdentityVerification: Required`. See [What a bearer route changes](#what-a-bearer-route-changes). | ### BearerIssuerConfig properties @@ -482,7 +506,9 @@ Cratis account id from the token's `sub` is still forwarded, as the `urn:cratis: selection and identity enrichment — none of them apply. [Admission](admission.md) and the trusted-proxy boundary run first and apply as to any request. - The deployment's [claim requirements](authorization.md) — proxy-wide and the route's service's — apply to - the token's principal after `ClaimMappings`. A token that does not satisfy them is refused with `403`. + the token's principal after `ClaimMappings`, unless the route sets `IgnoreDeploymentRequiredClaims`. The + route's own `RequiredClaims` apply on top either way. A token that does not satisfy them is refused with `403`. + A requirement on a role can never be met: roles are dropped from every token. - A bearer route never calls `/.cratis/me`, so it cannot obtain the verdict `IdentityVerification: Required` asks for. When any service declares `Required`, AuthProxy **refuses to start** with a bearer route unless the route sets `AcceptWithoutIdentityVerification: true` — the operator's statement that, on this route, the diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_bearer_route_ignores_the_deployment_requirements.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_bearer_route_ignores_the_deployment_requirements.cs new file mode 100644 index 00000000..d5fdd264 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_bearer_route_ignores_the_deployment_requirements.cs @@ -0,0 +1,54 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// A deployment whose claim requirements name a claim the token issuer does not mint would refuse every token on +/// its bearer routes. A route that leaves the deployment's requirements out serves a token that satisfies its own +/// requirements alone, and still refuses one that does not — however well it satisfies the deployment's. +/// +/// The running proxy, with claim requirements declared. +[Collection(GatedBearerRouteSpecCollection.Name)] +public class when_a_bearer_route_ignores_the_deployment_requirements(GatedBearerRouteHarness harness) : IAsyncLifetime +{ + const string MemberPath = $"{BearerRouteHarness.ForwardingRoutePrefix}/member"; + const string NotAMemberPath = $"{BearerRouteHarness.ForwardingRoutePrefix}/not-a-member"; + + HttpResponseMessage? _member; + HttpResponseMessage? _notAMember; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + + // Neither the proxy-wide organization nor the service's preferred_username: only the route's membership. + _member = await Send(client, MemberPath, harness.Issuer.Token(new Dictionary + { + [GatedBearerRouteHarness.MembershipClaim] = GatedBearerRouteHarness.MembershipValue, + ["preferred_username"] = "someone-else", + })); + + // Everything the deployment requires, but not the route's membership. + _notAMember = await Send(client, NotAMemberPath, harness.Issuer.Token(new Dictionary + { + [GatedBearerRouteHarness.RequiredClaim] = GatedBearerRouteHarness.RequiredValue, + ["preferred_username"] = BearerRouteHarness.GitHubLogin, + })); + } + + public Task DisposeAsync() + { + _member?.Dispose(); + _notAMember?.Dispose(); + return Task.CompletedTask; + } + + [Fact] public void should_forward_a_token_satisfying_the_route_requirements() => Assert.Equal(HttpStatusCode.OK, _member!.StatusCode); + [Fact] public void should_refuse_a_token_missing_the_route_requirement() => Assert.Equal(HttpStatusCode.Forbidden, _notAMember!.StatusCode); + [Fact] public void should_not_forward_the_refused_token() => Assert.False(harness.Origin.ReceivedAnythingFor(NotAMemberPath)); + + static Task Send(HttpClient client, string path, string token) => + client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, path, token)); +} diff --git a/Source/AuthProxy.Security.Specs/given/GatedBearerRouteHarness.cs b/Source/AuthProxy.Security.Specs/given/GatedBearerRouteHarness.cs index c0f149d6..7524250c 100644 --- a/Source/AuthProxy.Security.Specs/given/GatedBearerRouteHarness.cs +++ b/Source/AuthProxy.Security.Specs/given/GatedBearerRouteHarness.cs @@ -13,6 +13,11 @@ namespace Cratis.AuthProxy.Security.given; /// bearer route maps preferred_username from the token's github_login, so what is /// checked is the principal after the mapping, not the token as issued. Every bearer route states that it /// accepts callers without identity verification, which a deployment requiring verification must say. +/// +/// The route leaves the deployment's requirements out and +/// requires instead — the shape of a deployment whose requirements name a claim the +/// token issuer does not mint. +/// /// public class GatedBearerRouteHarness : BearerRouteHarness { @@ -22,6 +27,12 @@ public class GatedBearerRouteHarness : BearerRouteHarness /// The value of the deployment accepts. public const string RequiredValue = "Cratis"; + /// The claim the route ignoring the deployment's requirements requires instead. + public const string MembershipClaim = "membership"; + + /// The value of that route accepts. + public const string MembershipValue = "direct"; + /// protected override void AddSettings(IDictionary settings) { @@ -33,6 +44,10 @@ protected override void AddSettings(IDictionary settings) settings[$"{service}:Authorization:RequiredClaims:0:AnyOf:0"] = GitHubLogin; settings[$"{service}:IdentityVerification"] = nameof(C.IdentityVerificationMode.Required); + settings[$"{service}:BearerRoutes:1:{nameof(C.BearerRoute.IgnoreDeploymentRequiredClaims)}"] = "true"; + settings[$"{service}:BearerRoutes:1:{nameof(C.BearerRoute.RequiredClaims)}:0:Claim"] = MembershipClaim; + settings[$"{service}:BearerRoutes:1:{nameof(C.BearerRoute.RequiredClaims)}:0:AnyOf:0"] = MembershipValue; + for (var route = 0; route < 3; route++) { settings[$"{service}:BearerRoutes:{route}:{nameof(C.BearerRoute.AcceptWithoutIdentityVerification)}"] = "true"; diff --git a/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_a_bearer_route_ignores_the_deployment_requirements.cs b/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_a_bearer_route_ignores_the_deployment_requirements.cs new file mode 100644 index 00000000..e2634f87 --- /dev/null +++ b/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_a_bearer_route_ignores_the_deployment_requirements.cs @@ -0,0 +1,56 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Authorization.for_AccessPolicy; + +/// +/// A deployment can require a claim only its browser sign-in produces — a GitHub team, read from the GitHub API — +/// which the token issuer does not mint. A bearer route that says so leaves the root and service requirements out, +/// and is held to its own instead. Its own requirements apply whether or not it leaves the deployment's out. +/// +public class when_a_bearer_route_ignores_the_deployment_requirements : given.an_access_policy +{ + C.AuthProxy _config; + AccessDecision _memberWithoutTheTeam; + AccessDecision _teamWithoutTheMembership; + AccessDecision _byDefault; + AccessDecision _ownRequirementsOnTopOfTheDeployment; + + void Establish() => _config = new C.AuthProxy + { + Authorization = new C.Authorization { RequiredClaims = [Claiming("urn:github:team", "cratis/core")] }, + Services = new Dictionary + { + ["direct"] = new() + { + Backend = new C.ServiceEndpoint { BaseUrl = "http://direct.test/" }, + Authorization = new C.Authorization { RequiredClaims = [Claiming("urn:github:organization", "cratis")] }, + }, + }, + }; + + void Because() + { + var ignoring = new C.BearerRoute + { + IgnoreDeploymentRequiredClaims = true, + RequiredClaims = [Claiming("membership", "direct")], + }; + var applying = new C.BearerRoute { RequiredClaims = [Claiming("membership", "direct")] }; + + var member = Principal(new Claim("membership", "direct")); + var team = Principal(new Claim("urn:github:team", "cratis/core"), new Claim("urn:github:organization", "cratis")); + + _memberWithoutTheTeam = _policy.Evaluate(member, _config, "direct", ignoring); + _teamWithoutTheMembership = _policy.Evaluate(team, _config, "direct", ignoring); + _byDefault = _policy.Evaluate(member, _config, "direct", new C.BearerRoute()); + _ownRequirementsOnTopOfTheDeployment = _policy.Evaluate(team, _config, "direct", applying); + } + + [Fact] void should_grant_a_caller_satisfying_the_route_requirements_alone() => _memberWithoutTheTeam.IsGranted.ShouldBeTrue(); + [Fact] void should_deny_a_caller_missing_the_route_requirement() => _teamWithoutTheMembership.UnsatisfiedClaim.ShouldEqual("membership"); + [Fact] void should_apply_the_deployment_requirements_by_default() => _byDefault.UnsatisfiedClaim.ShouldEqual("urn:github:team"); + [Fact] void should_add_the_route_requirements_to_the_deployment_requirements() => _ownRequirementsOnTopOfTheDeployment.UnsatisfiedClaim.ShouldEqual("membership"); + + static ClaimsPrincipal Principal(params Claim[] claims) => new(new ClaimsIdentity(claims, "spec")); +} diff --git a/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_evaluating_for_a_named_service.cs b/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_evaluating_for_a_bearer_route.cs similarity index 84% rename from Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_evaluating_for_a_named_service.cs rename to Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_evaluating_for_a_bearer_route.cs index d08c138c..da15348e 100644 --- a/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_evaluating_for_a_named_service.cs +++ b/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_evaluating_for_a_bearer_route.cs @@ -4,11 +4,10 @@ namespace Cratis.AuthProxy.Authorization.for_AccessPolicy; /// -/// A request whose service is known without reading the request — a bearer route belongs to one service — is held -/// to the root requirements and that service's, never to another service's, and never to fewer because the -/// request names some other service. +/// A bearer route belongs to one service, so a token presented on it is held to the root requirements and that +/// service's, never to another service's, and never to fewer because the request names some other service. /// -public class when_evaluating_for_a_named_service : given.an_access_policy +public class when_evaluating_for_a_bearer_route : given.an_access_policy { C.AuthProxy _config; AccessDecision _qualified; @@ -40,13 +39,13 @@ void Because() var orgOnly = Principal(new Claim("org", "Cratis")); var teamOnly = Principal(new Claim("team", "operations")); - _qualified = _policy.Evaluate(qualified, _config, "admin"); - _missingTheServiceClaim = _policy.Evaluate(orgOnly, _config, "admin"); - _missingTheRootClaim = _policy.Evaluate(teamOnly, _config, "portal"); - _forAnotherService = _policy.Evaluate(orgOnly, _config, "portal"); + _qualified = _policy.Evaluate(qualified, _config, "admin", new C.BearerRoute()); + _missingTheServiceClaim = _policy.Evaluate(orgOnly, _config, "admin", new C.BearerRoute()); + _missingTheRootClaim = _policy.Evaluate(teamOnly, _config, "portal", new C.BearerRoute()); + _forAnotherService = _policy.Evaluate(orgOnly, _config, "portal", new C.BearerRoute()); _context.Request.Headers[Headers.ServiceId] = "portal"; - _namingAnotherServiceInTheRequest = _policy.Evaluate(orgOnly, _config, "ADMIN"); + _namingAnotherServiceInTheRequest = _policy.Evaluate(orgOnly, _config, "ADMIN", new C.BearerRoute()); } [Fact] void should_grant_a_caller_satisfying_the_root_and_the_service() => _qualified.IsGranted.ShouldBeTrue(); diff --git a/Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteConfigurationValidator/when_validating_bearer_routes.cs b/Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteConfigurationValidator/when_validating_bearer_routes.cs index 7347255c..f7ebf20b 100644 --- a/Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteConfigurationValidator/when_validating_bearer_routes.cs +++ b/Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteConfigurationValidator/when_validating_bearer_routes.cs @@ -31,6 +31,9 @@ public class when_validating_bearer_routes : Specification ValidateOptionsResult _verificationRequiredByAnotherService; ValidateOptionsResult _verificationRequiredAndAcceptedWithout; ValidateOptionsResult _verificationRequiredOfANonParticipant; + ValidateOptionsResult _withRouteRequirement; + ValidateOptionsResult _withRouteRequirementNamingNoClaim; + ValidateOptionsResult _withRouteRequirementOnARole; void Because() { @@ -56,6 +59,13 @@ void Because() _verificationRequired = validator.Validate(null, Configuration(_ => { }, adjustConfiguration: _ => _.Services["main"].IdentityVerification = C.IdentityVerificationMode.Required)); _verificationRequiredByAnotherService = validator.Validate(null, Configuration(_ => { }, adjustConfiguration: _ => _.Services["other"] = RequiringVerification(new C.Service { Backend = new C.ServiceEndpoint { BaseUrl = "https://other.example.test" } }))); _verificationRequiredAndAcceptedWithout = validator.Validate(null, Configuration(_ => _.AcceptWithoutIdentityVerification = true, adjustConfiguration: _ => _.Services["main"].IdentityVerification = C.IdentityVerificationMode.Required)); + _withRouteRequirement = validator.Validate(null, Configuration(_ => + { + _.IgnoreDeploymentRequiredClaims = true; + _.RequiredClaims = [new C.ClaimRequirement { Claim = "urn:cratis:membership", AnyOf = ["direct"] }]; + })); + _withRouteRequirementNamingNoClaim = validator.Validate(null, Configuration(_ => _.RequiredClaims = [new C.ClaimRequirement { Claim = " ", AnyOf = ["direct"] }])); + _withRouteRequirementOnARole = validator.Validate(null, Configuration(_ => _.RequiredClaims = [new C.ClaimRequirement { Claim = "roles", AnyOf = ["admin"] }])); _verificationRequiredOfANonParticipant = validator.Validate(null, Configuration(_ => { }, adjustConfiguration: _ => _.Services["other"] = RequiringVerification(new C.Service { Backend = new C.ServiceEndpoint { BaseUrl = "https://other.example.test" }, ResolveIdentityDetails = false }))); } @@ -80,6 +90,9 @@ void Because() [Fact] void should_name_the_setting_that_accepts_it() => _verificationRequired.FailureMessage.ShouldContain(nameof(C.BearerRoute.AcceptWithoutIdentityVerification)); [Fact] void should_refuse_a_route_when_another_service_requires_identity_verification() => _verificationRequiredByAnotherService.Failed.ShouldBeTrue(); [Fact] void should_accept_a_route_that_accepts_callers_without_identity_verification() => _verificationRequiredAndAcceptedWithout.Succeeded.ShouldBeTrue(); + [Fact] void should_accept_a_route_declaring_its_own_claim_requirements() => _withRouteRequirement.Succeeded.ShouldBeTrue(); + [Fact] void should_refuse_a_route_requirement_naming_no_claim() => _withRouteRequirementNamingNoClaim.Failed.ShouldBeTrue(); + [Fact] void should_refuse_a_route_requirement_on_a_role_a_token_never_carries() => _withRouteRequirementOnARole.Failed.ShouldBeTrue(); [Fact] void should_ignore_a_verification_requirement_of_a_service_that_resolves_no_identity() => _verificationRequiredOfANonParticipant.Succeeded.ShouldBeTrue(); static C.AuthProxy Configuration( diff --git a/Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteWarnings/when_a_route_ignores_the_deployment_requirements.cs b/Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteWarnings/when_a_route_ignores_the_deployment_requirements.cs new file mode 100644 index 00000000..fb19cfb4 --- /dev/null +++ b/Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteWarnings/when_a_route_ignores_the_deployment_requirements.cs @@ -0,0 +1,61 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.BearerRoutes.for_BearerRouteWarnings; + +/// +/// Leaving the deployment's claim requirements out on a bearer route admits callers a browser session would not be +/// admitted as, so it is reported at startup, naming every requirement left out. A route that leaves out nothing — +/// because nothing is declared, or because it does not ask to — is not reported. +/// +public class when_a_route_ignores_the_deployment_requirements : Specification +{ + BearerRouteWarning[] _ignoring; + BearerRouteWarning[] _ignoringNothingDeclared; + BearerRouteWarning[] _applying; + + void Because() + { + _ignoring = [.. BearerRouteWarnings.For(Configuration(ignore: true, declareRequirements: true)) + .Where(_ => _.Kind == BearerRouteWarningKind.DeploymentRequirementsIgnored)]; + _ignoringNothingDeclared = [.. BearerRouteWarnings.For(Configuration(ignore: true, declareRequirements: false)) + .Where(_ => _.Kind == BearerRouteWarningKind.DeploymentRequirementsIgnored)]; + _applying = [.. BearerRouteWarnings.For(Configuration(ignore: false, declareRequirements: true)) + .Where(_ => _.Kind == BearerRouteWarningKind.DeploymentRequirementsIgnored)]; + } + + [Fact] void should_report_the_route() => _ignoring.Length.ShouldEqual(1); + [Fact] void should_name_the_service() => _ignoring[0].ServiceName.ShouldEqual("direct"); + [Fact] void should_name_the_prefix() => _ignoring[0].Prefix.ShouldEqual("/mcp"); + [Fact] void should_name_the_root_and_service_requirements_left_out() => _ignoring[0].Subjects.ShouldContainOnly(["urn:github:team", "urn:github:organization"]); + [Fact] void should_not_report_a_route_when_nothing_is_required() => _ignoringNothingDeclared.ShouldBeEmpty(); + [Fact] void should_not_report_a_route_applying_the_requirements() => _applying.ShouldBeEmpty(); + + static C.AuthProxy Configuration(bool ignore, bool declareRequirements) => new() + { + Authorization = declareRequirements + ? new C.Authorization { RequiredClaims = [new C.ClaimRequirement { Claim = "urn:github:team", AnyOf = ["cratis/core"] }] } + : new C.Authorization(), + Services = new Dictionary + { + ["direct"] = new() + { + Backend = new C.ServiceEndpoint { BaseUrl = "https://direct.example.test" }, + ResolveIdentityDetails = false, + Authorization = declareRequirements + ? new C.Authorization { RequiredClaims = [new C.ClaimRequirement { Claim = "urn:github:organization", AnyOf = ["cratis"] }] } + : null, + BearerRoutes = + [ + new() + { + PathPrefix = "/mcp", + Issuers = [new C.BearerIssuer { Issuer = "https://auth.example.test/" }], + Audiences = ["direct-api"], + IgnoreDeploymentRequiredClaims = ignore, + }, + ], + }, + }, + }; +} diff --git a/Source/AuthProxy/Authorization/AccessPolicy.cs b/Source/AuthProxy/Authorization/AccessPolicy.cs index dcb44e67..f567667c 100644 --- a/Source/AuthProxy/Authorization/AccessPolicy.cs +++ b/Source/AuthProxy/Authorization/AccessPolicy.cs @@ -35,8 +35,14 @@ public AccessDecision Evaluate(HttpContext context, C.AuthProxy config) => Evaluate(context.User, RequirementsFor(config, ResolveService(context, config))); /// - public AccessDecision Evaluate(ClaimsPrincipal user, C.AuthProxy config, string serviceName) => - Evaluate(user, RequirementsFor(config, FindService(config, serviceName))); + public AccessDecision Evaluate(ClaimsPrincipal user, C.AuthProxy config, string serviceName, C.BearerRoute route) + { + var requirements = route.IgnoreDeploymentRequiredClaims + ? route.RequiredClaims + : RequirementsFor(config, FindService(config, serviceName)).Concat(route.RequiredClaims); + + return Evaluate(user, requirements); + } static AccessDecision Evaluate(ClaimsPrincipal user, IEnumerable requirements) { diff --git a/Source/AuthProxy/Authorization/IAccessPolicy.cs b/Source/AuthProxy/Authorization/IAccessPolicy.cs index 102e1872..a82fdd9d 100644 --- a/Source/AuthProxy/Authorization/IAccessPolicy.cs +++ b/Source/AuthProxy/Authorization/IAccessPolicy.cs @@ -32,15 +32,18 @@ public interface IAccessPolicy AccessDecision Evaluate(HttpContext context, C.AuthProxy config); /// - /// Evaluates the configured claim requirements against a principal for a request known to target a service. + /// Evaluates the claim requirements of a bearer route against the principal of the token presented on it. /// - /// The authenticated principal. + /// The token's principal, after the route's claim mappings. /// The auth proxy configuration to read. - /// The name of the service the request targets. - /// The for this principal and service. + /// The name of the service the route belongs to. + /// The bearer route the token was presented on. + /// The for this principal on this route. /// - /// For a request whose target is not worked out from the request itself — a bearer route belongs to exactly - /// one service, whatever the request names. The root requirements and the service's are applied the same way. + /// A bearer route belongs to exactly one service, whatever the request names, so its target is not worked out + /// from the request. The root requirements and the service's apply as they do to a browser session, unless the + /// route sets ; the route's own + /// always apply on top. /// - AccessDecision Evaluate(ClaimsPrincipal user, C.AuthProxy config, string serviceName); + AccessDecision Evaluate(ClaimsPrincipal user, C.AuthProxy config, string serviceName, C.BearerRoute route); } diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteConfigurationValidator.cs b/Source/AuthProxy/BearerRoutes/BearerRouteConfigurationValidator.cs index 6ed73e6a..64a3326d 100644 --- a/Source/AuthProxy/BearerRoutes/BearerRouteConfigurationValidator.cs +++ b/Source/AuthProxy/BearerRoutes/BearerRouteConfigurationValidator.cs @@ -125,6 +125,23 @@ static void ValidateRoute( failures.Add($"{at}:{nameof(C.BearerRoute.ClockSkew)} must be between zero and {C.BearerRoute.MaximumClockSkew}."); } + for (var i = 0; i < route.RequiredClaims.Count; i++) + { + var claim = route.RequiredClaims[i].Claim?.Trim(); + if (string.IsNullOrEmpty(claim)) + { + failures.Add( + $"{at}:{nameof(C.BearerRoute.RequiredClaims)}:{i}:{nameof(C.ClaimRequirement.Claim)} must name a claim type. " + + "A requirement without one can never be satisfied and would refuse every token."); + } + else if (IsRoleClaim(claim)) + { + failures.Add( + $"{at}:{nameof(C.BearerRoute.RequiredClaims)}:{i} requires the role claim '{claim}', which a bearer token never carries: " + + "AuthProxy drops role claims from every token, so the requirement would refuse every token."); + } + } + foreach (var (target, source) in route.ClaimMappings) { if (string.IsNullOrWhiteSpace(target) || string.IsNullOrWhiteSpace(source)) diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteMiddleware.cs b/Source/AuthProxy/BearerRoutes/BearerRouteMiddleware.cs index 2b94a5ea..ea890c9a 100644 --- a/Source/AuthProxy/BearerRoutes/BearerRouteMiddleware.cs +++ b/Source/AuthProxy/BearerRoutes/BearerRouteMiddleware.cs @@ -28,7 +28,9 @@ namespace Cratis.AuthProxy.BearerRoutes; /// cannot authenticate a browser-only surface through any other bearer support the deployment has configured. /// /// The deployment's claim requirements ( and the route's service's own) -/// apply here as they do to a browser session, to the principal after the route's claim mappings. Identity +/// apply here as they do to a browser session, to the principal after the route's claim mappings, unless the route +/// sets ; the route's own +/// apply on top. Identity /// verification through /.cratis/me does not run here; a deployment that requires it may declare a /// bearer route only when the route says it accepts callers without it /// (), which startup validation enforces. @@ -107,15 +109,12 @@ async Task Authenticate(HttpContext context, ResolvedBearerRoute route, C.AuthPr return; } - if (accessPolicy.IsConfigured(current)) + var decision = accessPolicy.Evaluate(validation.Principal!, current, route.ServiceName, route.Route); + if (!decision.IsGranted) { - var decision = accessPolicy.Evaluate(validation.Principal!, current, route.ServiceName); - if (!decision.IsGranted) - { - logger.BearerAccessDenied(route.Prefix, route.ServiceName, decision.UnsatisfiedClaim); - BearerChallenge.Forbidden(context); - return; - } + logger.BearerAccessDenied(route.Prefix, route.ServiceName, decision.UnsatisfiedClaim); + BearerChallenge.Forbidden(context); + return; } if (!await tenantVerifier.VerifyAsync(validation.TenantId!)) diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteServiceCollectionExtensions.cs b/Source/AuthProxy/BearerRoutes/BearerRouteServiceCollectionExtensions.cs index 0ea36994..cd520bed 100644 --- a/Source/AuthProxy/BearerRoutes/BearerRouteServiceCollectionExtensions.cs +++ b/Source/AuthProxy/BearerRoutes/BearerRouteServiceCollectionExtensions.cs @@ -33,6 +33,7 @@ public static WebApplicationBuilder AddBearerRoutes(this WebApplicationBuilder b builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton, BearerRouteConfigurationValidator>(); + builder.Services.AddHostedService(); return builder; } diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteStartupReport.cs b/Source/AuthProxy/BearerRoutes/BearerRouteStartupReport.cs new file mode 100644 index 00000000..f84c1929 --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/BearerRouteStartupReport.cs @@ -0,0 +1,35 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Extensions.Options; +using C = Cratis.AuthProxy.Configuration; + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Logs, at startup, every the configuration gives rise to. +/// +/// The auth proxy configuration monitor. +/// The logger. +public sealed class BearerRouteStartupReport(IOptionsMonitor config, ILogger logger) : IHostedService +{ + /// + public Task StartAsync(CancellationToken cancellationToken) + { + foreach (var warning in BearerRouteWarnings.For(config.CurrentValue)) + { + var subjects = string.Join(", ", warning.Subjects); + switch (warning.Kind) + { + case BearerRouteWarningKind.DeploymentRequirementsIgnored: + logger.BearerRouteIgnoresDeploymentRequirements(warning.Prefix, warning.ServiceName, subjects); + break; + } + } + + return Task.CompletedTask; + } + + /// + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteWarning.cs b/Source/AuthProxy/BearerRoutes/BearerRouteWarning.cs new file mode 100644 index 00000000..c2217bd6 --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/BearerRouteWarning.cs @@ -0,0 +1,17 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Represents something a bearer route does not check that the rest of the deployment does. +/// +/// What is not checked. +/// The service the route belongs to. +/// The route's path prefix. +/// What the warning is about: the claim types left out, or the services not consulted. +public sealed record BearerRouteWarning( + BearerRouteWarningKind Kind, + string ServiceName, + string Prefix, + IReadOnlyList Subjects); diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteWarningKind.cs b/Source/AuthProxy/BearerRoutes/BearerRouteWarningKind.cs new file mode 100644 index 00000000..82ce6df7 --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/BearerRouteWarningKind.cs @@ -0,0 +1,17 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Represents a way a bearer route admits callers a browser session in the same deployment would not be admitted +/// on, which the operator is told about at startup. +/// +public enum BearerRouteWarningKind +{ + /// + /// The route sets , leaving out claim + /// requirements the deployment declares. + /// + DeploymentRequirementsIgnored = 0, +} diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteWarnings.cs b/Source/AuthProxy/BearerRoutes/BearerRouteWarnings.cs new file mode 100644 index 00000000..5acbf6fd --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/BearerRouteWarnings.cs @@ -0,0 +1,52 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using C = Cratis.AuthProxy.Configuration; + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Works out what each bearer route leaves unchecked that the deployment otherwise checks. +/// +/// +/// Each of these is a deliberate configuration, which is why none of them refuses to start. Each also means a +/// caller can reach the service through the route who would be refused through a browser session, which is why +/// none of them is allowed to be silent either. +/// +public static class BearerRouteWarnings +{ + /// + /// Gets the warnings for every resolvable bearer route in the configuration. + /// + /// The auth proxy configuration to read. + /// The warnings, one per route and kind. + public static IEnumerable For(C.AuthProxy config) + { + foreach (var route in BearerRouteTable.All(config)) + { + if (route.Route.IgnoreDeploymentRequiredClaims) + { + var ignored = DeploymentRequirementClaims(config, route.ServiceName); + if (ignored.Count > 0) + { + yield return new BearerRouteWarning(BearerRouteWarningKind.DeploymentRequirementsIgnored, route.ServiceName, route.Prefix, ignored); + } + } + } + } + + static List DeploymentRequirementClaims(C.AuthProxy config, string serviceName) + { + var service = config.Services + .Where(_ => string.Equals(_.Key, serviceName, StringComparison.OrdinalIgnoreCase)) + .Select(_ => _.Value) + .FirstOrDefault(); + + return config.Authorization.RequiredClaims + .Concat(service?.Authorization?.RequiredClaims ?? []) + .Select(_ => _.Claim?.Trim() ?? string.Empty) + .Where(_ => _.Length > 0) + .Distinct(StringComparer.Ordinal) + .ToList(); + } +} diff --git a/Source/AuthProxy/BearerRoutes/BearerRoutesLogging.cs b/Source/AuthProxy/BearerRoutes/BearerRoutesLogging.cs index 55ee7289..0bda63bf 100644 --- a/Source/AuthProxy/BearerRoutes/BearerRoutesLogging.cs +++ b/Source/AuthProxy/BearerRoutes/BearerRoutesLogging.cs @@ -26,6 +26,9 @@ internal static partial class BearerRoutesLogging [LoggerMessage(LogLevel.Warning, "Tenant '{TenantId}' named by a bearer token on {Path} could not be verified. Refused.")] internal static partial void BearerTenantNotVerified(this ILogger logger, string tenantId, string path); + [LoggerMessage(LogLevel.Warning, "Bearer route '{Route}' of service '{Service}' sets IgnoreDeploymentRequiredClaims, so the deployment's claim requirements on {Claims} are not applied to its tokens. Only the route's own RequiredClaims are.")] + internal static partial void BearerRouteIgnoresDeploymentRequirements(this ILogger logger, string route, string service, string claims); + [LoggerMessage(LogLevel.Warning, "Forwarding bearer route '{Route}' of service '{Service}' failed ({Error}).")] internal static partial void BearerRouteForwardingFailed(this ILogger logger, Exception? exception, string route, string service, Yarp.ReverseProxy.Forwarder.ForwarderError error); } diff --git a/Source/AuthProxy/Configuration/BearerRoute.cs b/Source/AuthProxy/Configuration/BearerRoute.cs index afd47d78..6896fcc7 100644 --- a/Source/AuthProxy/Configuration/BearerRoute.cs +++ b/Source/AuthProxy/Configuration/BearerRoute.cs @@ -94,6 +94,31 @@ public class BearerRoute /// public TimeSpan? ClockSkew { get; set; } + /// + /// Gets or sets claim requirements of this route's own, which the token's principal must satisfy after + /// — every one of them, in addition to whatever deployment requirements apply. + /// + /// + /// Composed exactly like : the list is an and, each + /// requirement's an or. A token never carries a role, so a + /// requirement on a role claim is refused at startup. + /// + public IList RequiredClaims { get; set; } = []; + + /// + /// Gets or sets a value indicating whether the deployment's claim requirements — the proxy-wide + /// and the route's service's own — are left out on this route. + /// Defaults to : they apply to a bearer token as they do to a browser session. + /// + /// + /// For a deployment whose requirements name a claim only its browser sign-in produces — a GitHub team read + /// from the GitHub API, say — and which the token issuer does not mint. Without this, every token on the + /// route would be refused. Setting it widens who reaches the service through this route, so AuthProxy logs a + /// warning at startup naming the requirements it leaves out; state what the route requires instead in + /// . + /// + public bool IgnoreDeploymentRequiredClaims { get; set; } + /// /// Gets or sets a value indicating whether this route accepts callers although the deployment requires identity /// verification. Defaults to . From 96d819a1e8f1b4c08583df98c23940a435647993 Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 29 Sep 2026 08:11:25 +0200 Subject: [PATCH 13/24] Make the skipped /.cratis/me veto on bearer routes explicit A bearer route never calls /.cratis/me, so the 403 veto the default BestEffort mode applies to a browser session did not apply to tokens, and nothing said so. Calling it for tokens is not the safer option: the endpoint answers for browser sessions, and a product that cannot recognise a token principal would either lock out every token or give the veto no meaning. The behavior stays, and is now stated: every bearer route in a deployment where some service answers /.cratis/me is logged as a startup warning naming those services, unless the route sets AcceptWithoutIdentityVerification to say its backend decides membership. Required still refuses to start without it. The docs say that the backend must enforce tenant membership, and the Direct example sets the flag. Refs #143 --- Documentation/configuration/authentication.md | 21 ++++-- Documentation/configuration/services.md | 27 +++++--- ...he_caller_through_its_identity_endpoint.cs | 58 ++++++++++++++++ ..._does_not_consult_identity_verification.cs | 66 +++++++++++++++++++ .../BearerRoutes/BearerRouteMiddleware.cs | 8 ++- .../BearerRoutes/BearerRouteStartupReport.cs | 4 ++ .../BearerRoutes/BearerRouteWarningKind.cs | 8 +++ .../BearerRoutes/BearerRouteWarnings.cs | 27 +++++++- .../BearerRoutes/BearerRoutesLogging.cs | 3 + Source/AuthProxy/Configuration/BearerRoute.cs | 21 ++++-- 10 files changed, 218 insertions(+), 25 deletions(-) create mode 100644 Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_service_would_refuse_the_caller_through_its_identity_endpoint.cs create mode 100644 Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteWarnings/when_a_route_does_not_consult_identity_verification.cs diff --git a/Documentation/configuration/authentication.md b/Documentation/configuration/authentication.md index f76d9101..de748e7b 100644 --- a/Documentation/configuration/authentication.md +++ b/Documentation/configuration/authentication.md @@ -618,10 +618,23 @@ claim, and role claims in the token are not forwarded: a token never grants a ro Every inbound copy of these headers is removed on every route, bearer or not. The `Cookie` header is not forwarded on a bearer route, and neither is `Authorization` unless the route sets `ForwardAuthorizationHeader`. -Identity enrichment (`/.cratis/me`) is not called on a bearer route; the backend enforces tenant membership and -what the user may do with the scopes the client was granted. A deployment where a service declares -`IdentityVerification: Required` therefore refuses to start with a bearer route unless the route sets -`AcceptWithoutIdentityVerification` — see [Bearer routes](services.md#bearer-routes). +### Identity verification is not applied + +A bearer route **never calls `/.cratis/me`**, whatever `IdentityVerification` says. That endpoint answers for +browser sessions, and a product cannot be assumed to answer it correctly for a principal authenticated by a token. +The consequences: + +- Under the default `IdentityVerification: BestEffort`, a service answering `403` on `/.cratis/me` refuses a + browser session — but not a token on a bearer route. A user whose browser session a service refuses there can + still reach the service with a token. **The backend must enforce tenant membership** and what the user may do + with the scopes the client was granted. AuthProxy logs a warning at startup for every bearer route in a + deployment where some service answers `/.cratis/me`, unless the route sets `AcceptWithoutIdentityVerification` + to state that this is intended. +- Under `IdentityVerification: Required`, AuthProxy refuses to start with a bearer route unless the route sets + `AcceptWithoutIdentityVerification`. +- No identity details are resolved, so no `.cratis-identity` cookie is written. + +See [Bearer routes](services.md#bearer-routes). --- diff --git a/Documentation/configuration/services.md b/Documentation/configuration/services.md index c278c74a..0bc101ce 100644 --- a/Documentation/configuration/services.md +++ b/Documentation/configuration/services.md @@ -431,7 +431,8 @@ stays a relying party: it validates the token and forwards the request, and it n "sub": "github_id", "preferred_username": "github_login" }, - "IgnoreDeploymentRequiredClaims": true + "IgnoreDeploymentRequiredClaims": true, + "AcceptWithoutIdentityVerification": true }, { "PathPrefix": "/v1", @@ -443,7 +444,8 @@ stays a relying party: it validates the token and forwards the request, and it n "sub": "github_id", "preferred_username": "github_login" }, - "IgnoreDeploymentRequiredClaims": true + "IgnoreDeploymentRequiredClaims": true, + "AcceptWithoutIdentityVerification": true } ] } @@ -474,6 +476,10 @@ on the route: ] ``` +`AcceptWithoutIdentityVerification` states the same thing for `/.cratis/me`: Direct answers it for browser +sessions, a bearer route never calls it, and Direct's backend checks the caller's membership of the tenant on +every token-authenticated request instead. + ### BearerRouteConfig properties | Property | Type | Default | Description | @@ -490,7 +496,7 @@ on the route: | `ClockSkew` | `TimeSpan` | `00:00:30` | Allowed clock skew for `exp` and `nbf`. At most `00:05:00`. | | `RequiredClaims` | `{ Claim, AnyOf }[]` | `[]` | Claim requirements of the route's own, checked against the token's principal after `ClaimMappings`, in addition to the deployment's. Same shape and rules as [`Authorization:RequiredClaims`](authorization.md); a requirement on a role claim is refused at startup, because a token never carries a role. | | `IgnoreDeploymentRequiredClaims` | `bool` | `false` | Leave the deployment's claim requirements — proxy-wide and the service's — out on this route. For a deployment whose requirements name a claim the token issuer does not mint. Logged as a warning at startup. | -| `AcceptWithoutIdentityVerification` | `bool` | `false` | Accept this route's callers in a deployment where a service declares `IdentityVerification: Required`. See [What a bearer route changes](#what-a-bearer-route-changes). | +| `AcceptWithoutIdentityVerification` | `bool` | `false` | State that this route's callers are accepted without any service's `/.cratis/me` being asked about them. Required when a service declares `IdentityVerification: Required`; under `BestEffort` it silences the startup warning. See [What a bearer route changes](#what-a-bearer-route-changes). | ### BearerIssuerConfig properties @@ -509,11 +515,16 @@ on the route: the token's principal after `ClaimMappings`, unless the route sets `IgnoreDeploymentRequiredClaims`. The route's own `RequiredClaims` apply on top either way. A token that does not satisfy them is refused with `403`. A requirement on a role can never be met: roles are dropped from every token. -- A bearer route never calls `/.cratis/me`, so it cannot obtain the verdict `IdentityVerification: Required` - asks for. When any service declares `Required`, AuthProxy **refuses to start** with a bearer route unless the - route sets `AcceptWithoutIdentityVerification: true` — the operator's statement that, on this route, the - validated token, its scopes and the claim requirements are the whole decision at the edge and the backend - answers for the rest. It is forwarded straight to the service **backend**, +- A bearer route **never calls `/.cratis/me`**, in either identity-verification mode: the endpoint answers for + browser sessions, not for principals authenticated by a token. The backend must enforce tenant membership. + - Under the default `BestEffort`, a `403` from a service's `/.cratis/me` refuses a browser session but not a + token. AuthProxy starts, and logs a warning for each bearer route in a deployment where some service answers + `/.cratis/me`, unless the route sets `AcceptWithoutIdentityVerification: true`. + - Under `Required`, AuthProxy **refuses to start** with a bearer route unless the route sets + `AcceptWithoutIdentityVerification: true`. + + Setting it is the operator's statement that, on this route, the validated token, its scopes and the claim + requirements are the whole decision at the edge and the backend answers for the rest. It is forwarded straight to the service **backend**, whichever of the service's endpoints would otherwise serve that path, and without a `Service-ID` header. - The session cookie is never read, and the `Cookie` header is not forwarded. - A request whose path on the route still carries percent-encoding after the server has decoded it (such as an diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_service_would_refuse_the_caller_through_its_identity_endpoint.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_service_would_refuse_the_caller_through_its_identity_endpoint.cs new file mode 100644 index 00000000..2dff45ff --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_service_would_refuse_the_caller_through_its_identity_endpoint.cs @@ -0,0 +1,58 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.AspNetCore.Http; + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// Under the default best-effort identity verification, a service answering 403 on +/// /.cratis/me refuses a browser session. A bearer route never calls that endpoint — it answers for +/// browser sessions, not for principals authenticated by a token — so the refusal does not apply there: the token is +/// forwarded, and the backend decides membership. This is the documented behavior, reported at startup, not an +/// oversight a token can exploit without the operator knowing. +/// +/// The running proxy, with the default best-effort identity verification. +[Collection(BearerRouteSpecCollection.Name)] +public class when_a_service_would_refuse_the_caller_through_its_identity_endpoint(BearerRouteHarness harness) : IAsyncLifetime +{ + const string BearerPath = $"{BearerRouteHarness.RoutePrefix}/tools"; + const string BrowserPath = "/api/items"; + + HttpResponseMessage? _bearer; + HttpResponseMessage? _browser; + bool _identityEndpointCalledForTheToken; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + harness.Origin.IdentityResponse = () => Results.StatusCode(StatusCodes.Status403Forbidden); + + try + { + _bearer = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, BearerPath, harness.Issuer.Token())); + _identityEndpointCalledForTheToken = harness.Origin.ReceivedAnythingFor(WellKnownPaths.IdentityDetails); + + using var withSession = new HttpRequestMessage(HttpMethod.Get, BrowserPath); + withSession.Headers.TryAddWithoutValidation(SecurityHarness.AuthenticatedUserHeader, "refused-browser-user"); + _browser = await client.SendAsync(withSession); + } + finally + { + harness.Origin.IdentityResponse = () => Results.Json(new { }); + } + } + + public Task DisposeAsync() + { + _bearer?.Dispose(); + _browser?.Dispose(); + return Task.CompletedTask; + } + + [Fact] public void should_forward_the_token() => Assert.Equal(HttpStatusCode.OK, _bearer!.StatusCode); + [Fact] public void should_not_call_the_identity_endpoint_for_the_token() => Assert.False(_identityEndpointCalledForTheToken); + [Fact] public void should_still_refuse_the_browser_session() => Assert.Equal(HttpStatusCode.Forbidden, _browser!.StatusCode); + [Fact] public void should_not_forward_the_refused_browser_session() => Assert.False(harness.Origin.ReceivedAnythingFor(BrowserPath)); +} diff --git a/Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteWarnings/when_a_route_does_not_consult_identity_verification.cs b/Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteWarnings/when_a_route_does_not_consult_identity_verification.cs new file mode 100644 index 00000000..136f3c09 --- /dev/null +++ b/Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteWarnings/when_a_route_does_not_consult_identity_verification.cs @@ -0,0 +1,66 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.BearerRoutes.for_BearerRouteWarnings; + +/// +/// A bearer route never calls /.cratis/me, so the 403 veto a best-effort service +/// applies to a browser session never applies to a token. That is reported at startup, naming every service whose +/// veto is not applied, unless the route states it accepts it — and it is not reported when no service would have +/// been asked. +/// +public class when_a_route_does_not_consult_identity_verification : Specification +{ + BearerRouteWarning[] _notAccepting; + BearerRouteWarning[] _accepting; + BearerRouteWarning[] _nothingConsulted; + + void Because() + { + _notAccepting = Warnings(accept: false, participates: true); + _accepting = Warnings(accept: true, participates: true); + _nothingConsulted = Warnings(accept: false, participates: false); + } + + [Fact] void should_report_the_route() => _notAccepting.Length.ShouldEqual(1); + [Fact] void should_name_the_route() => _notAccepting[0].Prefix.ShouldEqual("/mcp"); + [Fact] void should_name_every_service_whose_veto_is_not_applied() => _notAccepting[0].Subjects.ShouldContainOnly(["direct", "lobby"]); + [Fact] void should_not_report_a_route_accepting_callers_without_it() => _accepting.ShouldBeEmpty(); + [Fact] void should_not_report_a_route_when_no_service_is_consulted() => _nothingConsulted.ShouldBeEmpty(); + + static BearerRouteWarning[] Warnings(bool accept, bool participates) => + [.. BearerRouteWarnings.For(Configuration(accept, participates)) + .Where(_ => _.Kind == BearerRouteWarningKind.IdentityVerificationNotConsulted)]; + + static C.AuthProxy Configuration(bool accept, bool participates) => new() + { + Services = new Dictionary + { + ["direct"] = new() + { + Backend = new C.ServiceEndpoint { BaseUrl = "https://direct.example.test" }, + ResolveIdentityDetails = participates, + BearerRoutes = + [ + new() + { + PathPrefix = "/mcp", + Issuers = [new C.BearerIssuer { Issuer = "https://auth.example.test/" }], + Audiences = ["direct-api"], + AcceptWithoutIdentityVerification = accept, + }, + ], + }, + ["lobby"] = new() + { + Backend = new C.ServiceEndpoint { BaseUrl = "https://lobby.example.test" }, + ResolveIdentityDetails = participates, + }, + ["static"] = new() + { + Backend = new C.ServiceEndpoint { BaseUrl = "https://static.example.test" }, + ResolveIdentityDetails = false, + }, + }, + }; +} diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteMiddleware.cs b/Source/AuthProxy/BearerRoutes/BearerRouteMiddleware.cs index ea890c9a..8b06516f 100644 --- a/Source/AuthProxy/BearerRoutes/BearerRouteMiddleware.cs +++ b/Source/AuthProxy/BearerRoutes/BearerRouteMiddleware.cs @@ -31,9 +31,11 @@ namespace Cratis.AuthProxy.BearerRoutes; /// apply here as they do to a browser session, to the principal after the route's claim mappings, unless the route /// sets ; the route's own /// apply on top. Identity -/// verification through /.cratis/me does not run here; a deployment that requires it may declare a -/// bearer route only when the route says it accepts callers without it -/// (), which startup validation enforces. +/// verification through /.cratis/me does not run here in either mode — not even the +/// 403 veto applies to a browser session. A +/// deployment that requires verification may declare a bearer route only when the route says it accepts callers +/// without it (), which startup validation enforces; +/// otherwise a route that does not say so is reported at startup (). /// /// /// With no bearer route configured it hands every request on untouched. diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteStartupReport.cs b/Source/AuthProxy/BearerRoutes/BearerRouteStartupReport.cs index f84c1929..8a67353e 100644 --- a/Source/AuthProxy/BearerRoutes/BearerRouteStartupReport.cs +++ b/Source/AuthProxy/BearerRoutes/BearerRouteStartupReport.cs @@ -24,6 +24,10 @@ public Task StartAsync(CancellationToken cancellationToken) case BearerRouteWarningKind.DeploymentRequirementsIgnored: logger.BearerRouteIgnoresDeploymentRequirements(warning.Prefix, warning.ServiceName, subjects); break; + + case BearerRouteWarningKind.IdentityVerificationNotConsulted: + logger.BearerRouteDoesNotConsultIdentityVerification(warning.Prefix, warning.ServiceName, subjects); + break; } } diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteWarningKind.cs b/Source/AuthProxy/BearerRoutes/BearerRouteWarningKind.cs index 82ce6df7..3ff94e4b 100644 --- a/Source/AuthProxy/BearerRoutes/BearerRouteWarningKind.cs +++ b/Source/AuthProxy/BearerRoutes/BearerRouteWarningKind.cs @@ -14,4 +14,12 @@ public enum BearerRouteWarningKind /// requirements the deployment declares. /// DeploymentRequirementsIgnored = 0, + + /// + /// Services answer /.cratis/me for browser sessions, and an HTTP 403 from one + /// refuses a browser session even under . A + /// bearer route never calls it, so that refusal never happens there, and the route does not say it accepts + /// that through . + /// + IdentityVerificationNotConsulted = 1, } diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteWarnings.cs b/Source/AuthProxy/BearerRoutes/BearerRouteWarnings.cs index 5acbf6fd..b9aa1c4d 100644 --- a/Source/AuthProxy/BearerRoutes/BearerRouteWarnings.cs +++ b/Source/AuthProxy/BearerRoutes/BearerRouteWarnings.cs @@ -9,9 +9,18 @@ namespace Cratis.AuthProxy.BearerRoutes; /// Works out what each bearer route leaves unchecked that the deployment otherwise checks. /// /// -/// Each of these is a deliberate configuration, which is why none of them refuses to start. Each also means a -/// caller can reach the service through the route who would be refused through a browser session, which is why -/// none of them is allowed to be silent either. +/// Each of these is a deliberate configuration, or the default one, which is why none of them refuses to start. +/// Each also means a caller can reach the service through the route who would be refused through a browser +/// session, which is why none of them is allowed to be silent either. +/// +/// A bearer route does not call /.cratis/me — not even for the 403 veto the default +/// applies to a browser session. The endpoint is written for +/// browser sessions, and a product cannot be assumed to answer it correctly for a principal authenticated by a +/// token: one that answered 403 to every principal it did not recognize would lock out every token, +/// and one that answered 200 would give the veto no meaning. The product's backend decides tenant +/// membership for a token instead, and the route says it accepts that through +/// , or is reported here. +/// /// public static class BearerRouteWarnings { @@ -22,8 +31,20 @@ public static class BearerRouteWarnings /// The warnings, one per route and kind. public static IEnumerable For(C.AuthProxy config) { + // Every participating service is asked for a browser session, whichever service the request targets, so + // every one of them is a service whose refusal a bearer route does not get. + var consulted = config.Services + .Where(_ => _.Value.ParticipatesInIdentityResolution) + .Select(_ => _.Key) + .ToList(); + foreach (var route in BearerRouteTable.All(config)) { + if (consulted.Count > 0 && !route.Route.AcceptWithoutIdentityVerification) + { + yield return new BearerRouteWarning(BearerRouteWarningKind.IdentityVerificationNotConsulted, route.ServiceName, route.Prefix, consulted); + } + if (route.Route.IgnoreDeploymentRequiredClaims) { var ignored = DeploymentRequirementClaims(config, route.ServiceName); diff --git a/Source/AuthProxy/BearerRoutes/BearerRoutesLogging.cs b/Source/AuthProxy/BearerRoutes/BearerRoutesLogging.cs index 0bda63bf..4e4c5ddb 100644 --- a/Source/AuthProxy/BearerRoutes/BearerRoutesLogging.cs +++ b/Source/AuthProxy/BearerRoutes/BearerRoutesLogging.cs @@ -29,6 +29,9 @@ internal static partial class BearerRoutesLogging [LoggerMessage(LogLevel.Warning, "Bearer route '{Route}' of service '{Service}' sets IgnoreDeploymentRequiredClaims, so the deployment's claim requirements on {Claims} are not applied to its tokens. Only the route's own RequiredClaims are.")] internal static partial void BearerRouteIgnoresDeploymentRequirements(this ILogger logger, string route, string service, string claims); + [LoggerMessage(LogLevel.Warning, "Bearer route '{Route}' of service '{Service}' does not call /.cratis/me, so a 403 from {Services} that would refuse a browser session does not refuse its tokens; the backend must decide membership. Set AcceptWithoutIdentityVerification on the route to state that this is intended.")] + internal static partial void BearerRouteDoesNotConsultIdentityVerification(this ILogger logger, string route, string service, string services); + [LoggerMessage(LogLevel.Warning, "Forwarding bearer route '{Route}' of service '{Service}' failed ({Error}).")] internal static partial void BearerRouteForwardingFailed(this ILogger logger, Exception? exception, string route, string service, Yarp.ReverseProxy.Forwarder.ForwarderError error); } diff --git a/Source/AuthProxy/Configuration/BearerRoute.cs b/Source/AuthProxy/Configuration/BearerRoute.cs index 6896fcc7..ddf33eff 100644 --- a/Source/AuthProxy/Configuration/BearerRoute.cs +++ b/Source/AuthProxy/Configuration/BearerRoute.cs @@ -120,15 +120,22 @@ public class BearerRoute public bool IgnoreDeploymentRequiredClaims { get; set; } /// - /// Gets or sets a value indicating whether this route accepts callers although the deployment requires identity - /// verification. Defaults to . + /// Gets or sets a value indicating whether this route accepts callers without asking any service's + /// /.cratis/me about them. Defaults to . /// /// - /// A bearer route never calls /.cratis/me: it has no browser session to verify. When any service - /// declares , every forwarded request is meant to carry a positive - /// verdict, so a bearer route in that deployment is refused at startup unless it sets this — stating that for - /// this route the validated token, its scopes and the claim requirements are the whole decision, and that the - /// backend answers for everything else. + /// A bearer route never calls /.cratis/me, in either : the + /// endpoint answers for browser sessions, and a product cannot be assumed to answer it correctly for a principal + /// authenticated by a token. Setting this states that for this route the validated token, its scopes and the + /// claim requirements are the whole decision at the edge, and that the backend decides tenant membership and + /// everything else. + /// + /// When any service declares , every forwarded request is meant to + /// carry a positive verdict, so a bearer route in that deployment is refused at startup unless it sets this. + /// Under , where an HTTP 403 from + /// /.cratis/me refuses a browser session, a route that does not set this is started with a + /// warning that the refusal does not apply to its tokens. + /// /// public bool AcceptWithoutIdentityVerification { get; set; } } From 0adb52a2bf353eed8223ee32977c0889a4742977 Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 29 Sep 2026 08:12:44 +0200 Subject: [PATCH 14/24] Correct the bearer-route docs against what the gate does - Keys: a failed refresh keeps the last retrieved keys in use; tokens are refused, with 503, only when none were ever retrieved or the metadata names another issuer. Unknown-key refreshes are limited to one per 30s. - The typ default is at+jwt or application/at+jwt. - A token without a single sub, or without a claim a mapping reads, is a 401 invalid_token; a tenant must be a single usable one. - The metadata path strips Cookie and Authorization too, and answers 405 to other methods. - userDetails falls back to sub. - The forward adds no Service-ID but passes a caller's through; the sentence saying it was forwarded 'without a Service-ID header' sat in the identity-verification bullet and was wrong on both counts. Refs #143 --- Documentation/configuration/authentication.md | 27 +++++++++++-------- Documentation/configuration/services.md | 9 ++++--- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/Documentation/configuration/authentication.md b/Documentation/configuration/authentication.md index de748e7b..ed893009 100644 --- a/Documentation/configuration/authentication.md +++ b/Documentation/configuration/authentication.md @@ -565,9 +565,11 @@ A token is accepted only when all of these hold: - Its `iss` is exactly one of the route's issuers. - Its signature verifies against a key in the issuer's JWKS. The metadata document (RFC 8414, or OpenID Connect discovery) must name exactly the configured issuer. Metadata and keys are cached and refreshed periodically, - and a token naming an unknown key triggers a rate-limited refresh, so key rotation needs no restart. If the - keys cannot be retrieved after the last good copy, tokens are refused. -- Its `typ` header is an access-token type (`at+jwt` by default). + and a token naming an unknown key triggers a refresh at most every 30 seconds per issuer, so key rotation needs + no restart. A failed refresh keeps the last keys that were retrieved in use. Only when no keys have been + retrieved yet, or the metadata names another issuer, are tokens refused — with `503`, not as invalid. +- Its `typ` header is an access-token type: the issuer's `TokenTypes`, `at+jwt` or `application/at+jwt` by + default. - Its `aud` names one of the route's audiences. - It has an `exp`, and it is within its lifetime allowing the route's clock skew (30 seconds by default). - It carries every scope the route requires, a single `sub`, and a single usable tenant. @@ -585,19 +587,20 @@ first, so a requirement on a role can never be met by a bearer token. |-----------|----------| | Path on the route still percent-encoded after decoding, or containing a backslash, a `;` (path parameter, as in `/mcp/..;/api`) or a `.`/`..` segment | `400`, no challenge | | No bearer token (a browser session does not count) | `401`, `WWW-Authenticate: Bearer resource_metadata=""` | -| Token invalid, expired, wrongly signed, from another issuer or for another audience | `401`, `WWW-Authenticate: Bearer error="invalid_token", resource_metadata="…"` | +| Token invalid, expired, wrongly signed, from another issuer or for another audience; without a single `sub`; or without a claim a `ClaimMappings` entry reads | `401`, `WWW-Authenticate: Bearer error="invalid_token", resource_metadata="…"` | | Token lacks a required scope | `403`, `WWW-Authenticate: Bearer error="insufficient_scope", scope="", resource_metadata="…"` | | Token does not satisfy the claim requirements that apply on the route | `403` | -| Token carries no tenant, or the tenant fails verification | `403` | -| The issuer's metadata or keys cannot be retrieved | `503` with `Retry-After` | +| Token carries no single usable tenant, or the tenant fails verification | `403` | +| The issuer's metadata or keys have never been retrieved, or the metadata names another issuer | `503` with `Retry-After: 30` | | Bearer-route token on any other path — whatever the case of the scheme or the whitespace after it, and in any of several `Authorization` headers | `401`, `WWW-Authenticate: Bearer error="invalid_token"` | -`resource_metadata` is omitted when the route declares no `ResourceMetadataUrl`. Every refusal carries -`Cache-Control: no-store` and an empty body; the reason is logged, not returned. +`resource_metadata` is omitted when the route declares no `ResourceMetadataUrl`. Every refusal in the table +carries `Cache-Control: no-store` and an empty body; the reason is logged, not returned. The path of `ResourceMetadataUrl` (for example `/.well-known/oauth-protected-resource/mcp`) is forwarded to the -service backend for `GET` and `HEAD` without authentication and without any identity headers, so a client can -discover the authorization server before it has a token. The backend serves the document. +service backend for `GET` and `HEAD` without authentication and without any identity headers, `Cookie` or +`Authorization`, so a client can discover the authorization server before it has a token. The backend serves the +document. Any other method on that path is answered `405` with `Allow: GET, HEAD`. ### Forwarded headers @@ -605,7 +608,7 @@ A request accepted on a bearer route is forwarded to the service backend with: | Header | Value | |--------|-------| -| `x-ms-client-principal` | The principal: `identityProvider` from the route's `IdentityProvider`, `userId` from `sub`, `userDetails` from `preferred_username` or `name`, roles `anonymous` and `authenticated`, and the token's claims — after the route's `ClaimMappings` have applied. | +| `x-ms-client-principal` | The principal: `identityProvider` from the route's `IdentityProvider`, `userId` from `sub`, `userDetails` from `preferred_username`, else `name`, else `sub`, roles `anonymous` and `authenticated`, and the token's claims — after the route's `ClaimMappings` have applied. | | `x-ms-client-principal-id`, `x-ms-client-principal-name` | As for a browser session, from the same principal. | | `Tenant-ID` | The tenant from the token. | | `x-cratis-token-scope` | The granted scopes, space-separated. | @@ -618,6 +621,8 @@ claim, and role claims in the token are not forwarded: a token never grants a ro Every inbound copy of these headers is removed on every route, bearer or not. The `Cookie` header is not forwarded on a bearer route, and neither is `Authorization` unless the route sets `ForwardAuthorizationHeader`. +Every other request header is passed through as for any proxied request; AuthProxy adds no `Service-ID`. + ### Identity verification is not applied A bearer route **never calls `/.cratis/me`**, whatever `IdentityVerification` says. That endpoint answers for diff --git a/Documentation/configuration/services.md b/Documentation/configuration/services.md index 0bc101ce..4cd0da17 100644 --- a/Documentation/configuration/services.md +++ b/Documentation/configuration/services.md @@ -524,15 +524,18 @@ every token-authenticated request instead. `AcceptWithoutIdentityVerification: true`. Setting it is the operator's statement that, on this route, the validated token, its scopes and the claim - requirements are the whole decision at the edge and the backend answers for the rest. It is forwarded straight to the service **backend**, - whichever of the service's endpoints would otherwise serve that path, and without a `Service-ID` header. + requirements are the whole decision at the edge and the backend answers for the rest. +- An accepted request is forwarded straight to the service **backend**, whichever of the service's endpoints + would otherwise serve that path, with its path and query unchanged. AuthProxy adds no `Service-ID` header; one + the caller sent is passed through like any other request header that is not an identity header. - The session cookie is never read, and the `Cookie` header is not forwarded. - A request whose path on the route still carries percent-encoding after the server has decoded it (such as an encoded `/`), a backslash, a `;` anywhere in it, or a `.` or `..` segment is refused with `400` before its token is read: a backend that decoded or normalized it differently would receive a principal vouched for at a path that is not a bearer route. The `;` starts a path parameter, which Tomcat, Jetty and Spring strip before resolving dot segments, so they read `/mcp/..;/api/items` as `/api/items`. -- Every refusal is an API-style `400`, `401`, `403` or `503` — never a redirect or a page. See +- Every refusal is an API-style `400`, `401`, `403` or `503` — or `405` for a method other than `GET` or `HEAD` + on the `ResourceMetadataUrl` path — never a redirect or a page. See [Bearer routes](authentication.md#bearer-routes-access-tokens-from-an-authorization-server). - A token from a bearer-route issuer is refused with `401` on every path that is **not** one of the issuer's bearer routes, even where another bearer scheme (the [JWT Bearer](authentication.md#jwt-bearer-api) handler) From 7ccfa91a5dda7f1e684c1ed217174b667574cc77 Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 29 Sep 2026 08:23:16 +0200 Subject: [PATCH 15/24] Recognize role claim types case-insensitively on bearer routes ClaimsPrincipal.IsInRole matches claim types case-insensitively, so a case-variant role claim in a token would still grant a backend role. One helper now drops and refuses role claims in any casing. --- ...token_carries_claims_it_may_not_forward.cs | 4 ++- .../BearerRouteConfigurationValidator.cs | 10 ++------ .../BearerRoutes/BearerTokenValidator.cs | 7 +----- Source/AuthProxy/BearerRoutes/RoleClaims.cs | 25 +++++++++++++++++++ 4 files changed, 31 insertions(+), 15 deletions(-) create mode 100644 Source/AuthProxy/BearerRoutes/RoleClaims.cs diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_carries_claims_it_may_not_forward.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_carries_claims_it_may_not_forward.cs index 2d935280..3604b1e7 100644 --- a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_carries_claims_it_may_not_forward.cs +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_carries_claims_it_may_not_forward.cs @@ -30,6 +30,8 @@ public async Task InitializeAsync() { ["roles"] = new[] { "Administrator" }, ["role"] = "Administrator", + ["Roles"] = "Administrator", + ["HTTP://SCHEMAS.MICROSOFT.COM/WS/2008/06/IDENTITY/CLAIMS/ROLE"] = "Administrator", ["urn:cratis:bearer:scope"] = "direct:admin", ["urn:cratis:bearer:client-id"] = "trusted-client", ["urn:cratis:identity:subject"] = "someone-else", @@ -48,7 +50,7 @@ public async Task InitializeAsync() [Fact] public void should_forward_the_request() => Assert.NotNull(_principal); [Fact] public void should_grant_no_role() => Assert.Equal(["anonymous", "authenticated"], _principal!.UserRoles); - [Fact] public void should_drop_the_role_claims() => Assert.DoesNotContain(_principal!.Claims, _ => string.Equals(_.Type, "roles", StringComparison.Ordinal) || string.Equals(_.Type, "role", StringComparison.Ordinal) || _.Type.EndsWith("/role", StringComparison.Ordinal)); + [Fact] public void should_drop_the_role_claims() => Assert.DoesNotContain(_principal!.Claims, _ => string.Equals(_.Type, "roles", StringComparison.OrdinalIgnoreCase) || string.Equals(_.Type, "role", StringComparison.OrdinalIgnoreCase) || _.Type.EndsWith("/role", StringComparison.OrdinalIgnoreCase)); [Fact] public void should_forward_only_the_scopes_it_validated() => Assert.Equal(["direct:read", "direct:work"], _principal!.Claims.Where(_ => _.Type == "urn:cratis:bearer:scope").Select(_ => _.Value).Order()); [Fact] public void should_forward_no_client_claim() => Assert.DoesNotContain(_principal!.Claims, _ => _.Type == "urn:cratis:bearer:client-id"); diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteConfigurationValidator.cs b/Source/AuthProxy/BearerRoutes/BearerRouteConfigurationValidator.cs index 64a3326d..ef0b483c 100644 --- a/Source/AuthProxy/BearerRoutes/BearerRouteConfigurationValidator.cs +++ b/Source/AuthProxy/BearerRoutes/BearerRouteConfigurationValidator.cs @@ -2,7 +2,6 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System.Buffers; -using System.Security.Claims; using Cratis.AuthProxy.Authentication; using Microsoft.Extensions.Options; using C = Cratis.AuthProxy.Configuration; @@ -134,7 +133,7 @@ static void ValidateRoute( $"{at}:{nameof(C.BearerRoute.RequiredClaims)}:{i}:{nameof(C.ClaimRequirement.Claim)} must name a claim type. " + "A requirement without one can never be satisfied and would refuse every token."); } - else if (IsRoleClaim(claim)) + else if (RoleClaims.Is(claim)) { failures.Add( $"{at}:{nameof(C.BearerRoute.RequiredClaims)}:{i} requires the role claim '{claim}', which a bearer token never carries: " + @@ -148,7 +147,7 @@ static void ValidateRoute( { failures.Add($"{at}:{nameof(C.BearerRoute.ClaimMappings)} has a mapping with an empty claim type."); } - else if (CanonicalIdentityClaims.IsReserved(target) || BearerRouteClaims.IsReserved(target) || IsRoleClaim(target)) + else if (CanonicalIdentityClaims.IsReserved(target) || BearerRouteClaims.IsReserved(target) || RoleClaims.Is(target)) { failures.Add($"{at}:{nameof(C.BearerRoute.ClaimMappings)} may not write '{target}': AuthProxy owns that claim type."); } @@ -187,9 +186,4 @@ static void ValidateResourceMetadata( static bool Overlaps(string first, string second) => new PathString(first).StartsWithSegments(second) || new PathString(second).StartsWithSegments(first); - - static bool IsRoleClaim(string claimType) => - string.Equals(claimType, ClaimTypes.Role, StringComparison.Ordinal) - || string.Equals(claimType, "role", StringComparison.Ordinal) - || string.Equals(claimType, "roles", StringComparison.Ordinal); } diff --git a/Source/AuthProxy/BearerRoutes/BearerTokenValidator.cs b/Source/AuthProxy/BearerRoutes/BearerTokenValidator.cs index 770fd5ac..d2e864e3 100644 --- a/Source/AuthProxy/BearerRoutes/BearerTokenValidator.cs +++ b/Source/AuthProxy/BearerRoutes/BearerTokenValidator.cs @@ -309,7 +309,7 @@ static bool TryBuildForwardedClaims(ResolvedBearerRoute route, Claim[] tokenClai // Role claims are dropped too: the issuer vouches for who the caller is and what the client may attempt, // and the forwarded principal carries no role the backend did not grant itself. forwarded = [.. tokenClaims - .Where(_ => !CanonicalIdentityClaims.IsReserved(_.Type) && !BearerRouteClaims.IsReserved(_.Type) && !IsRoleClaim(_.Type)) + .Where(_ => !CanonicalIdentityClaims.IsReserved(_.Type) && !BearerRouteClaims.IsReserved(_.Type) && !RoleClaims.Is(_.Type)) .Select(_ => new Claim(_.Type, _.Value, _.ValueType))]; foreach (var (target, source) in route.Route.ClaimMappings) @@ -331,9 +331,4 @@ static bool TryBuildForwardedClaims(ResolvedBearerRoute route, Claim[] tokenClai return true; } - - static bool IsRoleClaim(string claimType) => - string.Equals(claimType, ClaimTypes.Role, StringComparison.Ordinal) - || string.Equals(claimType, "role", StringComparison.Ordinal) - || string.Equals(claimType, "roles", StringComparison.Ordinal); } diff --git a/Source/AuthProxy/BearerRoutes/RoleClaims.cs b/Source/AuthProxy/BearerRoutes/RoleClaims.cs new file mode 100644 index 00000000..0914e311 --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/RoleClaims.cs @@ -0,0 +1,25 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Security.Claims; + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Recognizes claim types that grant a role, so a bearer token can never carry one to a backend. +/// +/// +/// ClaimsPrincipal.IsInRole compares claim types case-insensitively, so this does too. +/// +static class RoleClaims +{ + /// + /// Determines whether a claim type grants a role. + /// + /// The claim type. + /// True when the claim type is a role claim in any casing. + internal static bool Is(string claimType) => + string.Equals(claimType, ClaimTypes.Role, StringComparison.OrdinalIgnoreCase) + || string.Equals(claimType, "role", StringComparison.OrdinalIgnoreCase) + || string.Equals(claimType, "roles", StringComparison.OrdinalIgnoreCase); +} From 67e57b0030c147ef86cabb621eb7eb0a226ffe3d Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 1 Oct 2026 16:33:53 +0200 Subject: [PATCH 16/24] Fix bearer issuer refresh and mapped identity consistency --- Documentation/configuration/authentication.md | 13 ++- Documentation/configuration/services.md | 2 +- .../when_identity_claims_are_mapped.cs | 67 +++++++++++++ .../when_the_deployment_requires_claims.cs | 10 ++ .../when_the_issuer_rotates_its_key.cs | 50 ++++++++++ ...esource_metadata_is_requested_otherwise.cs | 3 + .../given/StubIssuer.cs | 49 ++++++++-- .../when_initial_metadata_retrieval_fails.cs | 60 ++++++++++++ .../when_validating_bearer_routes.cs | 25 +++++ .../AuthProxy/BearerRoutes/BearerChallenge.cs | 16 ++-- .../BearerRoutes/BearerForwardedIdentity.cs | 6 +- .../BearerRoutes/BearerIssuerMetadata.cs | 96 ++++++++++++------- .../BearerRoutes/BearerIssuerMetadataCache.cs | 60 ++++++++++++ .../BearerRouteConfigurationValidator.cs | 11 +++ .../BearerRoutes/BearerRouteMiddleware.cs | 1 + .../BearerRoutes/BearerTokenValidator.cs | 15 ++- Source/AuthProxy/Configuration/BearerRoute.cs | 4 +- 17 files changed, 425 insertions(+), 63 deletions(-) create mode 100644 Source/AuthProxy.Security.Specs/for_BearerRoutes/when_identity_claims_are_mapped.cs create mode 100644 Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_issuer_rotates_its_key.cs create mode 100644 Source/AuthProxy.Specs/BearerRoutes/for_BearerIssuerMetadata/when_initial_metadata_retrieval_fails.cs create mode 100644 Source/AuthProxy/BearerRoutes/BearerIssuerMetadataCache.cs diff --git a/Documentation/configuration/authentication.md b/Documentation/configuration/authentication.md index ed893009..2d9c2414 100644 --- a/Documentation/configuration/authentication.md +++ b/Documentation/configuration/authentication.md @@ -566,8 +566,10 @@ A token is accepted only when all of these hold: - Its signature verifies against a key in the issuer's JWKS. The metadata document (RFC 8414, or OpenID Connect discovery) must name exactly the configured issuer. Metadata and keys are cached and refreshed periodically, and a token naming an unknown key triggers a refresh at most every 30 seconds per issuer, so key rotation needs - no restart. A failed refresh keeps the last keys that were retrieved in use. Only when no keys have been - retrieved yet, or the metadata names another issuer, are tokens refused — with `503`, not as invalid. + no restart. An allowed refresh completes before validation is retried in the same request. A failed refresh + keeps the last trusted keys in use and backs off retrieval for 30 seconds, including before the first success. + Metadata naming another issuer is never trusted. When no trusted keys have been retrieved, tokens are refused + with `503`, not as invalid. - Its `typ` header is an access-token type: the issuer's `TokenTypes`, `at+jwt` or `application/at+jwt` by default. - Its `aud` names one of the route's audiences. @@ -579,7 +581,10 @@ the route's service's own — to the principal after the route's `ClaimMappings` browser session, together with the route's own `RequiredClaims`. A route that sets `IgnoreDeploymentRequiredClaims` is held to its own requirements only, for a deployment whose requirements name a claim the issuer does not mint; AuthProxy logs a warning at startup for it. Role claims are dropped from the token -first, so a requirement on a role can never be met by a bearer token. +first, so a requirement on a role can never be met by a bearer token. A mapping replaces every case variant of +its target. Mapped `sub`, `preferred_username` and `name` must each have one usable source value; multiple values +refuse the token. Mapping into the route's tenant claim, or declaring targets differing only by case, is refused +at startup. ### Responses @@ -600,7 +605,7 @@ carries `Cache-Control: no-store` and an empty body; the reason is logged, not r The path of `ResourceMetadataUrl` (for example `/.well-known/oauth-protected-resource/mcp`) is forwarded to the service backend for `GET` and `HEAD` without authentication and without any identity headers, `Cookie` or `Authorization`, so a client can discover the authorization server before it has a token. The backend serves the -document. Any other method on that path is answered `405` with `Allow: GET, HEAD`. +document. Any other method on that path is answered `405` with `Allow: GET, HEAD` and `Cache-Control: no-store`. ### Forwarded headers diff --git a/Documentation/configuration/services.md b/Documentation/configuration/services.md index 4cd0da17..fca1774a 100644 --- a/Documentation/configuration/services.md +++ b/Documentation/configuration/services.md @@ -490,7 +490,7 @@ every token-authenticated request instead. | `RequiredScopes` | `string[]` | `[]` | Scopes the token must carry, every one of them. | | `ResourceMetadataUrl` | `string` | `null` | Absolute URL of the RFC 9728 protected-resource metadata document. Named in every challenge; its path is forwarded to the backend without authentication. | | `TenantClaimType` | `string` | `tid` | The token claim the tenant is read from. See [Tenancy](tenancy.md#bearer-routes). | -| `ClaimMappings` | `map` | `{}` | Forwarded claim type → token claim it is read from. A mapped source claim missing from the token refuses the token. Claim types containing `:` cannot be keys, because `:` separates configuration sections. | +| `ClaimMappings` | `map` | `{}` | Forwarded claim type → token claim it is read from, replacing all case variants of the target. A missing source refuses the token; mapped `sub`, `preferred_username` and `name` require one usable source value. Targets may not differ only by case or overwrite the route's tenant claim. Claim types containing `:` cannot be keys, because `:` separates configuration sections. | | `IdentityProvider` | `string` | `bearer` | The identity provider named in the forwarded principal. | | `ForwardAuthorizationHeader` | `bool` | `false` | Whether the backend also receives the `Authorization` header. | | `ClockSkew` | `TimeSpan` | `00:00:30` | Allowed clock skew for `exp` and `nbf`. At most `00:05:00`. | diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_identity_claims_are_mapped.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_identity_claims_are_mapped.cs new file mode 100644 index 00000000..c2a5096d --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_identity_claims_are_mapped.cs @@ -0,0 +1,67 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.Identity; + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// Mapped identity fields are single-valued and cannot be overridden by a surviving case variant. +/// +/// The proxy mapping the GitHub identity. +[Collection(BearerRouteSpecCollection.Name)] +public class when_identity_claims_are_mapped(BearerRouteHarness harness) +{ + [Theory] + [InlineData("github_id")] + [InlineData("github_login")] + public async Task should_refuse_multiple_values_for_a_mapped_identity_field(string source) + { + using var client = harness.CreateBearerClient(); + var path = $"/mcp/multiple-{source}"; + harness.Origin.Clear(); + using var response = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, path, harness.Issuer.Token(new Dictionary + { + [source] = new[] { "one", "two" }, + }))); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Contains("invalid_token", response.Headers.WwwAuthenticate.ToString(), StringComparison.Ordinal); + Assert.False(harness.Origin.ReceivedAnythingFor(path)); + } + + [Fact] + public async Task should_replace_all_case_variants_of_mapped_identity_claims() + { + using var client = harness.CreateBearerClient(); + const string path = "/mcp/case-variant-subject"; + using var response = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, path, harness.Issuer.Token(new Dictionary + { + ["SUB"] = "another-user", + ["PREFERRED_USERNAME"] = "another-name", + }))); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var forwarded = harness.Origin.LastRequestTo(path)!; + Assert.Equal(BearerRouteHarness.GitHubId, forwarded.Value(Headers.PrincipalId)); + Assert.Equal(BearerRouteHarness.GitHubLogin, forwarded.Value(Headers.PrincipalName)); + Assert.True(ClientPrincipal.TryFromBase64(forwarded.Value(Headers.Principal), out var principal)); + Assert.Single(principal!.Claims, _ => string.Equals(_.Type, "sub", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(principal.Claims, _ => _.Type == "SUB" || _.Type == "PREFERRED_USERNAME"); + } + + [Fact] + public async Task should_read_unmapped_jwt_identity_fields_with_ordinal_comparison() + { + using var client = harness.CreateBearerClient(); + const string path = "/v1/case-variant-subject"; + var token = harness.Issuer.TokenShaped(_ => _.Claims = new Dictionary + { + ["SUB"] = "another-user", + ["PREFERRED_USERNAME"] = "another-name", + }.Concat(StubIssuer.DefaultClaims()).ToDictionary(_ => _.Key, _ => _.Value)); + using var response = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, path, token)); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var forwarded = harness.Origin.LastRequestTo(path)!; + Assert.Equal(BearerRouteHarness.AccountId, forwarded.Value(Headers.PrincipalId)); + Assert.Equal(BearerRouteHarness.GitHubLogin, forwarded.Value(Headers.PrincipalName)); + } +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_deployment_requires_claims.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_deployment_requires_claims.cs index 5112b879..af46bf2b 100644 --- a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_deployment_requires_claims.cs +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_deployment_requires_claims.cs @@ -24,6 +24,7 @@ public class when_the_deployment_requires_claims(GatedBearerRouteHarness harness HttpResponseMessage? _otherOrganization; HttpResponseMessage? _mapped; HttpResponseMessage? _unmapped; + HttpResponseMessage? _caseVariant; public async Task InitializeAsync() { @@ -41,6 +42,11 @@ public async Task InitializeAsync() // The token's own preferred_username qualifies, but the route replaces it with github_login. _unmapped = await Send(client, UnmappedPath, harness.Issuer.Token(new Dictionary(organization) { ["github_login"] = "mallory" })); + _caseVariant = await Send(client, $"{UnmappedPath}-case-variant", harness.Issuer.Token(new Dictionary(organization) + { + ["github_login"] = "mallory", + ["PREFERRED_USERNAME"] = BearerRouteHarness.GitHubLogin, + })); } public Task DisposeAsync() @@ -50,6 +56,7 @@ public Task DisposeAsync() _otherOrganization?.Dispose(); _mapped?.Dispose(); _unmapped?.Dispose(); + _caseVariant?.Dispose(); return Task.CompletedTask; } @@ -61,6 +68,9 @@ public Task DisposeAsync() [Fact] public void should_check_the_service_requirement_after_the_claim_mappings() => Assert.Equal(HttpStatusCode.OK, _mapped!.StatusCode); [Fact] public void should_not_let_a_mapped_away_claim_satisfy_the_service_requirement() => Assert.Equal(HttpStatusCode.Forbidden, _unmapped!.StatusCode); + [Fact] public void should_not_let_a_case_variant_of_a_mapped_claim_satisfy_the_service_requirement() => Assert.Equal(HttpStatusCode.Forbidden, _caseVariant!.StatusCode); + [Fact] public void should_not_forward_a_token_qualifying_only_through_a_case_variant() => Assert.False(harness.Origin.ReceivedAnythingFor($"{UnmappedPath}-case-variant")); + static Task Send(HttpClient client, string path, string token) => client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, path, token)); } diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_issuer_rotates_its_key.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_issuer_rotates_its_key.cs new file mode 100644 index 00000000..cfd88fd1 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_issuer_rotates_its_key.cs @@ -0,0 +1,50 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// A token naming the issuer's new key succeeds on its first request after the old keys were cached. +/// +public class when_the_issuer_rotates_its_key +{ + [Fact] + public async Task should_refresh_before_retrying_the_first_rotated_token() + { + await using var harness = new BearerRouteHarness(); + using var client = harness.CreateBearerClient(); + using var initial = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, "/mcp/before-rotation", harness.Issuer.Token())); + Assert.Equal(HttpStatusCode.OK, initial.StatusCode); + + harness.Issuer.RotateKey(); + using var rotated = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, "/mcp/after-rotation", harness.Issuer.Token())); + Assert.Equal(HttpStatusCode.OK, rotated.StatusCode); + Assert.True(harness.Origin.ReceivedAnythingFor("/mcp/after-rotation")); + Assert.Equal(2, harness.Issuer.MetadataRequests); + + harness.Issuer.RotateKey(); + using var throttled = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, "/mcp/second-rotation", harness.Issuer.Token())); + Assert.Equal(HttpStatusCode.Unauthorized, throttled.StatusCode); + Assert.Equal(2, harness.Issuer.MetadataRequests); + } + + [Fact] + public async Task should_keep_known_keys_and_back_off_when_a_refresh_fails() + { + await using var harness = new BearerRouteHarness(); + using var client = harness.CreateBearerClient(); + var knownToken = harness.Issuer.Token(); + using var initial = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, "/mcp/known-key", knownToken)); + Assert.Equal(HttpStatusCode.OK, initial.StatusCode); + + harness.Issuer.RotateKey(); + harness.Issuer.MetadataUnavailable = true; + using var unknown = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, "/mcp/unknown-key", harness.Issuer.Token())); + Assert.Equal(HttpStatusCode.Unauthorized, unknown.StatusCode); + using var known = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, "/mcp/known-key-during-outage", knownToken)); + Assert.Equal(HttpStatusCode.OK, known.StatusCode); + using var retry = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, "/mcp/retry-unknown-key", harness.Issuer.Token())); + Assert.Equal(HttpStatusCode.Unauthorized, retry.StatusCode); + Assert.Equal(2, harness.Issuer.MetadataRequests); + } +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_resource_metadata_is_requested_otherwise.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_resource_metadata_is_requested_otherwise.cs index dfc434de..6f7772db 100644 --- a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_resource_metadata_is_requested_otherwise.cs +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_resource_metadata_is_requested_otherwise.cs @@ -66,6 +66,9 @@ public Task DisposeAsync() [Fact] public void should_refuse_delete() => Assert.Equal(HttpStatusCode.MethodNotAllowed, _delete!.StatusCode); [Fact] public void should_refuse_options() => Assert.Equal(HttpStatusCode.MethodNotAllowed, _options!.StatusCode); [Fact] public void should_name_the_methods_it_allows() => Assert.Equal("GET, HEAD", _put!.Content.Headers.Allow.Count > 0 ? string.Join(", ", _put.Content.Headers.Allow) : string.Empty); + [Fact] public void should_not_cache_put_refusals() => Assert.True(_put!.Headers.CacheControl?.NoStore); + [Fact] public void should_not_cache_delete_refusals() => Assert.True(_delete!.Headers.CacheControl?.NoStore); + [Fact] public void should_not_cache_options_refusals() => Assert.True(_options!.Headers.CacheControl?.NoStore); [Fact] public void should_not_forward_other_methods() => Assert.False(_writesForwarded); [Fact] public void should_match_case_insensitively() => Assert.Equal(HttpStatusCode.OK, _upperCase!.StatusCode); [Fact] public void should_match_with_a_trailing_separator() => Assert.Equal(HttpStatusCode.OK, _trailingSeparator!.StatusCode); diff --git a/Source/AuthProxy.Security.Specs/given/StubIssuer.cs b/Source/AuthProxy.Security.Specs/given/StubIssuer.cs index 5d641fd1..05e8b9ba 100644 --- a/Source/AuthProxy.Security.Specs/given/StubIssuer.cs +++ b/Source/AuthProxy.Security.Specs/given/StubIssuer.cs @@ -30,6 +30,8 @@ public sealed class StubIssuer : IAsyncDisposable readonly WebApplication _app; readonly RSA _rsa; + string _keyId = KeyId; + int _metadataRequests; StubIssuer(WebApplication app, RSA rsa, string issuer) { @@ -43,6 +45,16 @@ public sealed class StubIssuer : IAsyncDisposable /// public string Issuer { get; } + /// + /// Gets the number of discovery requests received. + /// + public int MetadataRequests => Volatile.Read(ref _metadataRequests); + + /// + /// Gets or sets whether discovery returns an unavailable document. + /// + public bool MetadataUnavailable { get; set; } + /// /// Starts a new stub issuer. /// @@ -55,14 +67,21 @@ public static async Task Start() builder.WebHost.UseUrls("http://127.0.0.1:0"); var app = builder.Build(); string? issuer = null; + StubIssuer? stub = null; - app.MapGet("/.well-known/oauth-authorization-server", () => Results.Json(new Dictionary + app.MapGet("/.well-known/oauth-authorization-server", () => { - ["issuer"] = issuer!, - ["jwks_uri"] = $"{issuer}.well-known/jwks", - ["authorization_endpoint"] = $"{issuer}connect/authorize", - ["token_endpoint"] = $"{issuer}connect/token", - })); + Interlocked.Increment(ref stub!._metadataRequests); + return stub.MetadataUnavailable + ? Results.NotFound() + : Results.Json(new Dictionary + { + ["issuer"] = issuer!, + ["jwks_uri"] = $"{issuer}.well-known/jwks", + ["authorization_endpoint"] = $"{issuer}connect/authorize", + ["token_endpoint"] = $"{issuer}connect/token", + }); + }); app.MapGet("/.well-known/jwks", () => { @@ -76,7 +95,7 @@ public static async Task Start() ["kty"] = "RSA", ["use"] = "sig", ["alg"] = SecurityAlgorithms.RsaSha256, - ["kid"] = KeyId, + ["kid"] = stub!._keyId, ["n"] = Base64UrlEncoder.Encode(parameters.Modulus), ["e"] = Base64UrlEncoder.Encode(parameters.Exponent), }, @@ -92,7 +111,7 @@ public static async Task Start() .First(); issuer = $"{address.TrimEnd('/')}/"; - return new StubIssuer(app, rsa, issuer); + return stub = new StubIssuer(app, rsa, issuer); } /// @@ -110,7 +129,17 @@ public string Token( DateTime? expires = null, string? issuer = null, params string[] without) => - Sign(new RsaSecurityKey(_rsa) { KeyId = KeyId }, claims, audience, expires, issuer, without); + Sign(new RsaSecurityKey(_rsa) { KeyId = _keyId }, claims, audience, expires, issuer, without); + + /// + /// Replaces the published signing key and its identifier. + /// + public void RotateKey() + { + using var replacement = RSA.Create(2048); + _rsa.ImportParameters(replacement.ExportParameters(includePrivateParameters: true)); + _keyId = Guid.NewGuid().ToString("N"); + } /// /// Signs an access token with the published key, then lets the caller reshape it before it is written. @@ -119,7 +148,7 @@ public string Token( /// Whether the handler fills in lifetimes the descriptor leaves unset. /// The token. public string TokenShaped(Action shape, bool setDefaultTimes = true) => - Sign(new RsaSecurityKey(_rsa) { KeyId = KeyId }, null, BearerRouteHarness.Audience, null, null, [], shape, setDefaultTimes); + Sign(new RsaSecurityKey(_rsa) { KeyId = _keyId }, null, BearerRouteHarness.Audience, null, null, [], shape, setDefaultTimes); /// /// Signs a token with HMAC, keyed with this issuer's published public key — the algorithm-confusion attack on diff --git a/Source/AuthProxy.Specs/BearerRoutes/for_BearerIssuerMetadata/when_initial_metadata_retrieval_fails.cs b/Source/AuthProxy.Specs/BearerRoutes/for_BearerIssuerMetadata/when_initial_metadata_retrieval_fails.cs new file mode 100644 index 00000000..cef101be --- /dev/null +++ b/Source/AuthProxy.Specs/BearerRoutes/for_BearerIssuerMetadata/when_initial_metadata_retrieval_fails.cs @@ -0,0 +1,60 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Net; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.IdentityModel.Tokens; + +namespace Cratis.AuthProxy.BearerRoutes.for_BearerIssuerMetadata; + +public class when_initial_metadata_retrieval_fails : Specification +{ + readonly ResolvedBearerIssuer _issuer = new("https://issuer.example.test/", "https://issuer.example.test/metadata", ["at+jwt"], true); + readonly FailingMetadataHandler _handler = new(); + HttpClient _client; + BearerIssuerMetadata _metadata; + IReadOnlyCollection?[] _results; + + void Establish() + { + _client = new HttpClient(_handler); + var factory = Substitute.For(); + factory.CreateClient(Arg.Any()).Returns(_client); + _metadata = new BearerIssuerMetadata(factory, NullLogger.Instance); + } + + async Task Because() + { + var first = _metadata.GetSigningKeys(_issuer, CancellationToken.None); + await _handler.Started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + var waiting = Enumerable.Range(0, 8).Select(_ => _metadata.GetSigningKeys(_issuer, CancellationToken.None)).ToArray(); + _handler.Complete.SetResult(); + _results = await Task.WhenAll(waiting.Prepend(first)); + _metadata.RequestRefresh(_issuer); + await _metadata.GetSigningKeys(_issuer, CancellationToken.None); + } + + void Destroy() + { + _metadata.Dispose(); + _client.Dispose(); + } + + [Fact] void should_return_unavailable_for_every_waiting_request() => _results.All(_ => _ is null).ShouldBeTrue(); + [Fact] void should_fetch_only_once_during_the_failure_backoff_even_when_refresh_is_requested() => _handler.Requests.ShouldEqual(1); + + sealed class FailingMetadataHandler : HttpMessageHandler + { + internal TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + internal TaskCompletionSource Complete { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + internal int Requests { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Requests++; + Started.TrySetResult(); + await Complete.Task.WaitAsync(cancellationToken); + return new HttpResponseMessage(HttpStatusCode.NotFound); + } + } +} diff --git a/Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteConfigurationValidator/when_validating_bearer_routes.cs b/Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteConfigurationValidator/when_validating_bearer_routes.cs index f7ebf20b..5de8c9e7 100644 --- a/Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteConfigurationValidator/when_validating_bearer_routes.cs +++ b/Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteConfigurationValidator/when_validating_bearer_routes.cs @@ -34,6 +34,10 @@ public class when_validating_bearer_routes : Specification ValidateOptionsResult _withRouteRequirement; ValidateOptionsResult _withRouteRequirementNamingNoClaim; ValidateOptionsResult _withRouteRequirementOnARole; + ValidateOptionsResult _mappingIntoTenant; + ValidateOptionsResult _mappingIntoCustomTenant; + ValidateOptionsResult _mappingIntoDefaultTenant; + ValidateOptionsResult _conflictingMappingTargets; void Because() { @@ -56,6 +60,22 @@ void Because() _resourceMetadataOfAnotherService = validator.Validate(null, Configuration(_ => { }, adjustConfiguration: _ => _.Services["other"] = Service(Route(prefix: "/v1")))); _withEmptyMapping = validator.Validate(null, Configuration(_ => _.ClaimMappings = new Dictionary { ["sub"] = " " })); _mappingIntoRoles = validator.Validate(null, Configuration(_ => _.ClaimMappings = new Dictionary { ["roles"] = "groups" })); + _mappingIntoTenant = validator.Validate(null, Configuration(_ => _.ClaimMappings = new Dictionary { ["TID"] = "another_tenant" })); + _mappingIntoCustomTenant = validator.Validate(null, Configuration(_ => + { + _.TenantClaimType = " tenant "; + _.ClaimMappings = new Dictionary { ["tenant"] = "another_tenant" }; + })); + _mappingIntoDefaultTenant = validator.Validate(null, Configuration(_ => + { + _.TenantClaimType = " "; + _.ClaimMappings = new Dictionary { ["tid"] = "another_tenant" }; + })); + _conflictingMappingTargets = validator.Validate(null, Configuration(_ => _.ClaimMappings = new Dictionary + { + ["sub"] = "github_id", + ["SUB"] = "another_id", + })); _verificationRequired = validator.Validate(null, Configuration(_ => { }, adjustConfiguration: _ => _.Services["main"].IdentityVerification = C.IdentityVerificationMode.Required)); _verificationRequiredByAnotherService = validator.Validate(null, Configuration(_ => { }, adjustConfiguration: _ => _.Services["other"] = RequiringVerification(new C.Service { Backend = new C.ServiceEndpoint { BaseUrl = "https://other.example.test" } }))); _verificationRequiredAndAcceptedWithout = validator.Validate(null, Configuration(_ => _.AcceptWithoutIdentityVerification = true, adjustConfiguration: _ => _.Services["main"].IdentityVerification = C.IdentityVerificationMode.Required)); @@ -95,6 +115,11 @@ void Because() [Fact] void should_refuse_a_route_requirement_on_a_role_a_token_never_carries() => _withRouteRequirementOnARole.Failed.ShouldBeTrue(); [Fact] void should_ignore_a_verification_requirement_of_a_service_that_resolves_no_identity() => _verificationRequiredOfANonParticipant.Succeeded.ShouldBeTrue(); + [Fact] void should_refuse_a_mapping_into_a_case_variant_of_the_tenant_claim() => _mappingIntoTenant.Failed.ShouldBeTrue(); + [Fact] void should_refuse_a_mapping_into_the_effective_custom_tenant_claim() => _mappingIntoCustomTenant.Failed.ShouldBeTrue(); + [Fact] void should_refuse_a_mapping_into_the_default_tenant_when_unset() => _mappingIntoDefaultTenant.Failed.ShouldBeTrue(); + [Fact] void should_refuse_mapping_targets_differing_only_by_case() => _conflictingMappingTargets.Failed.ShouldBeTrue(); + static C.AuthProxy Configuration( Action adjust, bool backend = true, diff --git a/Source/AuthProxy/BearerRoutes/BearerChallenge.cs b/Source/AuthProxy/BearerRoutes/BearerChallenge.cs index b332df3b..7a86f42b 100644 --- a/Source/AuthProxy/BearerRoutes/BearerChallenge.cs +++ b/Source/AuthProxy/BearerRoutes/BearerChallenge.cs @@ -126,16 +126,20 @@ public static string ChallengeValue(Uri? resourceMetadataUrl, string? error, str return parameters.Count == 0 ? "Bearer" : $"Bearer {string.Join(", ", parameters)}"; } + /// + /// Prevents caching a bearer-route refusal. + /// + /// The current HTTP context. + internal static void NoStore(HttpContext context) + { + context.Response.Headers.CacheControl = "no-store"; + context.Response.Headers[HeaderNames.Pragma] = "no-cache"; + } + static void Challenge(HttpContext context, int statusCode, Uri? resourceMetadataUrl, string? error, string? scope) { NoStore(context); context.Response.StatusCode = statusCode; context.Response.Headers.WWWAuthenticate = ChallengeValue(resourceMetadataUrl, error, scope); } - - static void NoStore(HttpContext context) - { - context.Response.Headers.CacheControl = "no-store"; - context.Response.Headers[HeaderNames.Pragma] = "no-cache"; - } } diff --git a/Source/AuthProxy/BearerRoutes/BearerForwardedIdentity.cs b/Source/AuthProxy/BearerRoutes/BearerForwardedIdentity.cs index 88e6dfce..08933470 100644 --- a/Source/AuthProxy/BearerRoutes/BearerForwardedIdentity.cs +++ b/Source/AuthProxy/BearerRoutes/BearerForwardedIdentity.cs @@ -36,9 +36,9 @@ public sealed record BearerForwardedIdentity( public static BearerForwardedIdentity From(BearerTokenValidation validation, ResolvedBearerRoute route) { var user = validation.Principal!; - var subject = user.FindFirst("sub")?.Value ?? string.Empty; - var details = user.FindFirst("preferred_username")?.Value - ?? user.FindFirst("name")?.Value + var subject = user.Claims.FirstOrDefault(_ => string.Equals(_.Type, "sub", StringComparison.Ordinal))?.Value ?? string.Empty; + var details = user.Claims.FirstOrDefault(_ => string.Equals(_.Type, "preferred_username", StringComparison.Ordinal))?.Value + ?? user.Claims.FirstOrDefault(_ => string.Equals(_.Type, "name", StringComparison.Ordinal))?.Value ?? subject; var principal = new ClientPrincipal diff --git a/Source/AuthProxy/BearerRoutes/BearerIssuerMetadata.cs b/Source/AuthProxy/BearerRoutes/BearerIssuerMetadata.cs index f450e676..a1b5aeca 100644 --- a/Source/AuthProxy/BearerRoutes/BearerIssuerMetadata.cs +++ b/Source/AuthProxy/BearerRoutes/BearerIssuerMetadata.cs @@ -14,66 +14,90 @@ namespace Cratis.AuthProxy.BearerRoutes; /// The factory for the client the metadata and JWKS are read with. /// The logger. /// -/// One configuration manager per metadata address holds the last good metadata and keys, refreshes them -/// periodically, and refreshes them on request when a token names an unknown key — which is how key rotation at the -/// issuer is picked up. A refresh that fails keeps the last good keys, so an issuer outage after the first retrieval -/// does not refuse tokens that were signed with a key already known. -/// -/// The metadata must name exactly the configured issuer. A document that names another one is refused rather than -/// trusted: the keys it points at belong to whoever that other issuer is. -/// +/// Each issuer keeps its last good keys. Requested refreshes finish before keys are returned, so a token naming +/// a newly rotated key can be retried in the same request. Retrievals are serialized per issuer and failed +/// retrievals back off, including before the first success. An outage does not discard previously trusted keys. +/// Metadata naming another issuer is never cached or trusted. /// public sealed class BearerIssuerMetadata( IHttpClientFactory httpClientFactory, - ILogger logger) : IBearerIssuerMetadata + ILogger logger) : IBearerIssuerMetadata, IDisposable { /// - /// The shortest interval between two requested refreshes of one issuer's metadata. + /// The shortest interval between two requested refreshes of one issuer's metadata, and the failure backoff. /// public static readonly TimeSpan RefreshInterval = TimeSpan.FromSeconds(30); - readonly ConcurrentDictionary<(string Address, bool RequireHttps), ConfigurationManager> _managers = new(); + readonly ConcurrentDictionary<(string Issuer, string Address, bool RequireHttps), BearerIssuerMetadataCache> _caches = new(); /// public async Task?> GetSigningKeys(ResolvedBearerIssuer issuer, CancellationToken cancellationToken) { + var cache = CacheFor(issuer); + await cache.Gate.WaitAsync(cancellationToken); try { - var configuration = await ManagerFor(issuer).GetConfigurationAsync(cancellationToken); - if (!string.Equals(configuration.Issuer, issuer.Issuer, StringComparison.Ordinal)) + var now = DateTimeOffset.UtcNow; + var requested = cache.TakeRefreshRequest(); + if (now >= cache.RetryAfter && + (cache.Configuration is null || now >= cache.AutomaticRefreshAfter || (requested && now >= cache.RequestedRefreshAfter))) { - logger.IssuerMetadataNamesAnotherIssuer(issuer.Issuer, issuer.MetadataAddress); - return null; + if (requested) + { + cache.RequestedRefreshAfter = now + RefreshInterval; + } + + try + { + var configuration = await OpenIdConnectConfigurationRetriever.GetAsync(issuer.MetadataAddress, cache.Retriever, cancellationToken); + if (string.Equals(configuration.Issuer, issuer.Issuer, StringComparison.Ordinal)) + { + cache.Configuration = configuration; + cache.AutomaticRefreshAfter = DateTimeOffset.UtcNow + ConfigurationManager.DefaultAutomaticRefreshInterval; + } + else + { + logger.IssuerMetadataNamesAnotherIssuer(issuer.Issuer, issuer.MetadataAddress); + cache.RetryAfter = DateTimeOffset.UtcNow + RefreshInterval; + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + cache.RetryAfter = DateTimeOffset.UtcNow + RefreshInterval; + logger.IssuerMetadataUnavailable(exception, issuer.Issuer, issuer.MetadataAddress); + } } - return [.. configuration.SigningKeys]; - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; + return cache.Configuration is { } cached ? [.. cached.SigningKeys] : null; } - catch (Exception exception) + finally { - logger.IssuerMetadataUnavailable(exception, issuer.Issuer, issuer.MetadataAddress); - return null; + cache.Gate.Release(); } } /// - public void RequestRefresh(ResolvedBearerIssuer issuer) => ManagerFor(issuer).RequestRefresh(); + public void RequestRefresh(ResolvedBearerIssuer issuer) => CacheFor(issuer).RequestRefresh(); - ConfigurationManager ManagerFor(ResolvedBearerIssuer issuer) => - _managers.GetOrAdd( - (issuer.MetadataAddress, issuer.RequireHttps), - static (key, factory) => new ConfigurationManager( - key.Address, - new OpenIdConnectConfigurationRetriever(), - new HttpDocumentRetriever(factory.CreateClient(BearerRouteDefaults.MetadataHttpClientName)) - { - RequireHttps = key.RequireHttps, - }) + /// + public void Dispose() + { + foreach (var cache in _caches.Values) + { + cache.Dispose(); + } + } + + BearerIssuerMetadataCache CacheFor(ResolvedBearerIssuer issuer) => + _caches.GetOrAdd( + (issuer.Issuer, issuer.MetadataAddress, issuer.RequireHttps), + static (key, factory) => new BearerIssuerMetadataCache(new HttpDocumentRetriever(factory.CreateClient(BearerRouteDefaults.MetadataHttpClientName)) { - RefreshInterval = RefreshInterval, - }, + RequireHttps = key.RequireHttps, + }), httpClientFactory); } diff --git a/Source/AuthProxy/BearerRoutes/BearerIssuerMetadataCache.cs b/Source/AuthProxy/BearerRoutes/BearerIssuerMetadataCache.cs new file mode 100644 index 00000000..ffabfb57 --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/BearerIssuerMetadataCache.cs @@ -0,0 +1,60 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Holds one issuer's synchronized last-good metadata and retrieval deadlines. +/// +/// The issuer's document retriever. +internal sealed class BearerIssuerMetadataCache(IDocumentRetriever retriever) : IDisposable +{ + int _refreshRequested; + + /// + /// Gets the gate serializing retrievals and cache access. + /// + internal SemaphoreSlim Gate { get; } = new(1, 1); + + /// + /// Gets the issuer's document retriever. + /// + internal IDocumentRetriever Retriever { get; } = retriever; + + /// + /// Gets or sets the last configuration naming the expected issuer. + /// + internal OpenIdConnectConfiguration? Configuration { get; set; } + + /// + /// Gets or sets the next periodic retrieval deadline. + /// + internal DateTimeOffset AutomaticRefreshAfter { get; set; } + + /// + /// Gets or sets the next allowed unknown-key refresh deadline. + /// + internal DateTimeOffset RequestedRefreshAfter { get; set; } + + /// + /// Gets or sets the next attempt after a failed retrieval. + /// + internal DateTimeOffset RetryAfter { get; set; } + + /// + public void Dispose() => Gate.Dispose(); + + /// + /// Marks an unknown-key refresh for the next signing-key lookup. + /// + internal void RequestRefresh() => Interlocked.Exchange(ref _refreshRequested, 1); + + /// + /// Consumes any pending refresh request. + /// + /// Whether a refresh was requested. + internal bool TakeRefreshRequest() => Interlocked.Exchange(ref _refreshRequested, 0) != 0; +} diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteConfigurationValidator.cs b/Source/AuthProxy/BearerRoutes/BearerRouteConfigurationValidator.cs index ef0b483c..5f8e6980 100644 --- a/Source/AuthProxy/BearerRoutes/BearerRouteConfigurationValidator.cs +++ b/Source/AuthProxy/BearerRoutes/BearerRouteConfigurationValidator.cs @@ -141,6 +141,8 @@ static void ValidateRoute( } } + var mappingTargets = new HashSet(StringComparer.OrdinalIgnoreCase); + var tenantClaim = string.IsNullOrWhiteSpace(route.TenantClaimType) ? C.BearerRoute.DefaultTenantClaimType : route.TenantClaimType.Trim(); foreach (var (target, source) in route.ClaimMappings) { if (string.IsNullOrWhiteSpace(target) || string.IsNullOrWhiteSpace(source)) @@ -151,6 +153,15 @@ static void ValidateRoute( { failures.Add($"{at}:{nameof(C.BearerRoute.ClaimMappings)} may not write '{target}': AuthProxy owns that claim type."); } + else if (string.Equals(target, tenantClaim, StringComparison.OrdinalIgnoreCase)) + { + failures.Add($"{at}:{nameof(C.BearerRoute.ClaimMappings)} may not write '{target}': the tenant must remain the token's original tenant claim."); + } + + if (!mappingTargets.Add(target)) + { + failures.Add($"{at}:{nameof(C.BearerRoute.ClaimMappings)} has conflicting targets differing only by case: '{target}'."); + } } } diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteMiddleware.cs b/Source/AuthProxy/BearerRoutes/BearerRouteMiddleware.cs index 8b06516f..4df53d8c 100644 --- a/Source/AuthProxy/BearerRoutes/BearerRouteMiddleware.cs +++ b/Source/AuthProxy/BearerRoutes/BearerRouteMiddleware.cs @@ -93,6 +93,7 @@ async Task ServeResourceMetadata(HttpContext context, ResolvedBearerRoute route) { if (!HttpMethods.IsGet(context.Request.Method) && !HttpMethods.IsHead(context.Request.Method)) { + BearerChallenge.NoStore(context); context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed; context.Response.Headers[HeaderNames.Allow] = "GET, HEAD"; return; diff --git a/Source/AuthProxy/BearerRoutes/BearerTokenValidator.cs b/Source/AuthProxy/BearerRoutes/BearerTokenValidator.cs index d2e864e3..606288be 100644 --- a/Source/AuthProxy/BearerRoutes/BearerTokenValidator.cs +++ b/Source/AuthProxy/BearerRoutes/BearerTokenValidator.cs @@ -189,6 +189,11 @@ public async Task Validate(HttpRequest request, ResolvedB $"The token carries no '{missingSource}' claim for a configured claim mapping."); } + if (!TryGetSingleValue(forwarded, SubjectClaimType, out _)) + { + return BearerTokenValidation.Refused(BearerTokenValidationStatus.Invalid, "The token carries no single subject."); + } + forwarded.Add(new Claim(BearerRouteClaims.Issuer, issuer.Issuer)); forwarded.Add(new Claim(BearerRouteClaims.Subject, subject)); if (TryGetSingleValue(claims, "azp", out var clientId) || TryGetSingleValue(claims, "client_id", out clientId)) @@ -299,6 +304,11 @@ static bool TryGetSingleValue(IEnumerable claims, string claimType, out s static bool IsUsableTenantId(string tenantId) => tenantId.Length <= MaximumTenantIdLength && tenantId.AsSpan().IndexOfAnyExcept(_tenantCharacters) < 0; + static bool IsSingleValueIdentityClaim(string type) => + string.Equals(type, SubjectClaimType, StringComparison.OrdinalIgnoreCase) + || string.Equals(type, "preferred_username", StringComparison.OrdinalIgnoreCase) + || string.Equals(type, "name", StringComparison.OrdinalIgnoreCase); + static bool TryBuildForwardedClaims(ResolvedBearerRoute route, Claim[] tokenClaims, out List forwarded, out string missingSource) { missingSource = string.Empty; @@ -319,13 +329,14 @@ static bool TryBuildForwardedClaims(ResolvedBearerRoute route, Claim[] tokenClai .Select(_ => _.Value) .Where(_ => !string.IsNullOrWhiteSpace(_)) .ToArray(); - if (values.Length == 0) + if (values.Length == 0 || (IsSingleValueIdentityClaim(target) && values.Length != 1)) { missingSource = source; return false; } - forwarded.RemoveAll(_ => string.Equals(_.Type, target, StringComparison.Ordinal)); + // ClaimsPrincipal authorization lookups ignore case; no variant of an overwritten target may survive. + forwarded.RemoveAll(_ => string.Equals(_.Type, target, StringComparison.OrdinalIgnoreCase)); forwarded.AddRange(values.Select(_ => new Claim(target, _))); } diff --git a/Source/AuthProxy/Configuration/BearerRoute.cs b/Source/AuthProxy/Configuration/BearerRoute.cs index ddf33eff..a9bc5826 100644 --- a/Source/AuthProxy/Configuration/BearerRoute.cs +++ b/Source/AuthProxy/Configuration/BearerRoute.cs @@ -72,7 +72,9 @@ public class BearerRoute /// /// Gets or sets the claims to rewrite before the principal is forwarded: forwarded claim type to the token claim - /// it is read from. A mapped source claim the token does not carry refuses the token. + /// it is read from, replacing all case variants of the target. A missing source refuses the token, as do + /// multiple usable source values for mapped sub, preferred_username or name. Targets may not differ only + /// by case or overwrite the tenant claim. /// public IDictionary ClaimMappings { get; set; } = new Dictionary(); From 554511c29c3d98b294aa50eb0283d92a6c6d2ec6 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 1 Oct 2026 17:59:09 +0200 Subject: [PATCH 17/24] Serve trusted bearer keys while issuer metadata refreshes --- .../when_the_issuer_is_slow_to_refresh.cs | 50 +++++++++++++++++++ .../given/StubIssuer.cs | 12 ++++- .../BearerRoutes/BearerIssuerMetadata.cs | 18 +++++-- .../BearerRoutes/BearerIssuerMetadataCache.cs | 9 +++- 4 files changed, 83 insertions(+), 6 deletions(-) create mode 100644 Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_issuer_is_slow_to_refresh.cs diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_issuer_is_slow_to_refresh.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_issuer_is_slow_to_refresh.cs new file mode 100644 index 00000000..95e9f630 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_issuer_is_slow_to_refresh.cs @@ -0,0 +1,50 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// Known-key tokens keep working while another request awaits a slow issuer's refresh response. +/// +public class when_the_issuer_is_slow_to_refresh +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task should_answer_known_key_requests_before_the_refresh_finishes(bool unavailable) + { + await using var harness = new BearerRouteHarness(); + using var client = harness.CreateBearerClient(); + var knownToken = harness.Issuer.Token(); + using var initial = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, "/mcp/initial", knownToken)); + Assert.Equal(HttpStatusCode.OK, initial.StatusCode); + + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + harness.Issuer.RotateKey(); + harness.Issuer.MetadataUnavailable = unavailable; + harness.Issuer.BeforeMetadataResponse = () => + { + started.TrySetResult(); + return release.Task; + }; + var refresh = client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, "/mcp/refresh", harness.Issuer.Token())); + try + { + await started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + using var known = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, "/mcp/known-during-refresh", knownToken)) + .WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal(HttpStatusCode.OK, known.StatusCode); + Assert.True(harness.Origin.ReceivedAnythingFor("/mcp/known-during-refresh")); + Assert.False(refresh.IsCompleted); + } + finally + { + release.TrySetResult(); + using var refreshed = await refresh.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal(unavailable ? HttpStatusCode.Unauthorized : HttpStatusCode.OK, refreshed.StatusCode); + } + + Assert.Equal(2, harness.Issuer.MetadataRequests); + } +} diff --git a/Source/AuthProxy.Security.Specs/given/StubIssuer.cs b/Source/AuthProxy.Security.Specs/given/StubIssuer.cs index 05e8b9ba..d3f54fdb 100644 --- a/Source/AuthProxy.Security.Specs/given/StubIssuer.cs +++ b/Source/AuthProxy.Security.Specs/given/StubIssuer.cs @@ -55,6 +55,11 @@ public sealed class StubIssuer : IAsyncDisposable /// public bool MetadataUnavailable { get; set; } + /// + /// Gets or sets a callback that can hold a discovery response until a spec releases it. + /// + public Func? BeforeMetadataResponse { get; set; } + /// /// Starts a new stub issuer. /// @@ -69,9 +74,14 @@ public static async Task Start() string? issuer = null; StubIssuer? stub = null; - app.MapGet("/.well-known/oauth-authorization-server", () => + app.MapGet("/.well-known/oauth-authorization-server", async () => { Interlocked.Increment(ref stub!._metadataRequests); + if (stub.BeforeMetadataResponse is { } beforeResponse) + { + await beforeResponse(); + } + return stub.MetadataUnavailable ? Results.NotFound() : Results.Json(new Dictionary diff --git a/Source/AuthProxy/BearerRoutes/BearerIssuerMetadata.cs b/Source/AuthProxy/BearerRoutes/BearerIssuerMetadata.cs index a1b5aeca..421124cf 100644 --- a/Source/AuthProxy/BearerRoutes/BearerIssuerMetadata.cs +++ b/Source/AuthProxy/BearerRoutes/BearerIssuerMetadata.cs @@ -16,7 +16,8 @@ namespace Cratis.AuthProxy.BearerRoutes; /// /// Each issuer keeps its last good keys. Requested refreshes finish before keys are returned, so a token naming /// a newly rotated key can be retried in the same request. Retrievals are serialized per issuer and failed -/// retrievals back off, including before the first success. An outage does not discard previously trusted keys. +/// retrievals back off, including before the first success. Ordinary lookups use previously trusted keys without +/// waiting for an in-progress retrieval. An outage does not discard previously trusted keys. /// Metadata naming another issuer is never cached or trusted. /// public sealed class BearerIssuerMetadata( @@ -34,11 +35,22 @@ public sealed class BearerIssuerMetadata( public async Task?> GetSigningKeys(ResolvedBearerIssuer issuer, CancellationToken cancellationToken) { var cache = CacheFor(issuer); - await cache.Gate.WaitAsync(cancellationToken); + var requested = cache.TakeRefreshRequest(); + if (!requested && cache.Configuration is { } available) + { + if (!cache.Gate.Wait(0)) + { + return [.. available.SigningKeys]; + } + } + else + { + await cache.Gate.WaitAsync(cancellationToken); + } + try { var now = DateTimeOffset.UtcNow; - var requested = cache.TakeRefreshRequest(); if (now >= cache.RetryAfter && (cache.Configuration is null || now >= cache.AutomaticRefreshAfter || (requested && now >= cache.RequestedRefreshAfter))) { diff --git a/Source/AuthProxy/BearerRoutes/BearerIssuerMetadataCache.cs b/Source/AuthProxy/BearerRoutes/BearerIssuerMetadataCache.cs index ffabfb57..d0839cce 100644 --- a/Source/AuthProxy/BearerRoutes/BearerIssuerMetadataCache.cs +++ b/Source/AuthProxy/BearerRoutes/BearerIssuerMetadataCache.cs @@ -13,9 +13,10 @@ namespace Cratis.AuthProxy.BearerRoutes; internal sealed class BearerIssuerMetadataCache(IDocumentRetriever retriever) : IDisposable { int _refreshRequested; + OpenIdConnectConfiguration? _configuration; /// - /// Gets the gate serializing retrievals and cache access. + /// Gets the gate serializing retrievals and deadline access. /// internal SemaphoreSlim Gate { get; } = new(1, 1); @@ -27,7 +28,11 @@ internal sealed class BearerIssuerMetadataCache(IDocumentRetriever retriever) : /// /// Gets or sets the last configuration naming the expected issuer. /// - internal OpenIdConnectConfiguration? Configuration { get; set; } + internal OpenIdConnectConfiguration? Configuration + { + get => Volatile.Read(ref _configuration); + set => Volatile.Write(ref _configuration, value); + } /// /// Gets or sets the next periodic retrieval deadline. From 3b10a8f51bcfedb9a20fe540f4e2382231fc98d8 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 1 Oct 2026 17:59:09 +0200 Subject: [PATCH 18/24] Normalize configured bearer issuers and tenant claim names --- ...onfiguration_has_surrounding_whitespace.cs | 38 +++++++++++++++++++ .../given/PaddedBearerRouteHarness.cs | 18 +++++++++ .../BearerRoutes/BearerRouteTable.cs | 2 +- .../BearerRoutes/ResolvedBearerRoute.cs | 2 +- 4 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 Source/AuthProxy.Security.Specs/for_BearerRoutes/when_bearer_configuration_has_surrounding_whitespace.cs create mode 100644 Source/AuthProxy.Security.Specs/given/PaddedBearerRouteHarness.cs diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_bearer_configuration_has_surrounding_whitespace.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_bearer_configuration_has_surrounding_whitespace.cs new file mode 100644 index 00000000..a279a804 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_bearer_configuration_has_surrounding_whitespace.cs @@ -0,0 +1,38 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// Startup validation and runtime authentication agree on trimmed tenant claims and issuer identifiers. +/// +public class when_bearer_configuration_has_surrounding_whitespace +{ + [Fact] + public async Task should_validate_and_forward_the_original_tenant_claim() + { + await using var harness = new PaddedBearerRouteHarness(); + using var client = harness.CreateBearerClient(); + using var response = await client.SendAsync(BearerRouteHarness.WithToken( + HttpMethod.Get, + "/mcp/trimmed-configuration", + harness.Issuer.Token(new Dictionary { ["tenant"] = BearerRouteHarness.TenantId }, without: ["tid"]))); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(BearerRouteHarness.TenantId, harness.Origin.LastRequestTo("/mcp/trimmed-configuration")?.Value(Headers.TenantId)); + } + + [Fact] + public async Task should_not_accept_a_padded_issuer_inside_a_token() + { + await using var harness = new PaddedBearerRouteHarness(); + using var client = harness.CreateBearerClient(); + using var response = await client.SendAsync(BearerRouteHarness.WithToken( + HttpMethod.Get, + "/mcp/padded-token-issuer", + harness.Issuer.Token(issuer: $" {harness.Issuer.Issuer} "))); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.False(harness.Origin.ReceivedAnythingFor("/mcp/padded-token-issuer")); + } +} diff --git a/Source/AuthProxy.Security.Specs/given/PaddedBearerRouteHarness.cs b/Source/AuthProxy.Security.Specs/given/PaddedBearerRouteHarness.cs new file mode 100644 index 00000000..eabde585 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/given/PaddedBearerRouteHarness.cs @@ -0,0 +1,18 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.given; + +/// +/// A bearer route whose configured tenant claim and issuer include surrounding whitespace. +/// +public class PaddedBearerRouteHarness : BearerRouteHarness +{ + /// + protected override void AddSettings(IDictionary settings) + { + const string route = $"{C.AuthProxy.SectionKey}:Services:app:BearerRoutes:0"; + settings[$"{route}:TenantClaimType"] = " tenant "; + settings[$"{route}:Issuers:0:Issuer"] = $" {Issuer.Issuer} "; + } +} diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteTable.cs b/Source/AuthProxy/BearerRoutes/BearerRouteTable.cs index b6db656c..57e3ae15 100644 --- a/Source/AuthProxy/BearerRoutes/BearerRouteTable.cs +++ b/Source/AuthProxy/BearerRoutes/BearerRouteTable.cs @@ -182,7 +182,7 @@ public static bool TryResolveIssuer(C.BearerIssuer issuer, out ResolvedBearerIss var tokenTypes = issuer.TokenTypes.Where(_ => !string.IsNullOrWhiteSpace(_)).Select(_ => _.Trim()).ToArray(); resolved = new ResolvedBearerIssuer( - issuer.Issuer, + issuer.Issuer.Trim(), metadataAddress, tokenTypes.Length > 0 ? tokenTypes : C.BearerIssuer.DefaultTokenTypes, RequireHttps: !isLoopbackHttp); diff --git a/Source/AuthProxy/BearerRoutes/ResolvedBearerRoute.cs b/Source/AuthProxy/BearerRoutes/ResolvedBearerRoute.cs index 4ba634c6..a670dc81 100644 --- a/Source/AuthProxy/BearerRoutes/ResolvedBearerRoute.cs +++ b/Source/AuthProxy/BearerRoutes/ResolvedBearerRoute.cs @@ -41,7 +41,7 @@ public sealed record ResolvedBearerRoute( /// public string TenantClaimType => string.IsNullOrWhiteSpace(Route.TenantClaimType) ? C.BearerRoute.DefaultTenantClaimType - : Route.TenantClaimType; + : Route.TenantClaimType.Trim(); /// /// Gets the identity provider label of the forwarded principal. From c30eec76152e527133a8c59642db756e37bb67cc Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 1 Oct 2026 21:08:41 +0200 Subject: [PATCH 19/24] Keep bearer refresh intent caller-specific and reject unsafe paths --- Documentation/configuration/authentication.md | 6 +- Documentation/configuration/services.md | 7 +- ...y_headers_are_spoofed_on_a_bearer_route.cs | 4 + ...peated_separators_hide_a_stricter_route.cs | 47 +++++++ .../given/BearerRouteHarness.cs | 12 +- .../given/RecordingBackend.cs | 13 +- .../given/a_cached_issuer.cs | 93 +++++++++++++ .../when_an_automatic_refresh_is_due.cs | 31 +++++ .../when_an_automatic_refresh_retry_is_due.cs | 31 +++++ ...ary_lookup_precedes_a_requested_refresh.cs | 34 +++++ .../when_initial_metadata_retrieval_fails.cs | 3 +- .../when_validating_bearer_routes.cs | 12 ++ .../BearerRoutes/BearerIssuerMetadata.cs | 125 +++++++++++------- .../BearerRoutes/BearerIssuerMetadataCache.cs | 19 ++- .../BearerRouteConfigurationValidator.cs | 10 ++ .../BearerRouteHeadersTransform.cs | 10 +- .../BearerRoutes/BearerRouteTable.cs | 6 +- .../BearerRoutes/BearerTokenValidator.cs | 3 +- .../BearerRoutes/IBearerIssuerMetadata.cs | 13 +- 19 files changed, 392 insertions(+), 87 deletions(-) create mode 100644 Source/AuthProxy.Security.Specs/for_BearerRoutes/when_repeated_separators_hide_a_stricter_route.cs create mode 100644 Source/AuthProxy.Specs/BearerRoutes/for_BearerIssuerMetadata/given/a_cached_issuer.cs create mode 100644 Source/AuthProxy.Specs/BearerRoutes/for_BearerIssuerMetadata/when_an_automatic_refresh_is_due.cs create mode 100644 Source/AuthProxy.Specs/BearerRoutes/for_BearerIssuerMetadata/when_an_automatic_refresh_retry_is_due.cs create mode 100644 Source/AuthProxy.Specs/BearerRoutes/for_BearerIssuerMetadata/when_an_ordinary_lookup_precedes_a_requested_refresh.cs diff --git a/Documentation/configuration/authentication.md b/Documentation/configuration/authentication.md index 80df16f0..fb77c1b7 100644 --- a/Documentation/configuration/authentication.md +++ b/Documentation/configuration/authentication.md @@ -574,7 +574,9 @@ A token is accepted only when all of these hold: - Its signature verifies against a key in the issuer's JWKS. The metadata document (RFC 8414, or OpenID Connect discovery) must name exactly the configured issuer. Metadata and keys are cached and refreshed periodically, and a token naming an unknown key triggers a refresh at most every 30 seconds per issuer, so key rotation needs - no restart. An allowed refresh completes before validation is retried in the same request. A failed refresh + no restart. An allowed refresh completes before validation is retried in the same request. Known-key lookups + use cached keys without waiting for retrieval; due automatic refreshes and their retries run in the background. + A failed refresh keeps the last trusted keys in use and backs off retrieval for 30 seconds, including before the first success. Metadata naming another issuer is never trusted. When no trusted keys have been retrieved, tokens are refused with `503`, not as invalid. @@ -598,7 +600,7 @@ at startup. | Situation | Response | |-----------|----------| -| Path on the route still percent-encoded after decoding, or containing a backslash, a `;` (path parameter, as in `/mcp/..;/api`) or a `.`/`..` segment | `400`, no challenge | +| Path on the route still percent-encoded after decoding, or containing a backslash, repeated `/` separators, a `;` (path parameter, as in `/mcp/..;/api`) or a `.`/`..` segment | `400`, no challenge | | No bearer token (a browser session does not count) | `401`, `WWW-Authenticate: Bearer resource_metadata=""` | | Token invalid, expired, wrongly signed, from another issuer or for another audience; without a single `sub`; or without a claim a `ClaimMappings` entry reads | `401`, `WWW-Authenticate: Bearer error="invalid_token", resource_metadata="…"` | | Token lacks a required scope | `403`, `WWW-Authenticate: Bearer error="insufficient_scope", scope="", resource_metadata="…"` | diff --git a/Documentation/configuration/services.md b/Documentation/configuration/services.md index 03c0a8bf..2f242ba6 100644 --- a/Documentation/configuration/services.md +++ b/Documentation/configuration/services.md @@ -501,7 +501,7 @@ every token-authenticated request instead. | `Issuers` | `BearerIssuerConfig[]` | — | The authorization servers whose tokens are accepted. At least one. | | `Audiences` | `string[]` | — | The token's `aud` must name at least one of these. At least one. | | `RequiredScopes` | `string[]` | `[]` | Scopes the token must carry, every one of them. | -| `ResourceMetadataUrl` | `string` | `null` | Absolute URL of the RFC 9728 protected-resource metadata document. Named in every challenge; its path is forwarded to the backend without authentication. | +| `ResourceMetadataUrl` | `string` | `null` | Absolute URL of the RFC 9728 protected-resource metadata document. Named in every challenge; its path is forwarded to the backend without authentication and must be outside every bearer-route prefix. | | `TenantClaimType` | `string` | `tid` | The token claim the tenant is read from. See [Tenancy](tenancy.md#bearer-routes). | | `ClaimMappings` | `map` | `{}` | Forwarded claim type → token claim it is read from, replacing all case variants of the target. A missing source refuses the token; mapped `sub`, `preferred_username` and `name` require one usable source value. Targets may not differ only by case or overwrite the route's tenant claim. Claim types containing `:` cannot be keys, because `:` separates configuration sections. | | `IdentityProvider` | `string` | `bearer` | The identity provider named in the forwarded principal. | @@ -543,10 +543,11 @@ every token-authenticated request instead. the caller sent is passed through like any other request header that is not an identity header. - The session cookie is never read, and the `Cookie` header is not forwarded. - A request whose path on the route still carries percent-encoding after the server has decoded it (such as an - encoded `/`), a backslash, a `;` anywhere in it, or a `.` or `..` segment is refused with `400` before its token + encoded `/`), a backslash, a `;` anywhere in it, repeated `/` separators, or a `.` or `..` segment is refused with `400` before its token is read: a backend that decoded or normalized it differently would receive a principal vouched for at a path that is not a bearer route. The `;` starts a path parameter, which Tomcat, Jetty and Spring strip before - resolving dot segments, so they read `/mcp/..;/api/items` as `/api/items`. + resolving dot segments, so they read `/mcp/..;/api/items` as `/api/items`. Repeated separators can hide a + stricter nested route from AuthProxy while a backend collapses them, so `/v1//admin` is refused too. - Every refusal is an API-style `400`, `401`, `403` or `503` — or `405` for a method other than `GET` or `HEAD` on the `ResourceMetadataUrl` path — never a redirect or a page. See [Bearer routes](authentication.md#bearer-routes-access-tokens-from-an-authorization-server). diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_identity_headers_are_spoofed_on_a_bearer_route.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_identity_headers_are_spoofed_on_a_bearer_route.cs index b98af235..d834878d 100644 --- a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_identity_headers_are_spoofed_on_a_bearer_route.cs +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_identity_headers_are_spoofed_on_a_bearer_route.cs @@ -30,6 +30,8 @@ public async Task InitializeAsync() request.Headers.TryAddWithoutValidation(Headers.PrincipalName, "attacker"); request.Headers.TryAddWithoutValidation(Headers.PrincipalNameExtended, "UTF-8''attacker"); request.Headers.TryAddWithoutValidation(Headers.TenantId, "victim-tenant"); + request.Headers.TryAddWithoutValidation(Headers.LegacyTenantId, "another-victim-tenant"); + request.Headers.TryAddWithoutValidation("x-ms-client-principal-idp", "forged-provider"); request.Headers.TryAddWithoutValidation(Headers.TokenScope, "direct:admin"); request.Headers.TryAddWithoutValidation(Headers.TokenClientId, "trusted-client"); @@ -45,6 +47,8 @@ public async Task InitializeAsync() [Fact] public void should_replace_the_principal_name() => Assert.Equal(BearerRouteHarness.GitHubLogin, _forwarded!.Value(Headers.PrincipalName)); [Fact] public void should_drop_the_extended_principal_name() => Assert.False(_forwarded!.Has(Headers.PrincipalNameExtended)); [Fact] public void should_replace_the_tenant() => Assert.Equal(BearerRouteHarness.TenantId, _forwarded!.Value(Headers.TenantId)); + [Fact] public void should_replace_the_legacy_tenant() => Assert.Equal(BearerRouteHarness.TenantId, _forwarded!.Value(Headers.LegacyTenantId)); + [Fact] public void should_drop_a_principal_header_the_proxy_never_writes() => Assert.False(_forwarded!.Has("x-ms-client-principal-idp")); [Fact] public void should_replace_the_scopes() => Assert.Equal("direct:read direct:work", _forwarded!.Value(Headers.TokenScope)); [Fact] public void should_take_the_client_from_the_token_alone() => Assert.Equal(BearerRouteHarness.ClientId, _forwarded!.Value(Headers.TokenClientId)); } diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_repeated_separators_hide_a_stricter_route.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_repeated_separators_hide_a_stricter_route.cs new file mode 100644 index 00000000..e2ed81f1 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_repeated_separators_hide_a_stricter_route.cs @@ -0,0 +1,47 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +public class when_repeated_separators_hide_a_stricter_route +{ + [Theory] + [InlineData("/v1//admin")] + [InlineData("/v1///admin/tools")] + public async Task should_not_forward_a_broadly_authorized_token_to_a_normalizing_backend(string path) + { + await using var harness = new NestedRoutesHarness(); + using var client = harness.CreateBearerClient(); + var token = harness.Issuer.Token(); + + // Demonstrate that this backend resolves the ambiguous path to the stricter route. + using var origin = new HttpClient(); + using var direct = await origin.GetAsync($"{harness.Origin.BaseUrl.TrimEnd('/')}{path}"); + Assert.Equal(HttpStatusCode.OK, direct.StatusCode); + Assert.True(harness.Origin.ReceivedAnythingFor(path.Replace("///", "/", StringComparison.Ordinal).Replace("//", "/", StringComparison.Ordinal))); + harness.Origin.Clear(); + + using var broad = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, "/v1/items", token)); + Assert.Equal(HttpStatusCode.OK, broad.StatusCode); + using var narrow = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, "/v1/admin", token)); + Assert.Equal(HttpStatusCode.Forbidden, narrow.StatusCode); + harness.Origin.Clear(); + + using var ambiguous = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, path, token)); + Assert.Equal(HttpStatusCode.BadRequest, ambiguous.StatusCode); + Assert.Equal("no-store", ambiguous.Headers.CacheControl?.ToString()); + Assert.Empty(harness.Origin.Received); + } + + sealed class NestedRoutesHarness() : BearerRouteHarness(normalizeRepeatedSeparators: true) + { + protected override void AddSettings(IDictionary settings) + { + const string route = $"{C.AuthProxy.SectionKey}:Services:app:BearerRoutes:3"; + settings[$"{route}:PathPrefix"] = "/v1/admin"; + settings[$"{route}:Issuers:0:Issuer"] = Issuer.Issuer; + settings[$"{route}:Audiences:0"] = Audience; + settings[$"{route}:RequiredScopes:0"] = "admin:write"; + } + } +} diff --git a/Source/AuthProxy.Security.Specs/given/BearerRouteHarness.cs b/Source/AuthProxy.Security.Specs/given/BearerRouteHarness.cs index 06d5b38e..ec30e8db 100644 --- a/Source/AuthProxy.Security.Specs/given/BearerRouteHarness.cs +++ b/Source/AuthProxy.Security.Specs/given/BearerRouteHarness.cs @@ -70,13 +70,21 @@ public class BearerRouteHarness : WebApplicationFactory /// /// Initializes a new instance of the class. /// - public BearerRouteHarness() + public BearerRouteHarness() : this(false) + { + } + + /// + /// Initializes a new instance of the class with backend path normalization. + /// + /// Whether the backend collapses repeated path separators. + protected BearerRouteHarness(bool normalizeRepeatedSeparators) { Directory.CreateDirectory(_pagesPath); File.WriteAllText(Path.Combine(_pagesPath, "select-provider.html"), "Select Provider"); File.WriteAllText(Path.Combine(_pagesPath, "forbidden.html"), "Forbidden"); - Origin = RecordingBackend.Start().GetAwaiter().GetResult(); + Origin = RecordingBackend.Start(normalizeRepeatedSeparators).GetAwaiter().GetResult(); Issuer = StubIssuer.Start().GetAwaiter().GetResult(); } diff --git a/Source/AuthProxy.Security.Specs/given/RecordingBackend.cs b/Source/AuthProxy.Security.Specs/given/RecordingBackend.cs index f5818c1f..77c4f651 100644 --- a/Source/AuthProxy.Security.Specs/given/RecordingBackend.cs +++ b/Source/AuthProxy.Security.Specs/given/RecordingBackend.cs @@ -88,8 +88,9 @@ public Func IdentityResponse /// /// Starts a new recording origin. /// + /// Whether to collapse repeated path separators like a slash-normalizing backend. /// The started origin. - public static async Task Start() + public static async Task Start(bool normalizeRepeatedSeparators = false) { var builder = WebApplication.CreateSlimBuilder(); builder.Logging.ClearProviders(); @@ -103,6 +104,16 @@ public static async Task Start() app.Use(async (context, next) => { + if (normalizeRepeatedSeparators) + { + var path = context.Request.Path.Value ?? string.Empty; + while (path.Contains("//", StringComparison.Ordinal)) + { + path = path.Replace("//", "/", StringComparison.Ordinal); + } + context.Request.Path = path; + } + state.Record(context); await next(); }); diff --git a/Source/AuthProxy.Specs/BearerRoutes/for_BearerIssuerMetadata/given/a_cached_issuer.cs b/Source/AuthProxy.Specs/BearerRoutes/for_BearerIssuerMetadata/given/a_cached_issuer.cs new file mode 100644 index 00000000..d0ad6096 --- /dev/null +++ b/Source/AuthProxy.Specs/BearerRoutes/for_BearerIssuerMetadata/given/a_cached_issuer.cs @@ -0,0 +1,93 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Net; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.IdentityModel.Tokens; + +namespace Cratis.AuthProxy.BearerRoutes.for_BearerIssuerMetadata.given; + +public class a_cached_issuer : Specification +{ + protected readonly ResolvedBearerIssuer Issuer = new("https://issuer.example.test/", "https://issuer.example.test/metadata", ["at+jwt"], true); + protected readonly MetadataHandler Handler = new(); + protected readonly MetadataClock Clock = new(); + protected BearerIssuerMetadata Metadata; + HttpClient _client; + + async Task Establish() + { + _client = new HttpClient(Handler); + var factory = Substitute.For(); + factory.CreateClient(Arg.Any()).Returns(_client); + Metadata = new BearerIssuerMetadata(factory, NullLogger.Instance, Clock); + await Metadata.GetSigningKeys(Issuer, CancellationToken.None); + } + + void Destroy() + { + Handler.Release.TrySetResult(); + Metadata.Dispose(); + _client.Dispose(); + } + + protected class MetadataClock : TimeProvider + { + DateTimeOffset _now = DateTimeOffset.UtcNow; + + public override DateTimeOffset GetUtcNow() => _now; + internal void Advance(TimeSpan interval) => _now += interval; + } + + protected class MetadataHandler : HttpMessageHandler + { + readonly string _modulus; + readonly string _exponent; + + internal MetadataHandler() + { + using var rsa = RSA.Create(2048); + var parameters = rsa.ExportParameters(false); + _modulus = Base64UrlEncoder.Encode(parameters.Modulus); + _exponent = Base64UrlEncoder.Encode(parameters.Exponent); + } + + internal TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + internal TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + internal bool Unavailable { get; set; } + internal bool Delay { get; set; } + internal int Requests { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.RequestUri!.AbsolutePath == "/metadata") + { + Requests++; + if (Delay) + { + Started.TrySetResult(); + await Release.Task.WaitAsync(cancellationToken); + } + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(new + { + issuer = Unavailable ? "https://another-issuer.example.test/" : "https://issuer.example.test/", + jwks_uri = "https://issuer.example.test/keys", + })), + }; + } + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(new + { + keys = new[] { new { kty = "RSA", use = "sig", kid = Requests == 1 ? "known" : "rotated", n = _modulus, e = _exponent } }, + })), + }; + } + } +} diff --git a/Source/AuthProxy.Specs/BearerRoutes/for_BearerIssuerMetadata/when_an_automatic_refresh_is_due.cs b/Source/AuthProxy.Specs/BearerRoutes/for_BearerIssuerMetadata/when_an_automatic_refresh_is_due.cs new file mode 100644 index 00000000..7088df05 --- /dev/null +++ b/Source/AuthProxy.Specs/BearerRoutes/for_BearerIssuerMetadata/when_an_automatic_refresh_is_due.cs @@ -0,0 +1,31 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.IdentityModel.Tokens; + +namespace Cratis.AuthProxy.BearerRoutes.for_BearerIssuerMetadata; + +public class when_an_automatic_refresh_is_due : given.a_cached_issuer +{ + IReadOnlyCollection? _keys; + IReadOnlyCollection? _concurrent; + + void Establish() + { + Clock.Advance(TimeSpan.FromHours(13)); + Handler.Delay = true; + } + + async Task Because() + { + var lookup = Metadata.GetSigningKeys(Issuer, CancellationToken.None); + await Handler.Started.Task.WaitAsync(TimeSpan.FromSeconds(5), TimeProvider.System); + _keys = await lookup.WaitAsync(TimeSpan.FromSeconds(5), TimeProvider.System); + _concurrent = await Metadata.GetSigningKeys(Issuer, CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5), TimeProvider.System); + } + + [Fact] void should_answer_the_initiating_lookup_before_the_issuer_responds() => _keys!.Single().KeyId.ShouldEqual("known"); + [Fact] void should_answer_concurrent_lookups_before_the_issuer_responds() => _concurrent!.Single().KeyId.ShouldEqual("known"); + [Fact] void should_start_only_one_background_refresh() => Handler.Requests.ShouldEqual(2); + [Fact] void should_not_need_the_issuer_to_finish() => Handler.Release.Task.IsCompleted.ShouldBeFalse(); +} diff --git a/Source/AuthProxy.Specs/BearerRoutes/for_BearerIssuerMetadata/when_an_automatic_refresh_retry_is_due.cs b/Source/AuthProxy.Specs/BearerRoutes/for_BearerIssuerMetadata/when_an_automatic_refresh_retry_is_due.cs new file mode 100644 index 00000000..3bb7238c --- /dev/null +++ b/Source/AuthProxy.Specs/BearerRoutes/for_BearerIssuerMetadata/when_an_automatic_refresh_retry_is_due.cs @@ -0,0 +1,31 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.IdentityModel.Tokens; + +namespace Cratis.AuthProxy.BearerRoutes.for_BearerIssuerMetadata; + +public class when_an_automatic_refresh_retry_is_due : given.a_cached_issuer +{ + IReadOnlyCollection? _keys; + + async Task Establish() + { + Clock.Advance(TimeSpan.FromHours(13)); + Handler.Unavailable = true; + await Metadata.GetSigningKeys(Issuer, CancellationToken.None, refresh: true); + Clock.Advance(BearerIssuerMetadata.RefreshInterval + TimeSpan.FromSeconds(1)); + Handler.Delay = true; + } + + async Task Because() + { + var lookup = Metadata.GetSigningKeys(Issuer, CancellationToken.None); + await Handler.Started.Task.WaitAsync(TimeSpan.FromSeconds(5), TimeProvider.System); + _keys = await lookup.WaitAsync(TimeSpan.FromSeconds(5), TimeProvider.System); + } + + [Fact] void should_answer_the_initiating_lookup_before_the_retry_finishes() => _keys!.Single().KeyId.ShouldEqual("known"); + [Fact] void should_start_the_retry_after_the_failure_backoff() => Handler.Requests.ShouldEqual(3); + [Fact] void should_not_need_the_unavailable_issuer_to_finish() => Handler.Release.Task.IsCompleted.ShouldBeFalse(); +} diff --git a/Source/AuthProxy.Specs/BearerRoutes/for_BearerIssuerMetadata/when_an_ordinary_lookup_precedes_a_requested_refresh.cs b/Source/AuthProxy.Specs/BearerRoutes/for_BearerIssuerMetadata/when_an_ordinary_lookup_precedes_a_requested_refresh.cs new file mode 100644 index 00000000..01c77533 --- /dev/null +++ b/Source/AuthProxy.Specs/BearerRoutes/for_BearerIssuerMetadata/when_an_ordinary_lookup_precedes_a_requested_refresh.cs @@ -0,0 +1,34 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.IdentityModel.Tokens; + +namespace Cratis.AuthProxy.BearerRoutes.for_BearerIssuerMetadata; + +public class when_an_ordinary_lookup_precedes_a_requested_refresh : given.a_cached_issuer +{ + IReadOnlyCollection? _before; + IReadOnlyCollection? _during; + IReadOnlyCollection? _refreshed; + bool _refreshWaited; + + void Establish() => Handler.Delay = true; + + async Task Because() + { + // Another caller looks up known keys between unknown-key detection and the caller's refresh operation. + _before = await Metadata.GetSigningKeys(Issuer, CancellationToken.None); + var refresh = Metadata.GetSigningKeys(Issuer, CancellationToken.None, refresh: true); + await Handler.Started.Task.WaitAsync(TimeSpan.FromSeconds(5), TimeProvider.System); + _during = await Metadata.GetSigningKeys(Issuer, CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5), TimeProvider.System); + _refreshWaited = !refresh.IsCompleted; + Handler.Release.TrySetResult(); + _refreshed = await refresh.WaitAsync(TimeSpan.FromSeconds(5), TimeProvider.System); + } + + [Fact] void should_not_refresh_for_the_unrelated_lookup() => _before!.Single().KeyId.ShouldEqual("known"); + [Fact] void should_return_cached_keys_to_an_ordinary_caller_during_refresh() => _during!.Single().KeyId.ShouldEqual("known"); + [Fact] void should_keep_the_requesting_caller_waiting_for_its_refresh() => _refreshWaited.ShouldBeTrue(); + [Fact] void should_return_the_rotated_key_to_the_requesting_caller() => _refreshed!.Single().KeyId.ShouldEqual("rotated"); + [Fact] void should_retrieve_only_for_the_initial_lookup_and_requested_refresh() => Handler.Requests.ShouldEqual(2); +} diff --git a/Source/AuthProxy.Specs/BearerRoutes/for_BearerIssuerMetadata/when_initial_metadata_retrieval_fails.cs b/Source/AuthProxy.Specs/BearerRoutes/for_BearerIssuerMetadata/when_initial_metadata_retrieval_fails.cs index cef101be..8662abee 100644 --- a/Source/AuthProxy.Specs/BearerRoutes/for_BearerIssuerMetadata/when_initial_metadata_retrieval_fails.cs +++ b/Source/AuthProxy.Specs/BearerRoutes/for_BearerIssuerMetadata/when_initial_metadata_retrieval_fails.cs @@ -30,8 +30,7 @@ async Task Because() var waiting = Enumerable.Range(0, 8).Select(_ => _metadata.GetSigningKeys(_issuer, CancellationToken.None)).ToArray(); _handler.Complete.SetResult(); _results = await Task.WhenAll(waiting.Prepend(first)); - _metadata.RequestRefresh(_issuer); - await _metadata.GetSigningKeys(_issuer, CancellationToken.None); + await _metadata.GetSigningKeys(_issuer, CancellationToken.None, refresh: true); } void Destroy() diff --git a/Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteConfigurationValidator/when_validating_bearer_routes.cs b/Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteConfigurationValidator/when_validating_bearer_routes.cs index 5de8c9e7..b007170b 100644 --- a/Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteConfigurationValidator/when_validating_bearer_routes.cs +++ b/Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteConfigurationValidator/when_validating_bearer_routes.cs @@ -38,11 +38,19 @@ public class when_validating_bearer_routes : Specification ValidateOptionsResult _mappingIntoCustomTenant; ValidateOptionsResult _mappingIntoDefaultTenant; ValidateOptionsResult _conflictingMappingTargets; + ValidateOptionsResult _metadataAtBearerPrefix; + ValidateOptionsResult _metadataUnderBearerPrefix; + ValidateOptionsResult _metadataUnderLaterBearerPrefix; + ValidateOptionsResult _metadataAtSimilarPrefix; void Because() { var validator = new BearerRouteConfigurationValidator(); + _metadataAtBearerPrefix = validator.Validate(null, Configuration(_ => _.ResourceMetadataUrl = "https://direct.example.test/MCP")); + _metadataUnderBearerPrefix = validator.Validate(null, Configuration(_ => _.ResourceMetadataUrl = "https://direct.example.test/mcp/metadata")); + _metadataUnderLaterBearerPrefix = validator.Validate(null, Configuration(_ => { }, adjustConfiguration: _ => _.Services["other"] = Service(Route(prefix: "/.well-known")))); + _metadataAtSimilarPrefix = validator.Validate(null, Configuration(_ => _.ResourceMetadataUrl = "https://direct.example.test/mcpx/metadata")); _valid = validator.Validate(null, Configuration(_ => { })); _withoutRoutes = validator.Validate(null, Configuration(_ => _.PathPrefix = string.Empty, declareRoute: false)); _withoutAudience = validator.Validate(null, Configuration(_ => _.Audiences = [])); @@ -89,6 +97,10 @@ void Because() _verificationRequiredOfANonParticipant = validator.Validate(null, Configuration(_ => { }, adjustConfiguration: _ => _.Services["other"] = RequiringVerification(new C.Service { Backend = new C.ServiceEndpoint { BaseUrl = "https://other.example.test" }, ResolveIdentityDetails = false }))); } + [Fact] void should_refuse_metadata_at_a_case_variant_of_the_bearer_prefix() => _metadataAtBearerPrefix.Failed.ShouldBeTrue(); + [Fact] void should_refuse_metadata_under_a_bearer_prefix() => _metadataUnderBearerPrefix.Failed.ShouldBeTrue(); + [Fact] void should_refuse_metadata_under_a_later_declared_route_of_another_service() => _metadataUnderLaterBearerPrefix.Failed.ShouldBeTrue(); + [Fact] void should_allow_metadata_on_a_similar_but_distinct_segment() => _metadataAtSimilarPrefix.Succeeded.ShouldBeTrue(); [Fact] void should_accept_a_complete_route() => _valid.Succeeded.ShouldBeTrue(); [Fact] void should_leave_a_deployment_without_bearer_routes_alone() => _withoutRoutes.Succeeded.ShouldBeTrue(); [Fact] void should_refuse_a_route_without_an_audience() => _withoutAudience.Failed.ShouldBeTrue(); diff --git a/Source/AuthProxy/BearerRoutes/BearerIssuerMetadata.cs b/Source/AuthProxy/BearerRoutes/BearerIssuerMetadata.cs index 421124cf..151b371f 100644 --- a/Source/AuthProxy/BearerRoutes/BearerIssuerMetadata.cs +++ b/Source/AuthProxy/BearerRoutes/BearerIssuerMetadata.cs @@ -13,16 +13,18 @@ namespace Cratis.AuthProxy.BearerRoutes; /// /// The factory for the client the metadata and JWKS are read with. /// The logger. +/// The clock used for retrieval deadlines. /// /// Each issuer keeps its last good keys. Requested refreshes finish before keys are returned, so a token naming /// a newly rotated key can be retried in the same request. Retrievals are serialized per issuer and failed /// retrievals back off, including before the first success. Ordinary lookups use previously trusted keys without -/// waiting for an in-progress retrieval. An outage does not discard previously trusted keys. +/// waiting for an in-progress retrieval, and start due automatic refreshes in the background. An outage does not discard previously trusted keys. /// Metadata naming another issuer is never cached or trusted. /// public sealed class BearerIssuerMetadata( IHttpClientFactory httpClientFactory, - ILogger logger) : IBearerIssuerMetadata, IDisposable + ILogger logger, + TimeProvider? timeProvider = null) : IBearerIssuerMetadata, IDisposable { /// /// The shortest interval between two requested refreshes of one issuer's metadata, and the failure backoff. @@ -30,58 +32,37 @@ public sealed class BearerIssuerMetadata( public static readonly TimeSpan RefreshInterval = TimeSpan.FromSeconds(30); readonly ConcurrentDictionary<(string Issuer, string Address, bool RequireHttps), BearerIssuerMetadataCache> _caches = new(); + readonly CancellationTokenSource _shutdown = new(); + readonly TimeProvider _clock = timeProvider ?? TimeProvider.System; /// - public async Task?> GetSigningKeys(ResolvedBearerIssuer issuer, CancellationToken cancellationToken) + public async Task?> GetSigningKeys(ResolvedBearerIssuer issuer, CancellationToken cancellationToken, bool refresh = false) { var cache = CacheFor(issuer); - var requested = cache.TakeRefreshRequest(); - if (!requested && cache.Configuration is { } available) + if (!refresh && cache.Configuration is { } available) { - if (!cache.Gate.Wait(0)) + if (cache.Gate.Wait(0)) { - return [.. available.SigningKeys]; + if (ShouldRetrieve(cache, refresh: false)) + { + // The retrieval owns the gate until it finishes. No request, including this one, waits for it. + cache.BackgroundRefresh = Task.Run(() => RefreshInBackground(issuer, cache)); + } + else + { + cache.Gate.Release(); + } } - } - else - { - await cache.Gate.WaitAsync(cancellationToken); + + return [.. available.SigningKeys]; } + await cache.Gate.WaitAsync(cancellationToken); try { - var now = DateTimeOffset.UtcNow; - if (now >= cache.RetryAfter && - (cache.Configuration is null || now >= cache.AutomaticRefreshAfter || (requested && now >= cache.RequestedRefreshAfter))) + if (ShouldRetrieve(cache, refresh)) { - if (requested) - { - cache.RequestedRefreshAfter = now + RefreshInterval; - } - - try - { - var configuration = await OpenIdConnectConfigurationRetriever.GetAsync(issuer.MetadataAddress, cache.Retriever, cancellationToken); - if (string.Equals(configuration.Issuer, issuer.Issuer, StringComparison.Ordinal)) - { - cache.Configuration = configuration; - cache.AutomaticRefreshAfter = DateTimeOffset.UtcNow + ConfigurationManager.DefaultAutomaticRefreshInterval; - } - else - { - logger.IssuerMetadataNamesAnotherIssuer(issuer.Issuer, issuer.MetadataAddress); - cache.RetryAfter = DateTimeOffset.UtcNow + RefreshInterval; - } - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch (Exception exception) - { - cache.RetryAfter = DateTimeOffset.UtcNow + RefreshInterval; - logger.IssuerMetadataUnavailable(exception, issuer.Issuer, issuer.MetadataAddress); - } + await Retrieve(issuer, cache, refresh, cancellationToken); } return cache.Configuration is { } cached ? [.. cached.SigningKeys] : null; @@ -92,16 +73,70 @@ public sealed class BearerIssuerMetadata( } } - /// - public void RequestRefresh(ResolvedBearerIssuer issuer) => CacheFor(issuer).RequestRefresh(); - /// public void Dispose() { + _shutdown.Cancel(); foreach (var cache in _caches.Values) { cache.Dispose(); } + _shutdown.Dispose(); + } + + bool ShouldRetrieve(BearerIssuerMetadataCache cache, bool refresh) + { + var now = _clock.GetUtcNow(); + return now >= cache.RetryAfter && + (cache.Configuration is null || now >= cache.AutomaticRefreshAfter || (refresh && now >= cache.RequestedRefreshAfter)); + } + + async Task RefreshInBackground(ResolvedBearerIssuer issuer, BearerIssuerMetadataCache cache) + { + try + { + await Retrieve(issuer, cache, refresh: false, _shutdown.Token); + } + catch (OperationCanceledException) when (_shutdown.IsCancellationRequested) + { + // Shutdown cancels and joins background retrievals before disposing their gates. + } + finally + { + cache.Gate.Release(); + } + } + + async Task Retrieve(ResolvedBearerIssuer issuer, BearerIssuerMetadataCache cache, bool refresh, CancellationToken cancellationToken) + { + if (refresh) + { + cache.RequestedRefreshAfter = _clock.GetUtcNow() + RefreshInterval; + } + + try + { + var configuration = await OpenIdConnectConfigurationRetriever.GetAsync(issuer.MetadataAddress, cache.Retriever, cancellationToken); + if (string.Equals(configuration.Issuer, issuer.Issuer, StringComparison.Ordinal)) + { + cache.Configuration = configuration; + cache.AutomaticRefreshAfter = _clock.GetUtcNow() + ConfigurationManager.DefaultAutomaticRefreshInterval; + } + else + { + logger.IssuerMetadataNamesAnotherIssuer(issuer.Issuer, issuer.MetadataAddress); + cache.RetryAfter = _clock.GetUtcNow() + RefreshInterval; + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + cache.RetryAfter = _clock.GetUtcNow() + RefreshInterval; + logger.IssuerMetadataUnavailable(exception, issuer.Issuer, issuer.MetadataAddress); + } } BearerIssuerMetadataCache CacheFor(ResolvedBearerIssuer issuer) => diff --git a/Source/AuthProxy/BearerRoutes/BearerIssuerMetadataCache.cs b/Source/AuthProxy/BearerRoutes/BearerIssuerMetadataCache.cs index d0839cce..a067b574 100644 --- a/Source/AuthProxy/BearerRoutes/BearerIssuerMetadataCache.cs +++ b/Source/AuthProxy/BearerRoutes/BearerIssuerMetadataCache.cs @@ -12,7 +12,6 @@ namespace Cratis.AuthProxy.BearerRoutes; /// The issuer's document retriever. internal sealed class BearerIssuerMetadataCache(IDocumentRetriever retriever) : IDisposable { - int _refreshRequested; OpenIdConnectConfiguration? _configuration; /// @@ -49,17 +48,15 @@ internal OpenIdConnectConfiguration? Configuration /// internal DateTimeOffset RetryAfter { get; set; } - /// - public void Dispose() => Gate.Dispose(); - /// - /// Marks an unknown-key refresh for the next signing-key lookup. + /// Gets or sets the background retrieval to join before disposing its gate. /// - internal void RequestRefresh() => Interlocked.Exchange(ref _refreshRequested, 1); + internal Task? BackgroundRefresh { get; set; } - /// - /// Consumes any pending refresh request. - /// - /// Whether a refresh was requested. - internal bool TakeRefreshRequest() => Interlocked.Exchange(ref _refreshRequested, 0) != 0; + /// + public void Dispose() + { + BackgroundRefresh?.GetAwaiter().GetResult(); + Gate.Dispose(); + } } diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteConfigurationValidator.cs b/Source/AuthProxy/BearerRoutes/BearerRouteConfigurationValidator.cs index 5f8e6980..a41d7c6e 100644 --- a/Source/AuthProxy/BearerRoutes/BearerRouteConfigurationValidator.cs +++ b/Source/AuthProxy/BearerRoutes/BearerRouteConfigurationValidator.cs @@ -52,6 +52,16 @@ public ValidateOptionsResult Validate(string? name, C.AuthProxy options) } } + // Check after collecting every prefix so declaration order and service ownership cannot hide a conflict. + foreach (var metadataPath in metadataPaths.Keys) + { + var conflictingPrefix = prefixes.Keys.FirstOrDefault(_ => new PathString(metadataPath).StartsWithSegments(_, StringComparison.OrdinalIgnoreCase)); + if (conflictingPrefix is not null) + { + failures.Add($"{nameof(C.BearerRoute.ResourceMetadataUrl)} path '{metadataPath}' is under bearer route '{conflictingPrefix}'. Resource metadata is forwarded without authentication and must be outside every bearer route."); + } + } + return failures.Count == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(failures); } diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteHeadersTransform.cs b/Source/AuthProxy/BearerRoutes/BearerRouteHeadersTransform.cs index 945ad65c..19067506 100644 --- a/Source/AuthProxy/BearerRoutes/BearerRouteHeadersTransform.cs +++ b/Source/AuthProxy/BearerRoutes/BearerRouteHeadersTransform.cs @@ -23,11 +23,7 @@ public class BearerRouteHeadersTransform : RequestTransform public override ValueTask ApplyAsync(RequestTransformContext context) { var headers = context.ProxyRequest.Headers; - headers.Remove(Headers.Principal); - headers.Remove(Headers.PrincipalId); - headers.Remove(Headers.PrincipalName); - headers.Remove(Headers.PrincipalNameExtended); - headers.Remove(Headers.TenantId); + SpoofableHeaders.Strip(headers); headers.Remove(Headers.TokenClientId); headers.Remove(Headers.TokenScope); headers.Remove(HeaderNames.Cookie); @@ -47,7 +43,9 @@ public override ValueTask ApplyAsync(RequestTransformContext context) } context.ProxyRequest.SetMicrosoftIdentityHeaders(identity.Principal); - headers.TryAddWithoutValidation(Headers.TenantId, HeaderValue.ToTransportValue(identity.TenantId)); + var tenantId = HeaderValue.ToTransportValue(identity.TenantId); + headers.TryAddWithoutValidation(Headers.TenantId, tenantId); + headers.TryAddWithoutValidation(Headers.LegacyTenantId, tenantId); if (identity.Scopes.Count > 0) { headers.TryAddWithoutValidation(Headers.TokenScope, HeaderValue.ToTransportValue(string.Join(' ', identity.Scopes))); diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteTable.cs b/Source/AuthProxy/BearerRoutes/BearerRouteTable.cs index 57e3ae15..eae0718f 100644 --- a/Source/AuthProxy/BearerRoutes/BearerRouteTable.cs +++ b/Source/AuthProxy/BearerRoutes/BearerRouteTable.cs @@ -90,14 +90,16 @@ public static bool TryMatch(PathString path, C.AuthProxy config, out ResolvedBea /// Jetty and Spring read /mcp/..;/api as /api — and others keep, the same /// rule applies to a declared prefix. A backend that decoded /// /mcp/..%2Fapi into /api would receive a principal vouched for on a bearer route - /// at a path that is not one, so a bearer route accepts none of these. + /// at a path that is not one. Repeated separators are also refused because a backend may collapse them and + /// select a narrower route whose policy was not checked. A bearer route accepts none of these. /// public static bool IsUnambiguous(PathString path) { var value = path.Value ?? string.Empty; if (value.Contains('%', StringComparison.Ordinal) || value.Contains('\\', StringComparison.Ordinal) - || value.Contains(';', StringComparison.Ordinal)) + || value.Contains(';', StringComparison.Ordinal) + || value.Contains("//", StringComparison.Ordinal)) { return false; } diff --git a/Source/AuthProxy/BearerRoutes/BearerTokenValidator.cs b/Source/AuthProxy/BearerRoutes/BearerTokenValidator.cs index 606288be..5b14c5d3 100644 --- a/Source/AuthProxy/BearerRoutes/BearerTokenValidator.cs +++ b/Source/AuthProxy/BearerRoutes/BearerTokenValidator.cs @@ -138,8 +138,7 @@ public async Task Validate(HttpRequest request, ResolvedB { // The token names a key this proxy has not seen — the issuer may have rotated. Ask for fresh keys once; // refreshes are rate limited, so this cannot be turned into a flood of requests to the issuer. - metadata.RequestRefresh(issuer); - keys = await metadata.GetSigningKeys(issuer, cancellationToken); + keys = await metadata.GetSigningKeys(issuer, cancellationToken, refresh: true); if (keys is null) { return BearerTokenValidation.Refused(BearerTokenValidationStatus.IssuerUnavailable, "The issuer's signing keys are unavailable."); diff --git a/Source/AuthProxy/BearerRoutes/IBearerIssuerMetadata.cs b/Source/AuthProxy/BearerRoutes/IBearerIssuerMetadata.cs index 8a62da25..e2fe690e 100644 --- a/Source/AuthProxy/BearerRoutes/IBearerIssuerMetadata.cs +++ b/Source/AuthProxy/BearerRoutes/IBearerIssuerMetadata.cs @@ -15,16 +15,7 @@ public interface IBearerIssuerMetadata /// /// The issuer. /// The request's cancellation token. + /// Whether this caller needs to await a rate-limited unknown-key refresh before receiving keys. /// The signing keys, or when they could not be retrieved or the metadata names another issuer. - Task?> GetSigningKeys(ResolvedBearerIssuer issuer, CancellationToken cancellationToken); - - /// - /// Asks for the issuer's metadata and keys to be retrieved again, because a token names a key that is not known. - /// - /// The issuer. - /// - /// Refreshes are rate limited, so a flood of tokens naming unknown keys cannot turn into a flood of requests to - /// the issuer. - /// - void RequestRefresh(ResolvedBearerIssuer issuer); + Task?> GetSigningKeys(ResolvedBearerIssuer issuer, CancellationToken cancellationToken, bool refresh = false); } From 39d037242cd4b5b7f9baaf7b6d221f692db30500 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 1 Oct 2026 23:18:01 +0200 Subject: [PATCH 20/24] Remove trailing blank lines from bearer route guides --- Documentation/configuration/services.md | 1 - Documentation/configuration/tenancy.md | 1 - 2 files changed, 2 deletions(-) diff --git a/Documentation/configuration/services.md b/Documentation/configuration/services.md index 8eaaea94..8c36b96e 100644 --- a/Documentation/configuration/services.md +++ b/Documentation/configuration/services.md @@ -641,4 +641,3 @@ every token-authenticated request instead. belongs to a service without a backend is refused at startup, as is one that does not accept callers without identity verification in a deployment that requires it. - With no bearer route configured, nothing changes. - diff --git a/Documentation/configuration/tenancy.md b/Documentation/configuration/tenancy.md index 80f75cab..749659c4 100644 --- a/Documentation/configuration/tenancy.md +++ b/Documentation/configuration/tenancy.md @@ -269,4 +269,3 @@ never read and the tenant-selection page is never served. that fails verification is refused with `403` rather than the tenant-not-found page. - The tenant is forwarded as `Tenant-ID`. It records which tenant the user chose when the token was granted; the backend still decides whether the user is a member of it. - From bd2a11db1f975d122fb22456a19c34304f1cd05e Mon Sep 17 00:00:00 2001 From: woksin Date: Fri, 2 Oct 2026 00:48:31 +0200 Subject: [PATCH 21/24] Align bearer-route specs with required identity verification --- Documentation/configuration/authentication.md | 4 ++-- Documentation/configuration/services.md | 4 ++-- ...he_caller_through_its_identity_endpoint.cs | 4 ++-- .../given/BearerRouteHarness.cs | 1 + .../when_validating_bearer_routes.cs | 21 +++++++++++++++++++ .../BearerRoutes/BearerRouteWarnings.cs | 2 +- 6 files changed, 29 insertions(+), 7 deletions(-) diff --git a/Documentation/configuration/authentication.md b/Documentation/configuration/authentication.md index da3bdbea..a42ff026 100644 --- a/Documentation/configuration/authentication.md +++ b/Documentation/configuration/authentication.md @@ -644,13 +644,13 @@ A bearer route **never calls `/.cratis/me`**, whatever `IdentityVerification` sa browser sessions, and a product cannot be assumed to answer it correctly for a principal authenticated by a token. The consequences: -- Under the default `IdentityVerification: BestEffort`, a service answering `403` on `/.cratis/me` refuses a +- Under explicitly configured `IdentityVerification: BestEffort`, a service answering `403` on `/.cratis/me` refuses a browser session — but not a token on a bearer route. A user whose browser session a service refuses there can still reach the service with a token. **The backend must enforce tenant membership** and what the user may do with the scopes the client was granted. AuthProxy logs a warning at startup for every bearer route in a deployment where some service answers `/.cratis/me`, unless the route sets `AcceptWithoutIdentityVerification` to state that this is intended. -- Under `IdentityVerification: Required`, AuthProxy refuses to start with a bearer route unless the route sets +- Under `IdentityVerification: Required` (the default), AuthProxy refuses to start with a bearer route unless the route sets `AcceptWithoutIdentityVerification`. - No identity details are resolved, so no `.cratis-identity` cookie is written. diff --git a/Documentation/configuration/services.md b/Documentation/configuration/services.md index f91de1c0..c4f56621 100644 --- a/Documentation/configuration/services.md +++ b/Documentation/configuration/services.md @@ -727,10 +727,10 @@ every token-authenticated request instead. A requirement on a role can never be met: roles are dropped from every token. - A bearer route **never calls `/.cratis/me`**, in either identity-verification mode: the endpoint answers for browser sessions, not for principals authenticated by a token. The backend must enforce tenant membership. - - Under the default `BestEffort`, a `403` from a service's `/.cratis/me` refuses a browser session but not a + - Under explicitly configured `BestEffort`, a `403` from a service's `/.cratis/me` refuses a browser session but not a token. AuthProxy starts, and logs a warning for each bearer route in a deployment where some service answers `/.cratis/me`, unless the route sets `AcceptWithoutIdentityVerification: true`. - - Under `Required`, AuthProxy **refuses to start** with a bearer route unless the route sets + - Under `Required` (the default), AuthProxy **refuses to start** with a bearer route unless the route sets `AcceptWithoutIdentityVerification: true`. Setting it is the operator's statement that, on this route, the validated token, its scopes and the claim diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_service_would_refuse_the_caller_through_its_identity_endpoint.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_service_would_refuse_the_caller_through_its_identity_endpoint.cs index 2dff45ff..e9dbce10 100644 --- a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_service_would_refuse_the_caller_through_its_identity_endpoint.cs +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_service_would_refuse_the_caller_through_its_identity_endpoint.cs @@ -6,13 +6,13 @@ namespace Cratis.AuthProxy.Security.for_BearerRoutes; /// -/// Under the default best-effort identity verification, a service answering 403 on +/// Under explicitly configured best-effort identity verification, a service answering 403 on /// /.cratis/me refuses a browser session. A bearer route never calls that endpoint — it answers for /// browser sessions, not for principals authenticated by a token — so the refusal does not apply there: the token is /// forwarded, and the backend decides membership. This is the documented behavior, reported at startup, not an /// oversight a token can exploit without the operator knowing. /// -/// The running proxy, with the default best-effort identity verification. +/// The running proxy, with explicitly configured best-effort identity verification. [Collection(BearerRouteSpecCollection.Name)] public class when_a_service_would_refuse_the_caller_through_its_identity_endpoint(BearerRouteHarness harness) : IAsyncLifetime { diff --git a/Source/AuthProxy.Security.Specs/given/BearerRouteHarness.cs b/Source/AuthProxy.Security.Specs/given/BearerRouteHarness.cs index ec30e8db..86aa7e34 100644 --- a/Source/AuthProxy.Security.Specs/given/BearerRouteHarness.cs +++ b/Source/AuthProxy.Security.Specs/given/BearerRouteHarness.cs @@ -189,6 +189,7 @@ protected virtual void ConfigureHost(IWebHostBuilder builder) { [$"{C.AuthProxy.SectionKey}:Services:app:Backend:BaseUrl"] = Origin.BaseUrl, [$"{C.AuthProxy.SectionKey}:Services:app:Frontend:BaseUrl"] = Origin.BaseUrl, + [$"{C.AuthProxy.SectionKey}:Services:app:IdentityVerification"] = nameof(C.IdentityVerificationMode.BestEffort), [$"{route}:PathPrefix"] = RoutePrefix, [$"{route}:Issuers:0:Issuer"] = Issuer.Issuer, diff --git a/Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteConfigurationValidator/when_validating_bearer_routes.cs b/Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteConfigurationValidator/when_validating_bearer_routes.cs index b007170b..8f07ff16 100644 --- a/Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteConfigurationValidator/when_validating_bearer_routes.cs +++ b/Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteConfigurationValidator/when_validating_bearer_routes.cs @@ -42,6 +42,8 @@ public class when_validating_bearer_routes : Specification ValidateOptionsResult _metadataUnderBearerPrefix; ValidateOptionsResult _metadataUnderLaterBearerPrefix; ValidateOptionsResult _metadataAtSimilarPrefix; + ValidateOptionsResult _verificationRequiredByDefault; + ValidateOptionsResult _verificationRequiredByDefaultAndAcceptedWithout; void Because() { @@ -84,6 +86,22 @@ void Because() ["sub"] = "github_id", ["SUB"] = "another_id", })); + _verificationRequiredByDefault = validator.Validate(null, Configuration(_ => { }, adjustConfiguration: _ => _.Services["main"] = new C.Service + { + Backend = new C.ServiceEndpoint { BaseUrl = "https://backend.example.test" }, + BearerRoutes = [Route()], + })); + _verificationRequiredByDefaultAndAcceptedWithout = validator.Validate(null, Configuration(_ => { }, adjustConfiguration: _ => _.Services["main"] = new C.Service + { + Backend = new C.ServiceEndpoint { BaseUrl = "https://backend.example.test" }, + BearerRoutes = [new C.BearerRoute + { + PathPrefix = "/mcp", + Issuers = [new C.BearerIssuer { Issuer = "https://auth.example.test/" }], + Audiences = ["direct-api"], + AcceptWithoutIdentityVerification = true, + }], + })); _verificationRequired = validator.Validate(null, Configuration(_ => { }, adjustConfiguration: _ => _.Services["main"].IdentityVerification = C.IdentityVerificationMode.Required)); _verificationRequiredByAnotherService = validator.Validate(null, Configuration(_ => { }, adjustConfiguration: _ => _.Services["other"] = RequiringVerification(new C.Service { Backend = new C.ServiceEndpoint { BaseUrl = "https://other.example.test" } }))); _verificationRequiredAndAcceptedWithout = validator.Validate(null, Configuration(_ => _.AcceptWithoutIdentityVerification = true, adjustConfiguration: _ => _.Services["main"].IdentityVerification = C.IdentityVerificationMode.Required)); @@ -118,6 +136,8 @@ void Because() [Fact] void should_refuse_a_resource_metadata_path_another_service_already_serves() => _resourceMetadataOfAnotherService.Failed.ShouldBeTrue(); [Fact] void should_refuse_a_mapping_with_an_empty_claim_type() => _withEmptyMapping.Failed.ShouldBeTrue(); [Fact] void should_refuse_a_mapping_into_a_role_claim() => _mappingIntoRoles.Failed.ShouldBeTrue(); + [Fact] void should_refuse_a_route_when_its_service_requires_identity_verification_by_default() => _verificationRequiredByDefault.Failed.ShouldBeTrue(); + [Fact] void should_accept_a_route_accepting_callers_without_the_default_identity_verification() => _verificationRequiredByDefaultAndAcceptedWithout.Succeeded.ShouldBeTrue(); [Fact] void should_refuse_a_route_when_its_service_requires_identity_verification() => _verificationRequired.Failed.ShouldBeTrue(); [Fact] void should_name_the_setting_that_accepts_it() => _verificationRequired.FailureMessage.ShouldContain(nameof(C.BearerRoute.AcceptWithoutIdentityVerification)); [Fact] void should_refuse_a_route_when_another_service_requires_identity_verification() => _verificationRequiredByAnotherService.Failed.ShouldBeTrue(); @@ -169,6 +189,7 @@ static C.AuthProxy Configuration( static C.Service Service(C.BearerRoute? route) => new() { Backend = new C.ServiceEndpoint { BaseUrl = "https://backend.example.test" }, + IdentityVerification = C.IdentityVerificationMode.BestEffort, BearerRoutes = route is null ? [] : [route], }; diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteWarnings.cs b/Source/AuthProxy/BearerRoutes/BearerRouteWarnings.cs index b9aa1c4d..afcbe55b 100644 --- a/Source/AuthProxy/BearerRoutes/BearerRouteWarnings.cs +++ b/Source/AuthProxy/BearerRoutes/BearerRouteWarnings.cs @@ -13,7 +13,7 @@ namespace Cratis.AuthProxy.BearerRoutes; /// Each also means a caller can reach the service through the route who would be refused through a browser /// session, which is why none of them is allowed to be silent either. /// -/// A bearer route does not call /.cratis/me — not even for the 403 veto the default +/// A bearer route does not call /.cratis/me — not even for the 403 veto /// applies to a browser session. The endpoint is written for /// browser sessions, and a product cannot be assumed to answer it correctly for a principal authenticated by a /// token: one that answered 403 to every principal it did not recognize would lock out every token, From 8ba1239515fa34e4fd1dfef78444cf4dec324f56 Mon Sep 17 00:00:00 2001 From: woksin Date: Fri, 2 Oct 2026 02:36:25 +0200 Subject: [PATCH 22/24] Reject ambiguous bearer-route aliases and honor activity timeouts --- Documentation/configuration/authentication.md | 2 +- Documentation/configuration/services.md | 22 +++-- .../when_bearer_route_paths_are_matched.cs | 4 +- ...d_separators_hide_the_only_bearer_route.cs | 56 +++++++++++ .../when_activity_timeouts_are_configured.cs | 94 +++++++++++++++++++ .../BearerRoutes/BearerRouteForwarder.cs | 17 +++- .../BearerRoutes/BearerRouteMiddleware.cs | 16 ++-- .../BearerRoutes/BearerRoutesLogging.cs | 4 +- 8 files changed, 192 insertions(+), 23 deletions(-) create mode 100644 Source/AuthProxy.Security.Specs/for_BearerRoutes/when_repeated_separators_hide_the_only_bearer_route.cs create mode 100644 Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteForwarder/when_activity_timeouts_are_configured.cs diff --git a/Documentation/configuration/authentication.md b/Documentation/configuration/authentication.md index a42ff026..32fdc9a0 100644 --- a/Documentation/configuration/authentication.md +++ b/Documentation/configuration/authentication.md @@ -600,7 +600,7 @@ at startup. | Situation | Response | |-----------|----------| -| Path on the route still percent-encoded after decoding, or containing a backslash, repeated `/` separators, a `;` (path parameter, as in `/mcp/..;/api`) or a `.`/`..` segment | `400`, no challenge | +| Path still percent-encoded after decoding, or containing a backslash, repeated `/` separators, a `;` (path parameter, as in `/mcp/..;/api`) or a `.`/`..` segment, on any route when bearer routes are configured | `400` before route selection, no challenge | | No bearer token (a browser session does not count) | `401`, `WWW-Authenticate: Bearer resource_metadata=""` | | Token invalid, expired, wrongly signed, from another issuer or for another audience; without a single `sub`; or without a claim a `ClaimMappings` entry reads | `401`, `WWW-Authenticate: Bearer error="invalid_token", resource_metadata="…"` | | Token lacks a required scope | `403`, `WWW-Authenticate: Bearer error="insufficient_scope", scope="", resource_metadata="…"` | diff --git a/Documentation/configuration/services.md b/Documentation/configuration/services.md index c4f56621..b2f1b7da 100644 --- a/Documentation/configuration/services.md +++ b/Documentation/configuration/services.md @@ -737,14 +737,18 @@ every token-authenticated request instead. requirements are the whole decision at the edge and the backend answers for the rest. - An accepted request is forwarded straight to the service **backend**, whichever of the service's endpoints would otherwise serve that path, with its path and query unchanged. AuthProxy adds no `Service-ID` header; one - the caller sent is passed through like any other request header that is not an identity header. + the caller sent is passed through like any other request header that is not an identity header. The backend's + [activity timeout](#timeouts-and-streaming) applies, with backend → service → root → default precedence, + including after configuration reloads. - The session cookie is never read, and the `Cookie` header is not forwarded. -- A request whose path on the route still carries percent-encoding after the server has decoded it (such as an - encoded `/`), a backslash, a `;` anywhere in it, repeated `/` separators, or a `.` or `..` segment is refused with `400` before its token - is read: a backend that decoded or normalized it differently would receive a principal vouched for at a path - that is not a bearer route. The `;` starts a path parameter, which Tomcat, Jetty and Spring strip before - resolving dot segments, so they read `/mcp/..;/api/items` as `/api/items`. Repeated separators can hide a - stricter nested route from AuthProxy while a backend collapses them, so `/v1//admin` is refused too. +- When any bearer route is configured, a request whose path still carries percent-encoding after the server + has decoded it (such as an encoded `/`), a backslash, a `;` anywhere in it, repeated `/` separators, or a `.` or + `..` segment is refused with `400` **before route selection**, including on browser-session paths. A backend + that decoded or normalized it differently could otherwise serve a bearer resource under browser-session + authentication or a weaker bearer policy. The `;` starts a path parameter, which Tomcat, Jetty and Spring + strip before resolving dot segments, so they read `/mcp/..;/api/items` as `/api/items`. Repeated separators can + hide a stricter nested route, such as `/v1//admin`, or the only bearer prefix, such as `/api//mcp/tools` when + `/api/mcp` is configured. Clients must send unambiguous paths; there is no browser-session fallback. - Every refusal is an API-style `400`, `401`, `403` or `503` — or `405` for a method other than `GET` or `HEAD` on the `ResourceMetadataUrl` path — never a redirect or a page. See [Bearer routes](authentication.md#bearer-routes-access-tokens-from-an-authorization-server). @@ -754,4 +758,6 @@ every token-authenticated request instead. - A route that overlaps an anonymous path, repeats another route's prefix, names no issuer or audience, or belongs to a service without a backend is refused at startup, as is one that does not accept callers without identity verification in a deployment that requires it. -- With no bearer route configured, nothing changes. +- With no bearer route configured, the bearer gate leaves requests untouched. Browser sessions still strip + inbound `x-cratis-token-scope` and `x-cratis-token-client-id` headers and claims in the `urn:cratis:bearer:` + namespace, which only bearer routes set. diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_bearer_route_paths_are_matched.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_bearer_route_paths_are_matched.cs index b804dbf0..794901f1 100644 --- a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_bearer_route_paths_are_matched.cs +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_bearer_route_paths_are_matched.cs @@ -8,7 +8,7 @@ namespace Cratis.AuthProxy.Security.for_BearerRoutes; /// /// A bearer route covers its prefix case-insensitively and on segment boundaries only: /mcpx and /// /api/mcp are not /mcp, so a token presented there is refused as off its routes. A -/// path on a bearer route that a backend could decode or normalize into some other path — an encoded separator, any +/// path that a backend could decode or normalize into some other path — an encoded separator, any /// other remaining percent-encoding, a backslash, a dot segment, a path parameter (;), which /// Tomcat, Jetty and Spring strip before resolving the dot segment it hides in — is refused before the token is looked at, because /// the principal forwarded with it would be vouched for at a path that is not a bearer route. @@ -69,7 +69,7 @@ public async Task InitializeAsync() [Fact] public void should_refuse_remaining_percent_encoding() => Assert.Equal(StatusCodes.Status400BadRequest, _doubleEncodedDot!.Response.StatusCode); [Fact] public void should_refuse_a_dot_segment() => Assert.Equal(StatusCodes.Status400BadRequest, _dotSegment!.Response.StatusCode); [Fact] public void should_refuse_a_backslash() => Assert.Equal(StatusCodes.Status400BadRequest, _backslash!.Response.StatusCode); - [Fact] public void should_not_match_the_prefix_followed_by_a_backslash() => Assert.Equal(StatusCodes.Status401Unauthorized, _backslashAfterThePrefix!.Response.StatusCode); + [Fact] public void should_refuse_a_backslash_before_matching_the_prefix() => Assert.Equal(StatusCodes.Status400BadRequest, _backslashAfterThePrefix!.Response.StatusCode); [Fact] public void should_refuse_a_parent_segment_hidden_by_a_path_parameter() => Assert.Equal(StatusCodes.Status400BadRequest, _parentSegmentWithPathParameter!.Response.StatusCode); [Fact] public void should_refuse_a_current_segment_hidden_by_a_path_parameter() => Assert.Equal(StatusCodes.Status400BadRequest, _currentSegmentWithPathParameter!.Response.StatusCode); [Fact] public void should_refuse_a_path_parameter_on_any_segment() => Assert.Equal(StatusCodes.Status400BadRequest, _pathParameterOnAnOrdinarySegment!.Response.StatusCode); diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_repeated_separators_hide_the_only_bearer_route.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_repeated_separators_hide_the_only_bearer_route.cs new file mode 100644 index 00000000..2dcd745f --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_repeated_separators_hide_the_only_bearer_route.cs @@ -0,0 +1,56 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +public class when_repeated_separators_hide_the_only_bearer_route +{ + [Theory] + [InlineData("/api//mcp/tools")] + [InlineData("/api///mcp/tools")] + public async Task should_not_forward_a_browser_session_to_a_normalizing_backend(string path) + { + await using var harness = new SingleRouteHarness(); + using var client = harness.CreateBearerClient(); + + // This origin serves the bearer resource when it collapses the repeated separators. + using var origin = new HttpClient(); + using var direct = await origin.GetAsync($"{harness.Origin.BaseUrl.TrimEnd('/')}{path}"); + Assert.Equal(HttpStatusCode.OK, direct.StatusCode); + Assert.True(harness.Origin.ReceivedAnythingFor("/api/mcp/tools")); + harness.Origin.Clear(); + + using var browserRequest = SecurityHarness.Authenticated(HttpMethod.Get, "/api/items", "browser-user"); + using var browser = await client.SendAsync(browserRequest); + Assert.Equal(HttpStatusCode.OK, browser.StatusCode); + Assert.NotNull(harness.Origin.LastRequestTo("/api/items")); + + using var canonicalRequest = SecurityHarness.Authenticated(HttpMethod.Get, "/api/mcp/tools", "browser-user"); + using var canonical = await client.SendAsync(canonicalRequest); + Assert.Equal(HttpStatusCode.Unauthorized, canonical.StatusCode); + harness.Origin.Clear(); + + using var request = SecurityHarness.Authenticated(HttpMethod.Get, path, "browser-user"); + using var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal("no-store", response.Headers.CacheControl?.ToString()); + Assert.Empty(harness.Origin.Received); + } + + sealed class SingleRouteHarness() : BearerRouteHarness(normalizeRepeatedSeparators: true) + { + protected override void AddSettings(IDictionary settings) + { + const string routes = $"{C.AuthProxy.SectionKey}:Services:app:BearerRoutes"; + foreach (var key in settings.Keys.Where(_ => _.StartsWith($"{routes}:1:", StringComparison.Ordinal) + || _.StartsWith($"{routes}:2:", StringComparison.Ordinal)).ToArray()) + { + settings.Remove(key); + } + + settings[$"{routes}:0:PathPrefix"] = "/api/mcp"; + settings[$"{routes}:0:RequiredClaims:0:Claim"] = "membership"; + settings[$"{routes}:0:RequiredClaims:0:AnyOf:0"] = "allowed"; + } + } +} diff --git a/Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteForwarder/when_activity_timeouts_are_configured.cs b/Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteForwarder/when_activity_timeouts_are_configured.cs new file mode 100644 index 00000000..7e8a3d7b --- /dev/null +++ b/Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteForwarder/when_activity_timeouts_are_configured.cs @@ -0,0 +1,94 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Yarp.ReverseProxy.Forwarder; +using Yarp.ReverseProxy.Transforms.Builder; + +namespace Cratis.AuthProxy.BearerRoutes.for_BearerRouteForwarder; + +public class when_activity_timeouts_are_configured +{ + [Theory] + [InlineData(null, null, null, 300)] + [InlineData(900, null, null, 900)] + [InlineData(900, 600, null, 600)] + [InlineData(900, 600, 45, 45)] + public async Task should_apply_backend_then_service_then_root_then_default_precedence(int? root, int? service, int? backend, int expected) + { + var timeouts = await CaptureTimeouts(Configuration(root, service, backend)); + timeouts.Single().ShouldEqual(TimeSpan.FromSeconds(expected)); + } + + [Theory] + [InlineData(900, null, null, 1200, null, null, 1200)] + [InlineData(900, 600, null, 900, 120, null, 120)] + [InlineData(900, 600, 45, 900, 600, 1800, 1800)] + [InlineData(900, 600, 45, 900, 600, null, 600)] + public async Task should_use_reloaded_configuration_for_the_next_request(int? root, int? service, int? backend, int? nextRoot, int? nextService, int? nextBackend, int expected) + { + var timeouts = await CaptureTimeouts(Configuration(root, service, backend), Configuration(nextRoot, nextService, nextBackend)); + timeouts[^1].ShouldEqual(TimeSpan.FromSeconds(expected)); + } + + static async Task> CaptureTimeouts(C.AuthProxy initial, C.AuthProxy? reloaded = null) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddReverseProxy(); + await using var provider = services.BuildServiceProvider(); + var monitor = Substitute.For>(); + monitor.CurrentValue.Returns(initial); + var transport = Substitute.For(); + var timeouts = new List(); + transport.SendAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()).Returns(call => + { + timeouts.Add(call.Arg().ActivityTimeout); + return ValueTask.FromResult(ForwarderError.None); + }); + + using var forwarder = new BearerRouteForwarder( + transport, + provider.GetRequiredService(), + provider.GetRequiredService(), + monitor, + NullLogger.Instance); + var route = BearerRouteTable.All(initial).Single(); + await forwarder.Forward(new DefaultHttpContext(), route, identity: null); + if (reloaded is not null) + { + monitor.CurrentValue.Returns(reloaded); + await forwarder.Forward(new DefaultHttpContext(), BearerRouteTable.All(reloaded).Single(), identity: null); + } + + return timeouts; + } + + static C.AuthProxy Configuration(int? root, int? service, int? backend) => new() + { + ActivityTimeout = Seconds(root), + Services = new Dictionary + { + ["app"] = new() + { + ActivityTimeout = Seconds(service), + Backend = new C.ServiceEndpoint { BaseUrl = "https://backend.example.test/", ActivityTimeout = Seconds(backend) }, + BearerRoutes = [new C.BearerRoute + { + PathPrefix = "/mcp", + Issuers = [new C.BearerIssuer { Issuer = "https://issuer.example.test/" }], + Audiences = ["api"], + AcceptWithoutIdentityVerification = true, + }], + }, + }, + }; + + static TimeSpan? Seconds(int? value) => value is { } seconds ? TimeSpan.FromSeconds(seconds) : null; +} diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteForwarder.cs b/Source/AuthProxy/BearerRoutes/BearerRouteForwarder.cs index f2206cea..448fb8f8 100644 --- a/Source/AuthProxy/BearerRoutes/BearerRouteForwarder.cs +++ b/Source/AuthProxy/BearerRoutes/BearerRouteForwarder.cs @@ -1,9 +1,11 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using Microsoft.Extensions.Options; using Yarp.ReverseProxy.Configuration; using Yarp.ReverseProxy.Forwarder; using Yarp.ReverseProxy.Transforms.Builder; +using C = Cratis.AuthProxy.Configuration; namespace Cratis.AuthProxy.BearerRoutes; @@ -20,15 +22,15 @@ namespace Cratis.AuthProxy.BearerRoutes; /// The YARP forwarder. /// The YARP client factory, which applies the reverse proxy's own client configuration. /// The YARP transform builder. +/// The current configuration, including the backend's activity timeout. /// The logger. public sealed class BearerRouteForwarder( IHttpForwarder forwarder, IForwarderHttpClientFactory clientFactory, ITransformBuilder transformBuilder, + IOptionsMonitor config, ILogger logger) : IBearerRouteForwarder, IDisposable { - static readonly ForwarderRequestConfig _requestConfig = new() { ActivityTimeout = TimeSpan.FromMinutes(5) }; - readonly HttpTransformer _transformer = transformBuilder.Create(context => context.RequestTransforms.Add(new BearerRouteHeadersTransform())); readonly HttpMessageInvoker _invoker = clientFactory.CreateClient(new ForwarderHttpClientContext @@ -53,7 +55,16 @@ public async Task Forward(HttpContext context, ResolvedBearerRoute route, Bearer context.Items[BearerRouteDefaults.ForwardedIdentityItemKey] = identity; } - var error = await forwarder.SendAsync(context, route.BackendBaseUrl, _invoker, _requestConfig, _transformer); + var current = config.CurrentValue; + current.Services.TryGetValue(route.ServiceName, out var service); + var requestConfig = new ForwarderRequestConfig + { + ActivityTimeout = service?.Backend?.ActivityTimeout + ?? service?.ActivityTimeout + ?? current.ActivityTimeout + ?? C.AuthProxy.DefaultActivityTimeout, + }; + var error = await forwarder.SendAsync(context, route.BackendBaseUrl, _invoker, requestConfig, _transformer); if (error != ForwarderError.None) { logger.BearerRouteForwardingFailed( diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteMiddleware.cs b/Source/AuthProxy/BearerRoutes/BearerRouteMiddleware.cs index 4df53d8c..a0fb5061 100644 --- a/Source/AuthProxy/BearerRoutes/BearerRouteMiddleware.cs +++ b/Source/AuthProxy/BearerRoutes/BearerRouteMiddleware.cs @@ -60,6 +60,15 @@ public async Task InvokeAsync(HttpContext context) return; } + // Reject aliases before selecting a route: a backend may normalize a path that would otherwise + // miss every bearer prefix and fall through to browser-session authentication. + if (!BearerRouteTable.IsUnambiguous(context.Request.Path)) + { + logger.BearerRoutePathAmbiguous(); + BearerChallenge.BadRequest(context); + return; + } + if (BearerRouteTable.TryMatchResourceMetadata(context.Request.Path, current, out var metadataRoute)) { await ServeResourceMetadata(context, metadataRoute); @@ -68,13 +77,6 @@ public async Task InvokeAsync(HttpContext context) if (BearerRouteTable.TryMatch(context.Request.Path, current, out var route)) { - if (!BearerRouteTable.IsUnambiguous(context.Request.Path)) - { - logger.BearerRoutePathAmbiguous(route.Prefix, route.ServiceName); - BearerChallenge.BadRequest(context); - return; - } - await Authenticate(context, route, current); return; } diff --git a/Source/AuthProxy/BearerRoutes/BearerRoutesLogging.cs b/Source/AuthProxy/BearerRoutes/BearerRoutesLogging.cs index 4e4c5ddb..48f4e0f9 100644 --- a/Source/AuthProxy/BearerRoutes/BearerRoutesLogging.cs +++ b/Source/AuthProxy/BearerRoutes/BearerRoutesLogging.cs @@ -17,8 +17,8 @@ internal static partial class BearerRoutesLogging [LoggerMessage(LogLevel.Information, "A token from bearer-route issuer '{Issuer}' was presented on {Path}, which is not one of its bearer routes. Refused.")] internal static partial void BearerTokenOutsideItsRoutes(this ILogger logger, string issuer, string path); - [LoggerMessage(LogLevel.Information, "A request on bearer route '{Route}' of service '{Service}' has an encoded character, a backslash, a semicolon or a dot segment in its path. Refused.")] - internal static partial void BearerRoutePathAmbiguous(this ILogger logger, string route, string service); + [LoggerMessage(LogLevel.Information, "A request has an encoded character, a backslash, a semicolon, repeated separators or a dot segment in its path. Refused before route selection because bearer routes are configured.")] + internal static partial void BearerRoutePathAmbiguous(this ILogger logger); [LoggerMessage(LogLevel.Information, "A valid bearer token on route '{Route}' of service '{Service}' does not satisfy the required claim '{Claim}'. Refused.")] internal static partial void BearerAccessDenied(this ILogger logger, string route, string service, string claim); From 68d8b36a7fc4a7f500be39ec600a671963c460b3 Mon Sep 17 00:00:00 2001 From: woksin Date: Fri, 2 Oct 2026 03:03:00 +0200 Subject: [PATCH 23/24] Close bearer audience, tenant and prefix boundaries --- Documentation/configuration/services.md | 10 ++- ...bearer_route_has_a_browser_prefix_alias.cs | 67 +++++++++++++++++++ ...en_audience_differs_by_a_trailing_slash.cs | 41 ++++++++++++ .../when_the_token_tenant_is_a_dot_segment.cs | 46 +++++++++++++ .../BearerRouteConfigurationValidator.cs | 22 ++++++ .../BearerRoutes/BearerTokenValidator.cs | 5 +- 6 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_bearer_route_has_a_browser_prefix_alias.cs create mode 100644 Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_token_audience_differs_by_a_trailing_slash.cs create mode 100644 Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_tenant_is_a_dot_segment.cs diff --git a/Documentation/configuration/services.md b/Documentation/configuration/services.md index b2f1b7da..4338b14a 100644 --- a/Documentation/configuration/services.md +++ b/Documentation/configuration/services.md @@ -696,7 +696,7 @@ every token-authenticated request instead. |----------|------|---------|-------------| | `PathPrefix` | `string` | — | The prefix this route covers, for example `/mcp`. Matched case-insensitively on segment boundaries, with the same rules as [anonymous paths](#what-a-valid-entry-looks-like). The longest matching prefix wins. | | `Issuers` | `BearerIssuerConfig[]` | — | The authorization servers whose tokens are accepted. At least one. | -| `Audiences` | `string[]` | — | The token's `aud` must name at least one of these. At least one. | +| `Audiences` | `string[]` | — | The token's `aud` must exactly match at least one of these, including any trailing slash. At least one. | | `RequiredScopes` | `string[]` | `[]` | Scopes the token must carry, every one of them. | | `ResourceMetadataUrl` | `string` | `null` | Absolute URL of the RFC 9728 protected-resource metadata document. Named in every challenge; its path is forwarded to the backend without authentication and must be outside every bearer-route prefix. | | `TenantClaimType` | `string` | `tid` | The token claim the tenant is read from. See [Tenancy](tenancy.md#bearer-routes). | @@ -735,6 +735,14 @@ every token-authenticated request instead. Setting it is the operator's statement that, on this route, the validated token, its scopes and the claim requirements are the whole decision at the edge and the backend answers for the rest. +- Bearer prefixes are full external paths claimed on **every host**, not relative to the owning service's + `PathPrefix` or restricted by its `Hosts`. They may lie under their own service's `PathPrefix`, but cannot + overlap another service's `PathPrefix`, even on different hosts. AuthProxy refuses such overlaps at startup. +- A service with bearer routes cannot set `StripPathPrefix: true`: a browser request such as + `/app/api/mcp/tools` could otherwise be stripped to `/api/mcp/tools` and bypass the bearer policy there. + AuthProxy refuses this configuration at startup. Set `StripPathPrefix: false` and have the backend serve + the full external paths, or remove the bearer routes from that service; do not expose the same bearer-only + backend resource through another service that strips a prefix. - An accepted request is forwarded straight to the service **backend**, whichever of the service's endpoints would otherwise serve that path, with its path and query unchanged. AuthProxy adds no `Service-ID` header; one the caller sent is passed through like any other request header that is not an identity header. The backend's diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_bearer_route_has_a_browser_prefix_alias.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_bearer_route_has_a_browser_prefix_alias.cs new file mode 100644 index 00000000..74d9a7fc --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_bearer_route_has_a_browser_prefix_alias.cs @@ -0,0 +1,67 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Extensions.Options; + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// A stripped browser prefix must not create cookie-authenticated access to a bearer-only backend resource. +/// Bearer prefixes also cannot shadow another service's browser prefix, regardless of host restrictions. +/// +public class when_a_bearer_route_has_a_browser_prefix_alias +{ + [Fact] + public void should_refuse_a_service_whose_browser_prefix_would_be_stripped() + { + using var harness = new StrippedPrefixHarness(); + var error = Assert.Throws(() => harness.CreateBearerClient().Dispose()); + + Assert.Contains(nameof(C.Service.StripPathPrefix), error.Message, StringComparison.Ordinal); + Assert.Empty(harness.Origin.Received); + } + + [Theory] + [InlineData("/MCP", "/mcp")] + [InlineData("/mcp/tools", "/mcp")] + [InlineData(" /mcp/ ", "/mcp")] + [InlineData("/app", "/app/mcp")] + public void should_refuse_overlap_with_another_services_prefix(string otherPrefix, string bearerPrefix) + { + using var harness = new OverlappingPrefixHarness(otherPrefix, bearerPrefix); + var error = Assert.Throws(() => harness.CreateBearerClient().Dispose()); + + Assert.Contains("overlaps service 'other' PathPrefix", error.Message, StringComparison.Ordinal); + Assert.Empty(harness.Origin.Received); + } + + [Fact] + public void should_allow_distinct_segments() + { + using var harness = new OverlappingPrefixHarness("/mcpx", BearerRouteHarness.RoutePrefix); + using var client = harness.CreateBearerClient(); + } + + sealed class StrippedPrefixHarness : BearerRouteHarness + { + protected override void AddSettings(IDictionary settings) + { + settings[$"{C.AuthProxy.SectionKey}:Services:app:PathPrefix"] = "/app"; + settings[$"{C.AuthProxy.SectionKey}:Services:app:StripPathPrefix"] = "true"; + settings[$"{C.AuthProxy.SectionKey}:Services:app:BearerRoutes:0:PathPrefix"] = "/api/mcp"; + } + } + + sealed class OverlappingPrefixHarness(string otherPrefix, string bearerPrefix) : BearerRouteHarness + { + protected override void AddSettings(IDictionary settings) + { + settings[$"{C.AuthProxy.SectionKey}:Services:app:Hosts:0"] = "app.example.test"; + settings[$"{C.AuthProxy.SectionKey}:Services:app:BearerRoutes:0:PathPrefix"] = bearerPrefix; + settings[$"{C.AuthProxy.SectionKey}:Services:other:Hosts:0"] = "other.example.test"; + settings[$"{C.AuthProxy.SectionKey}:Services:other:PathPrefix"] = otherPrefix; + settings[$"{C.AuthProxy.SectionKey}:Services:other:Backend:BaseUrl"] = Origin.BaseUrl; + settings[$"{C.AuthProxy.SectionKey}:Services:other:ResolveIdentityDetails"] = "false"; + } + } +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_token_audience_differs_by_a_trailing_slash.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_token_audience_differs_by_a_trailing_slash.cs new file mode 100644 index 00000000..86489fac --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_token_audience_differs_by_a_trailing_slash.cs @@ -0,0 +1,41 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// Audience identifiers are exact: adding or removing a trailing slash names a different resource. +/// +public class when_a_token_audience_differs_by_a_trailing_slash +{ + [Theory] + [InlineData("direct-api", "direct-api/", false, HttpStatusCode.Unauthorized)] + [InlineData("direct-api/", "direct-api", false, HttpStatusCode.Unauthorized)] + [InlineData("direct-api", "direct-api", false, HttpStatusCode.OK)] + [InlineData("direct-api/", "direct-api/", false, HttpStatusCode.OK)] + [InlineData("direct-api", "direct-api/", true, HttpStatusCode.OK)] + [InlineData("direct-api/", "direct-api", true, HttpStatusCode.OK)] + public async Task should_accept_only_an_explicitly_configured_audience(string configuredAudience, string tokenAudience, bool includeTokenAudience, HttpStatusCode expected) + { + const string path = "/mcp/exact-audience"; + await using var harness = new ExactAudienceHarness(configuredAudience, includeTokenAudience ? tokenAudience : null); + using var client = harness.CreateBearerClient(); + using var request = BearerRouteHarness.WithToken(HttpMethod.Get, path, harness.Issuer.Token(audience: tokenAudience)); + using var response = await client.SendAsync(request); + + Assert.Equal(expected, response.StatusCode); + Assert.Equal(expected == HttpStatusCode.OK, harness.Origin.ReceivedAnythingFor(path)); + } + + sealed class ExactAudienceHarness(string audience, string? additionalAudience) : BearerRouteHarness + { + protected override void AddSettings(IDictionary settings) + { + settings[$"{C.AuthProxy.SectionKey}:Services:app:BearerRoutes:0:Audiences:0"] = audience; + if (additionalAudience is not null) + { + settings[$"{C.AuthProxy.SectionKey}:Services:app:BearerRoutes:0:Audiences:1"] = additionalAudience; + } + } + } +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_tenant_is_a_dot_segment.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_tenant_is_a_dot_segment.cs new file mode 100644 index 00000000..5ef9c2d7 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_tenant_is_a_dot_segment.cs @@ -0,0 +1,46 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// Dot-segment tenant identifiers must not be normalized to an unrelated successful verification endpoint. +/// +public class when_the_token_tenant_is_a_dot_segment +{ + [Theory] + [InlineData(".")] + [InlineData("..")] + public async Task should_refuse_without_verification_or_forwarding(string tenantId) + { + const string path = "/mcp/dot-tenant"; + await using var harness = new VerifyingTenantHarness(); + using var client = harness.CreateBearerClient(); + using var request = BearerRouteHarness.WithToken(HttpMethod.Get, path, harness.Issuer.Token(new Dictionary { ["tid"] = tenantId })); + using var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + Assert.Empty(harness.Origin.Received); + } + + [Fact] + public async Task should_still_verify_and_forward_a_tenant_containing_a_dot() + { + const string tenantId = "tenant.one"; + const string path = "/mcp/valid-dot-tenant"; + await using var harness = new VerifyingTenantHarness(); + using var client = harness.CreateBearerClient(); + using var request = BearerRouteHarness.WithToken(HttpMethod.Get, path, harness.Issuer.Token(new Dictionary { ["tid"] = tenantId })); + using var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.True(harness.Origin.ReceivedAnythingFor($"/api/tenants/{tenantId}")); + Assert.True(harness.Origin.ReceivedAnythingFor(path)); + } + + sealed class VerifyingTenantHarness : BearerRouteHarness + { + protected override void AddSettings(IDictionary settings) => + settings[$"{C.AuthProxy.SectionKey}:TenantVerification:UrlTemplate"] = $"{Origin.BaseUrl}/api/tenants/{{tenantId}}"; + } +} diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteConfigurationValidator.cs b/Source/AuthProxy/BearerRoutes/BearerRouteConfigurationValidator.cs index a41d7c6e..756a9411 100644 --- a/Source/AuthProxy/BearerRoutes/BearerRouteConfigurationValidator.cs +++ b/Source/AuthProxy/BearerRoutes/BearerRouteConfigurationValidator.cs @@ -41,6 +41,28 @@ public ValidateOptionsResult Validate(string? name, C.AuthProxy options) var at = $"{C.AuthProxy.SectionKey}:{nameof(C.AuthProxy.Services)}:{serviceName}:{nameof(C.Service.BearerRoutes)}:{index}"; ValidateRoute(at, serviceName, service, route, prefixes, metadataPaths, anonymousPaths, failures); + if (service.StripPathPrefix) + { + failures.Add( + $"{at}: bearer routes cannot be used with {nameof(C.Service.StripPathPrefix)}. " + + "A browser-session path with the service prefix removed could reach the same backend resource without the bearer policy. " + + $"Set {nameof(C.Service.StripPathPrefix)} to false and serve the full external path at the backend, or remove the bearer routes."); + } + + if (AnonymousPathPolicy.Evaluate(route.PathPrefix, out var prefix) == AnonymousPathRejection.None) + { + foreach (var (otherName, otherService) in options.Services.Where(_ => !string.Equals(_.Key, serviceName, StringComparison.OrdinalIgnoreCase))) + { + if (AnonymousPathPolicy.Evaluate(otherService.PathPrefix, out var otherPrefix) == AnonymousPathRejection.None + && Overlaps(prefix, otherPrefix)) + { + failures.Add( + $"{at}:{nameof(C.BearerRoute.PathPrefix)} '{prefix}' overlaps service '{otherName}' {nameof(C.Service.PathPrefix)} '{otherPrefix}'. " + + "Bearer routes claim their paths on every host; use non-overlapping prefixes."); + } + } + } + if (options.RequiresIdentityVerification && !route.AcceptWithoutIdentityVerification) { failures.Add( diff --git a/Source/AuthProxy/BearerRoutes/BearerTokenValidator.cs b/Source/AuthProxy/BearerRoutes/BearerTokenValidator.cs index 5b14c5d3..e85ebca0 100644 --- a/Source/AuthProxy/BearerRoutes/BearerTokenValidator.cs +++ b/Source/AuthProxy/BearerRoutes/BearerTokenValidator.cs @@ -212,6 +212,7 @@ public async Task Validate(HttpRequest request, ResolvedB ValidIssuer = issuer.Issuer, ValidateAudience = true, RequireAudience = true, + IgnoreTrailingSlashWhenValidatingAudience = false, ValidAudiences = route.Route.Audiences.Where(_ => !string.IsNullOrWhiteSpace(_)).Select(_ => _.Trim()).ToArray(), ValidateLifetime = true, RequireExpirationTime = true, @@ -301,7 +302,9 @@ static bool TryGetSingleValue(IEnumerable claims, string claimType, out s } static bool IsUsableTenantId(string tenantId) => - tenantId.Length <= MaximumTenantIdLength && tenantId.AsSpan().IndexOfAnyExcept(_tenantCharacters) < 0; + tenantId is not "." and not ".." + && tenantId.Length <= MaximumTenantIdLength + && tenantId.AsSpan().IndexOfAnyExcept(_tenantCharacters) < 0; static bool IsSingleValueIdentityClaim(string type) => string.Equals(type, SubjectClaimType, StringComparison.OrdinalIgnoreCase) From a8c504d6397a6e24d4099d7d84ad4af71df2acb3 Mon Sep 17 00:00:00 2001 From: woksin Date: Fri, 2 Oct 2026 03:59:22 +0200 Subject: [PATCH 24/24] Use a routable endpoint for dotted tenant verification specs --- .../when_the_token_tenant_is_a_dot_segment.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_tenant_is_a_dot_segment.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_tenant_is_a_dot_segment.cs index 5ef9c2d7..2a9742bf 100644 --- a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_tenant_is_a_dot_segment.cs +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_tenant_is_a_dot_segment.cs @@ -34,13 +34,13 @@ public async Task should_still_verify_and_forward_a_tenant_containing_a_dot() using var response = await client.SendAsync(request); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.True(harness.Origin.ReceivedAnythingFor($"/api/tenants/{tenantId}")); + Assert.True(harness.Origin.ReceivedAnythingFor($"/api/tenants/{tenantId}/exists")); Assert.True(harness.Origin.ReceivedAnythingFor(path)); } sealed class VerifyingTenantHarness : BearerRouteHarness { protected override void AddSettings(IDictionary settings) => - settings[$"{C.AuthProxy.SectionKey}:TenantVerification:UrlTemplate"] = $"{Origin.BaseUrl}/api/tenants/{{tenantId}}"; + settings[$"{C.AuthProxy.SectionKey}:TenantVerification:UrlTemplate"] = $"{Origin.BaseUrl}/api/tenants/{{tenantId}}/exists"; } }