From b6258716d566667732e653680bb230d98a4888d9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:49:46 +0000 Subject: [PATCH 1/5] Initial plan From 513d0c5dca7ee18f74fbab742142ee2622beca31 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:06:31 +0000 Subject: [PATCH 2/5] Add back-channel client credentials support --- Documentation/configuration/authentication.md | 46 ++++- Documentation/configuration/services.md | 30 +++- .../when_token_is_used_for_another_service.cs | 48 ++++++ ...en_matches_the_target_service_and_route.cs | 45 +++++ .../AuthProxyFactory.cs | 78 +++++++++ .../ClientCredentialsScenarioCollection.cs | 10 ++ .../RejectedAuthProxyFactory.cs | 11 ++ .../and_credentials_are_rejected.cs | 36 ++++ .../and_valid_credentials_are_presented.cs | 47 ++++++ ...thenticationServiceCollectionExtensions.cs | 47 +++++- ...tCredentialsBearerAuthenticationHandler.cs | 82 +++++++++ .../ClientCredentialsDefaults.cs | 35 ++++ .../ClientCredentialsGrantResult.cs | 76 +++++++++ .../ClientCredentialsGrantService.cs | 78 +++++++++ .../ClientCredentialsServiceResolver.cs | 157 ++++++++++++++++++ .../ClientCredentialsTokenPayload.cs | 15 ++ .../ClientCredentialsTokenProtector.cs | 69 ++++++++ .../ClientCredentialsVerificationRequest.cs | 17 ++ .../ClientCredentialsVerificationResult.cs | 15 ++ .../ClientCredentialsVerificationStatus.cs | 25 +++ .../ClientCredentialsVerifier.cs | 56 +++++++ .../ConfiguredClientCredentialsService.cs | 15 ++ Source/AuthProxy/Configuration/Service.cs | 6 + .../Configuration/ServiceClientCredentials.cs | 26 +++ Source/AuthProxy/HttpContextExtensions.cs | 9 +- Source/AuthProxy/IngressExtensions.cs | 32 ++++ Source/AuthProxy/WellKnownPaths.cs | 5 + 27 files changed, 1107 insertions(+), 9 deletions(-) create mode 100644 Source/AuthProxy.Specs/Authentication/for_ClientCredentialsBearerAuthenticationHandler/when_token_is_used_for_another_service.cs create mode 100644 Source/AuthProxy.Specs/Authentication/for_ClientCredentialsBearerAuthenticationHandler/when_token_matches_the_target_service_and_route.cs create mode 100644 Source/AuthProxy.Specs/Scenarios/when_client_credentials_token_is_requested/AuthProxyFactory.cs create mode 100644 Source/AuthProxy.Specs/Scenarios/when_client_credentials_token_is_requested/ClientCredentialsScenarioCollection.cs create mode 100644 Source/AuthProxy.Specs/Scenarios/when_client_credentials_token_is_requested/RejectedAuthProxyFactory.cs create mode 100644 Source/AuthProxy.Specs/Scenarios/when_client_credentials_token_is_requested/and_credentials_are_rejected.cs create mode 100644 Source/AuthProxy.Specs/Scenarios/when_client_credentials_token_is_requested/and_valid_credentials_are_presented.cs create mode 100644 Source/AuthProxy/Authentication/ClientCredentialsBearerAuthenticationHandler.cs create mode 100644 Source/AuthProxy/Authentication/ClientCredentialsDefaults.cs create mode 100644 Source/AuthProxy/Authentication/ClientCredentialsGrantResult.cs create mode 100644 Source/AuthProxy/Authentication/ClientCredentialsGrantService.cs create mode 100644 Source/AuthProxy/Authentication/ClientCredentialsServiceResolver.cs create mode 100644 Source/AuthProxy/Authentication/ClientCredentialsTokenPayload.cs create mode 100644 Source/AuthProxy/Authentication/ClientCredentialsTokenProtector.cs create mode 100644 Source/AuthProxy/Authentication/ClientCredentialsVerificationRequest.cs create mode 100644 Source/AuthProxy/Authentication/ClientCredentialsVerificationResult.cs create mode 100644 Source/AuthProxy/Authentication/ClientCredentialsVerificationStatus.cs create mode 100644 Source/AuthProxy/Authentication/ClientCredentialsVerifier.cs create mode 100644 Source/AuthProxy/Authentication/ConfiguredClientCredentialsService.cs create mode 100644 Source/AuthProxy/Configuration/ServiceClientCredentials.cs diff --git a/Documentation/configuration/authentication.md b/Documentation/configuration/authentication.md index b330914..a9f8a59 100644 --- a/Documentation/configuration/authentication.md +++ b/Documentation/configuration/authentication.md @@ -1,9 +1,10 @@ # Authentication -AuthProxy supports two authentication modes that can be active simultaneously: +AuthProxy supports three authentication modes that can be active simultaneously: - **Interactive browser sessions** – OpenID Connect (OIDC) with a cookie. -- **Machine-to-machine / API** – JWT Bearer tokens. +- **Machine-to-machine / API** – JWT Bearer tokens from an external identity provider. +- **Back-channel client credentials** – service-owned client credentials verified by the target service itself. --- @@ -122,3 +123,44 @@ For machine-to-machine calls, configure a JWT Bearer handler: } ``` +--- + +## Back-channel client credentials + +AuthProxy can also issue bearer tokens itself after a proxied service verifies the supplied +client credentials over a private back channel. + +1. The client sends `POST /.cratis/token` +2. The request body uses standard OAuth form fields: + - `grant_type=client_credentials` + - `service=` (optional when only one service has client credentials configured) + - `client_id=` + - `client_secret=` +3. AuthProxy calls the configured downstream verification endpoint with a JSON payload: + +```json +{ + "service": "portal", + "routePrefix": "/api", + "clientId": "orders-api", + "clientSecret": "" +} +``` + +4. Any `2xx` response mints a bearer token scoped to that service and route prefix +5. Any `4xx` response rejects the credentials +6. Any `5xx` response is treated as a downstream verification failure + +Successful responses from `/.cratis/token` look like this: + +```json +{ + "access_token": "", + "token_type": "Bearer", + "expires_in": 3600 +} +``` + +The issued bearer token can then be used on the configured route prefix (for example `/api/**`). +AuthProxy validates that the token is used against the same configured service and route before +forwarding the request. diff --git a/Documentation/configuration/services.md b/Documentation/configuration/services.md index 9ac3fed..48c9455 100644 --- a/Documentation/configuration/services.md +++ b/Documentation/configuration/services.md @@ -11,12 +11,16 @@ Services are configured under `Cratis:AuthProxy:Services`, keyed by a friendly n ```json { - "Cratis": { { + "Cratis": { "Services": { "portal": { "Backend": { "BaseUrl": "http://portal-api:8080/" }, "Frontend": { "BaseUrl": "http://portal-web:3000/" }, - "ResolveIdentityDetails": true + "ResolveIdentityDetails": true, + "ClientCredentials": { + "RoutePrefix": "/api", + "VerificationPath": "/.cratis/client-credentials/verify" + } }, "catalog": { "Backend": { "BaseUrl": "http://catalog-api:8080/" } @@ -33,6 +37,7 @@ Services are configured under `Cratis:AuthProxy:Services`, keyed by a friendly n | `Backend` | `ServiceEndpointConfig` | `null` | API backend endpoint. | | `Frontend` | `ServiceEndpointConfig` | `null` | SPA / static-asset frontend endpoint. | | `ResolveIdentityDetails` | `bool?` | `true` when Backend is set | Whether to call `/.cratis/me` on this service to enrich the identity cookie. | +| `ClientCredentials` | `ServiceClientCredentialsConfig` | `null` | Enables back-channel client-credentials verification and token minting for this service. | ### ServiceEndpointConfig properties @@ -40,6 +45,13 @@ Services are configured under `Cratis:AuthProxy:Services`, keyed by a friendly n |----------|------|-------------| | `BaseUrl` | `string` | Base URL of the endpoint (e.g. `http://my-service:8080/`). | +### ServiceClientCredentialsConfig properties + +| Property | Type | Description | +|----------|------|-------------| +| `RoutePrefix` | `string` | Route prefix that AuthProxy-issued bearer tokens are allowed to access (for example `/api`). | +| `VerificationPath` | `string` | Internal verification endpoint. Relative values are resolved against `Backend.BaseUrl`; absolute values are used as-is. | + --- ## Routing @@ -72,3 +84,17 @@ For each service with a `Backend` endpoint (and `ResolveIdentityDetails` not exp The response is stored in a short-lived HTTP-only cookie (`.cratis-identity`) and injected as the `X-MS-CLIENT-PRINCIPAL` header on every proxied request so that backend services can read identity details without re-calling the identity endpoint themselves. + +--- + +## Client credentials + +When `ClientCredentials` is configured for a service, AuthProxy exposes `POST /.cratis/token`. +That endpoint forwards the supplied client credentials to the service's verification endpoint and, +on success, issues a bearer token scoped to the configured `RoutePrefix`. + +This creates a one-to-one relationship between: + +- the proxied service +- the route prefix the token may access +- the downstream endpoint that verifies the client credentials diff --git a/Source/AuthProxy.Specs/Authentication/for_ClientCredentialsBearerAuthenticationHandler/when_token_is_used_for_another_service.cs b/Source/AuthProxy.Specs/Authentication/for_ClientCredentialsBearerAuthenticationHandler/when_token_is_used_for_another_service.cs new file mode 100644 index 0000000..b2fc909 --- /dev/null +++ b/Source/AuthProxy.Specs/Authentication/for_ClientCredentialsBearerAuthenticationHandler/when_token_is_used_for_another_service.cs @@ -0,0 +1,48 @@ +// 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.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Cratis.AuthProxy.Authentication.for_ClientCredentialsBearerAuthenticationHandler; + +public class when_token_is_used_for_another_service : Specification +{ + AuthenticateResult _result; + + async Task Establish() + { + var builder = WebApplication.CreateBuilder(); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + [$"{C.AuthProxy.SectionKey}:Services:portal:Backend:BaseUrl"] = "http://portal.test/", + [$"{C.AuthProxy.SectionKey}:Services:portal:ClientCredentials:RoutePrefix"] = "/api", + [$"{C.AuthProxy.SectionKey}:Services:portal:ClientCredentials:VerificationPath"] = "/.cratis/client-credentials/verify", + + [$"{C.AuthProxy.SectionKey}:Services:catalog:Backend:BaseUrl"] = "http://catalog.test/", + [$"{C.AuthProxy.SectionKey}:Services:catalog:ClientCredentials:RoutePrefix"] = "/api", + [$"{C.AuthProxy.SectionKey}:Services:catalog:ClientCredentials:VerificationPath"] = "/.cratis/client-credentials/verify", + }); + + builder.AddIngressConfiguration(); + builder.AddIngressAuthentication(); + + var serviceProvider = builder.Services.BuildServiceProvider(); + var tokenProtector = serviceProvider.GetRequiredService(); + var resolver = serviceProvider.GetRequiredService(); + resolver.TryResolveForTokenRequest("portal", out var service, out _).ShouldBeTrue(); + + var context = new DefaultHttpContext { RequestServices = serviceProvider }; + context.Request.Path = "/api/orders"; + var token = tokenProtector.CreateToken(service, "orders-api"); + context.Request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token).ToString(); + context.Request.Headers[Headers.ServiceId] = "catalog"; + + _result = await context.AuthenticateAsync(ClientCredentialsDefaults.AuthenticationScheme); + } + + [Fact] void should_reject_the_request() => _result.Succeeded.ShouldBeFalse(); +} diff --git a/Source/AuthProxy.Specs/Authentication/for_ClientCredentialsBearerAuthenticationHandler/when_token_matches_the_target_service_and_route.cs b/Source/AuthProxy.Specs/Authentication/for_ClientCredentialsBearerAuthenticationHandler/when_token_matches_the_target_service_and_route.cs new file mode 100644 index 0000000..c482e16 --- /dev/null +++ b/Source/AuthProxy.Specs/Authentication/for_ClientCredentialsBearerAuthenticationHandler/when_token_matches_the_target_service_and_route.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. + +using System.Net.Http.Headers; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Cratis.AuthProxy.Authentication.for_ClientCredentialsBearerAuthenticationHandler; + +public class when_token_matches_the_target_service_and_route : Specification +{ + AuthenticateResult _result; + + async Task Establish() + { + var builder = WebApplication.CreateBuilder(); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + [$"{C.AuthProxy.SectionKey}:Services:portal:Backend:BaseUrl"] = "http://portal.test/", + [$"{C.AuthProxy.SectionKey}:Services:portal:ClientCredentials:RoutePrefix"] = "/api", + [$"{C.AuthProxy.SectionKey}:Services:portal:ClientCredentials:VerificationPath"] = "/.cratis/client-credentials/verify", + }); + + builder.AddIngressConfiguration(); + builder.AddIngressAuthentication(); + + var serviceProvider = builder.Services.BuildServiceProvider(); + var tokenProtector = serviceProvider.GetRequiredService(); + var resolver = serviceProvider.GetRequiredService(); + resolver.TryResolveForTokenRequest("portal", out var service, out _).ShouldBeTrue(); + + var context = new DefaultHttpContext { RequestServices = serviceProvider }; + context.Request.Path = "/api/orders"; + var token = tokenProtector.CreateToken(service, "orders-api"); + context.Request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token).ToString(); + + _result = await context.AuthenticateAsync(ClientCredentialsDefaults.AuthenticationScheme); + } + + [Fact] void should_authenticate_the_request() => _result.Succeeded.ShouldBeTrue(); + [Fact] void should_expose_the_client_id_as_the_subject() => _result.Principal!.FindFirst("sub")!.Value.ShouldEqual("orders-api"); + [Fact] void should_scope_the_principal_to_the_service() => _result.Principal!.FindFirst(ClientCredentialsDefaults.ServiceClaimType)!.Value.ShouldEqual("portal"); +} diff --git a/Source/AuthProxy.Specs/Scenarios/when_client_credentials_token_is_requested/AuthProxyFactory.cs b/Source/AuthProxy.Specs/Scenarios/when_client_credentials_token_is_requested/AuthProxyFactory.cs new file mode 100644 index 0000000..8c74dd1 --- /dev/null +++ b/Source/AuthProxy.Specs/Scenarios/when_client_credentials_token_is_requested/AuthProxyFactory.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 System.Net; +using System.Net.Http.Json; +using Cratis.AuthProxy.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.Scenarios.when_client_credentials_token_is_requested; + +/// +/// Web application factory for exercising the back-channel client-credentials token endpoint. +/// +public class AuthProxyFactory : WebApplicationFactory +{ + public const string TenantId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + + public ClientCredentialsVerificationRequest? CapturedVerificationRequest { get; private set; } + + protected virtual HttpStatusCode VerificationStatusCode => HttpStatusCode.NoContent; + + /// + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureAppConfiguration((_, config) => + { + config.AddInMemoryCollection(new Dictionary + { + [$"{C.AuthProxy.SectionKey}:TenantResolutions:0:Strategy"] = nameof(C.TenantSourceIdentifierResolverType.Specified), + [$"{C.AuthProxy.SectionKey}:TenantResolutions:0:Options:TenantId"] = TenantId, + + [$"{C.AuthProxy.SectionKey}:Services:portal:Backend:BaseUrl"] = "http://portal.test/", + [$"{C.AuthProxy.SectionKey}:Services:portal:ClientCredentials:RoutePrefix"] = "/api", + [$"{C.AuthProxy.SectionKey}:Services:portal:ClientCredentials:VerificationPath"] = "/.cratis/client-credentials/verify", + + [$"{C.Authentication.SectionKey}:OidcProviders:0:Name"] = "Microsoft", + [$"{C.Authentication.SectionKey}:OidcProviders:0:Authority"] = "https://login.example.com/common/v2.0", + [$"{C.Authentication.SectionKey}:OidcProviders:0:ClientId"] = "browser-client", + }); + }); + + builder.ConfigureTestServices(services => + { + services.AddSingleton(new TestHttpClientFactory(async request => + { + if (request.Content is not null) + { + CapturedVerificationRequest = await request.Content.ReadFromJsonAsync(); + } + + return new HttpResponseMessage(VerificationStatusCode); + })); + }); + } + + /// + /// Creates a test HTTP client that does not follow redirects. + /// + /// A configured . + public HttpClient CreateTestClient() => + CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + + sealed class TestHttpClientFactory(Func> handler) : IHttpClientFactory + { + public HttpClient CreateClient(string name) => + new(new DispatchingHandler(handler)) { Timeout = TimeSpan.FromSeconds(10) }; + + sealed class DispatchingHandler(Func> handler) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + handler(request); + } + } +} diff --git a/Source/AuthProxy.Specs/Scenarios/when_client_credentials_token_is_requested/ClientCredentialsScenarioCollection.cs b/Source/AuthProxy.Specs/Scenarios/when_client_credentials_token_is_requested/ClientCredentialsScenarioCollection.cs new file mode 100644 index 0000000..c65344b --- /dev/null +++ b/Source/AuthProxy.Specs/Scenarios/when_client_credentials_token_is_requested/ClientCredentialsScenarioCollection.cs @@ -0,0 +1,10 @@ +// 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.Scenarios.when_client_credentials_token_is_requested; + +[CollectionDefinition(Name, DisableParallelization = true)] +public static class ClientCredentialsScenarioCollection +{ + public const string Name = "client-credentials-scenarios"; +} diff --git a/Source/AuthProxy.Specs/Scenarios/when_client_credentials_token_is_requested/RejectedAuthProxyFactory.cs b/Source/AuthProxy.Specs/Scenarios/when_client_credentials_token_is_requested/RejectedAuthProxyFactory.cs new file mode 100644 index 0000000..0e68407 --- /dev/null +++ b/Source/AuthProxy.Specs/Scenarios/when_client_credentials_token_is_requested/RejectedAuthProxyFactory.cs @@ -0,0 +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 System.Net; + +namespace Cratis.AuthProxy.Scenarios.when_client_credentials_token_is_requested; + +public class RejectedAuthProxyFactory : AuthProxyFactory +{ + protected override HttpStatusCode VerificationStatusCode => HttpStatusCode.Unauthorized; +} diff --git a/Source/AuthProxy.Specs/Scenarios/when_client_credentials_token_is_requested/and_credentials_are_rejected.cs b/Source/AuthProxy.Specs/Scenarios/when_client_credentials_token_is_requested/and_credentials_are_rejected.cs new file mode 100644 index 0000000..492a88d --- /dev/null +++ b/Source/AuthProxy.Specs/Scenarios/when_client_credentials_token_is_requested/and_credentials_are_rejected.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 System.Net; +using System.Text.Json; +using Cratis.AuthProxy.Authentication; + +namespace Cratis.AuthProxy.Scenarios.when_client_credentials_token_is_requested; + +[Collection(ClientCredentialsScenarioCollection.Name)] +public class and_credentials_are_rejected : Specification +{ + HttpResponseMessage _response; + JsonDocument _payload; + + async Task Establish() + { + await using var factory = new RejectedAuthProxyFactory(); + using var client = factory.CreateTestClient(); + + _response = await client.PostAsync( + WellKnownPaths.Token, + new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = ClientCredentialsDefaults.GrantType, + ["service"] = "portal", + ["client_id"] = "orders-api", + ["client_secret"] = "wrong-secret", + })); + + _payload = JsonDocument.Parse(await _response.Content.ReadAsStringAsync()); + } + + [Fact] void should_return_unauthorized() => _response.StatusCode.ShouldEqual(HttpStatusCode.Unauthorized); + [Fact] void should_return_an_invalid_client_error() => _payload.RootElement.GetProperty("error").GetString().ShouldEqual("invalid_client"); +} diff --git a/Source/AuthProxy.Specs/Scenarios/when_client_credentials_token_is_requested/and_valid_credentials_are_presented.cs b/Source/AuthProxy.Specs/Scenarios/when_client_credentials_token_is_requested/and_valid_credentials_are_presented.cs new file mode 100644 index 0000000..6effe31 --- /dev/null +++ b/Source/AuthProxy.Specs/Scenarios/when_client_credentials_token_is_requested/and_valid_credentials_are_presented.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. + +using System.Net; +using System.Text.Json; +using Cratis.AuthProxy.Authentication; + +namespace Cratis.AuthProxy.Scenarios.when_client_credentials_token_is_requested; + +[Collection(ClientCredentialsScenarioCollection.Name)] +public class and_valid_credentials_are_presented : Specification +{ + HttpResponseMessage _response; + JsonDocument _payload; + AuthProxyFactory _factory; + + async Task Establish() + { + _factory = new AuthProxyFactory(); + using var client = _factory.CreateTestClient(); + + _response = await client.PostAsync( + WellKnownPaths.Token, + new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = ClientCredentialsDefaults.GrantType, + ["service"] = "portal", + ["client_id"] = "orders-api", + ["client_secret"] = "super-secret", + })); + + _payload = JsonDocument.Parse(await _response.Content.ReadAsStringAsync()); + } + + [Fact] void should_return_ok() => _response.StatusCode.ShouldEqual(HttpStatusCode.OK); + [Fact] void should_return_a_bearer_token() => _payload.RootElement.GetProperty("token_type").GetString().ShouldEqual("Bearer"); + [Fact] void should_return_an_access_token() => string.IsNullOrWhiteSpace(_payload.RootElement.GetProperty("access_token").GetString()).ShouldBeFalse(); + [Fact] void should_return_the_token_lifetime() => _payload.RootElement.GetProperty("expires_in").GetInt32().ShouldEqual(3600); + [Fact] void should_forward_the_well_known_verification_payload() + { + _factory.CapturedVerificationRequest.ShouldNotBeNull(); + _factory.CapturedVerificationRequest!.Service.ShouldEqual("portal"); + _factory.CapturedVerificationRequest.RoutePrefix.ShouldEqual("/api"); + _factory.CapturedVerificationRequest.ClientId.ShouldEqual("orders-api"); + _factory.CapturedVerificationRequest.ClientSecret.ShouldEqual("super-secret"); + } +} diff --git a/Source/AuthProxy/Authentication/AuthenticationServiceCollectionExtensions.cs b/Source/AuthProxy/Authentication/AuthenticationServiceCollectionExtensions.cs index ab7ffd4..01276e4 100644 --- a/Source/AuthProxy/Authentication/AuthenticationServiceCollectionExtensions.cs +++ b/Source/AuthProxy/Authentication/AuthenticationServiceCollectionExtensions.cs @@ -25,9 +25,24 @@ public static class AuthenticationServiceCollectionExtensions /// The same for chaining. public static WebApplicationBuilder AddIngressAuthentication(this WebApplicationBuilder builder) { + var jwtSection = builder.Configuration.GetSection($"{C.Authentication.SectionKey}:JwtBearer"); + var hasJwtBearer = jwtSection.Exists(); + var authBuilder = builder.Services - .AddAuthentication(options => options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme) - .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, ConfigureCookieOptions); + .AddAuthentication(options => + { + options.DefaultScheme = ClientCredentialsDefaults.CompositeAuthenticationScheme; + options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme; + options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; + }) + .AddPolicyScheme( + ClientCredentialsDefaults.CompositeAuthenticationScheme, + ClientCredentialsDefaults.CompositeAuthenticationScheme, + options => options.ForwardDefaultSelector = context => ResolveAuthenticationScheme(context, hasJwtBearer)) + .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, ConfigureCookieOptions) + .AddScheme( + ClientCredentialsDefaults.AuthenticationScheme, + _ => { }); var authConfig = builder.Configuration .GetSection(C.Authentication.SectionKey) @@ -36,7 +51,11 @@ public static WebApplicationBuilder AddIngressAuthentication(this WebApplication RegisterOidcProviders(authBuilder, authConfig.OidcProviders); RegisterOAuthProviders(authBuilder, authConfig.OAuthProviders); - var jwtSection = builder.Configuration.GetSection($"{C.Authentication.SectionKey}:JwtBearer"); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + if (jwtSection.Exists()) { authBuilder.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, jwtSection.Bind); @@ -50,6 +69,27 @@ public static WebApplicationBuilder AddIngressAuthentication(this WebApplication return builder; } + static string ResolveAuthenticationScheme(HttpContext context, bool hasJwtBearer) + { + var authorization = context.Request.Headers.Authorization.ToString(); + if (authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + var token = authorization["Bearer ".Length..].Trim(); + var tokenProtector = context.RequestServices.GetRequiredService(); + if (tokenProtector.TryValidate(token, out _)) + { + return ClientCredentialsDefaults.AuthenticationScheme; + } + + if (hasJwtBearer) + { + return JwtBearerDefaults.AuthenticationScheme; + } + } + + return CookieAuthenticationDefaults.AuthenticationScheme; + } + static void ConfigureCookieOptions(CookieAuthenticationOptions options) { options.Cookie.HttpOnly = true; @@ -215,4 +255,3 @@ static void RegisterOAuthProviders(AuthenticationBuilder authBuilder, IList +/// Authenticates AuthProxy-issued bearer tokens created through the back-channel client-credentials flow. +/// +/// The authentication scheme options monitor. +/// The logger factory. +/// The URL encoder. +/// Resolves which configured service the current request targets. +/// Validates AuthProxy-issued bearer tokens. +public class ClientCredentialsBearerAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + ClientCredentialsServiceResolver serviceResolver, + ClientCredentialsTokenProtector tokenProtector) + : AuthenticationHandler(options, logger, encoder) +{ + /// + protected override Task HandleAuthenticateAsync() + { + if (!TryGetBearerToken(out var token)) + { + return Task.FromResult(AuthenticateResult.NoResult()); + } + + if (!tokenProtector.TryValidate(token, out var payload)) + { + return Task.FromResult(AuthenticateResult.NoResult()); + } + + if (!serviceResolver.TryResolveForRequest(Request, out var service)) + { + return Task.FromResult(AuthenticateResult.Fail("Unable to resolve the target service for the bearer token.")); + } + + if (!string.Equals(service.Name, payload.Service, StringComparison.OrdinalIgnoreCase) + || !string.Equals(service.RoutePrefix, payload.RoutePrefix, StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(AuthenticateResult.Fail("The bearer token is not valid for the requested service or route.")); + } + + var claims = new List + { + new("sub", payload.ClientId), + new("client_id", payload.ClientId), + new("name", payload.ClientId), + new(ClaimTypes.Name, payload.ClientId), + new(ClientCredentialsDefaults.ServiceClaimType, payload.Service), + new(ClientCredentialsDefaults.RoutePrefixClaimType, payload.RoutePrefix), + new("amr", ClientCredentialsDefaults.GrantType), + }; + + var identity = new ClaimsIdentity(claims, Scheme.Name); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, Scheme.Name); + + return Task.FromResult(AuthenticateResult.Success(ticket)); + } + + bool TryGetBearerToken(out string token) + { + token = string.Empty; + + var authorization = Request.Headers.Authorization.ToString(); + if (!authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + token = authorization["Bearer ".Length..].Trim(); + return !string.IsNullOrWhiteSpace(token); + } +} diff --git a/Source/AuthProxy/Authentication/ClientCredentialsDefaults.cs b/Source/AuthProxy/Authentication/ClientCredentialsDefaults.cs new file mode 100644 index 0000000..5a47053 --- /dev/null +++ b/Source/AuthProxy/Authentication/ClientCredentialsDefaults.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. + +namespace Cratis.AuthProxy.Authentication; + +/// +/// Defines constants used by the back-channel client-credentials flow. +/// +public static class ClientCredentialsDefaults +{ + /// + /// The composite authentication scheme that selects between cookies and bearer tokens. + /// + public const string CompositeAuthenticationScheme = "AuthProxyAuthentication"; + + /// + /// The authentication scheme used for AuthProxy-issued bearer tokens. + /// + public const string AuthenticationScheme = "AuthProxyClientCredentials"; + + /// + /// The OAuth 2.0 grant type supported by the token endpoint. + /// + public const string GrantType = "client_credentials"; + + /// + /// The claim type that stores the target service key. + /// + public const string ServiceClaimType = "cratis/service"; + + /// + /// The claim type that stores the route prefix the token is scoped to. + /// + public const string RoutePrefixClaimType = "cratis/route-prefix"; +} diff --git a/Source/AuthProxy/Authentication/ClientCredentialsGrantResult.cs b/Source/AuthProxy/Authentication/ClientCredentialsGrantResult.cs new file mode 100644 index 0000000..72dd2a3 --- /dev/null +++ b/Source/AuthProxy/Authentication/ClientCredentialsGrantResult.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. + +namespace Cratis.AuthProxy.Authentication; + +/// +/// Represents the outcome of a client-credentials token request. +/// +public record ClientCredentialsGrantResult +{ + ClientCredentialsGrantResult() + { + } + + /// + /// Gets the HTTP status code to return. + /// + public required int StatusCode { get; init; } + + /// + /// Gets the issued access token, when the request succeeded. + /// + public string AccessToken { get; init; } = string.Empty; + + /// + /// Gets the token type to return. + /// + public string TokenType { get; init; } = "Bearer"; + + /// + /// Gets the token lifetime in seconds. + /// + public int ExpiresIn { get; init; } + + /// + /// Gets the OAuth error code, when the request failed. + /// + public string Error { get; init; } = string.Empty; + + /// + /// Gets the OAuth error description, when the request failed. + /// + public string ErrorDescription { get; init; } = string.Empty; + + /// + /// Gets whether the request succeeded. + /// + public bool Succeeded => StatusCode is >= 200 and < 300; + + /// + /// Creates a successful token result. + /// + /// The issued access token. + /// The token lifetime in seconds. + /// A successful token result. + public static ClientCredentialsGrantResult Success(string accessToken, int expiresIn) => new() + { + StatusCode = StatusCodes.Status200OK, + AccessToken = accessToken, + ExpiresIn = expiresIn, + }; + + /// + /// Creates an OAuth error result. + /// + /// The HTTP status code to return. + /// The OAuth error code. + /// The OAuth error description. + /// An error result. + public static ClientCredentialsGrantResult CreateError(int statusCode, string error, string errorDescription) => new() + { + StatusCode = statusCode, + Error = error, + ErrorDescription = errorDescription, + }; +} diff --git a/Source/AuthProxy/Authentication/ClientCredentialsGrantService.cs b/Source/AuthProxy/Authentication/ClientCredentialsGrantService.cs new file mode 100644 index 0000000..d0f3c3f --- /dev/null +++ b/Source/AuthProxy/Authentication/ClientCredentialsGrantService.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. + +namespace Cratis.AuthProxy.Authentication; + +/// +/// Orchestrates the back-channel client-credentials token flow. +/// +/// Resolves which configured service should handle the token request. +/// Verifies the supplied client credentials against the target service. +/// Creates AuthProxy-issued bearer tokens for verified clients. +public class ClientCredentialsGrantService( + ClientCredentialsServiceResolver serviceResolver, + ClientCredentialsVerifier verifier, + ClientCredentialsTokenProtector tokenProtector) +{ + /// + /// Processes a client-credentials token request. + /// + /// The requested OAuth grant type. + /// The requested service name. + /// The provided client identifier. + /// The provided client secret. + /// The cancellation token. + /// The token issuance result. + public async Task GrantAsync( + string? grantType, + string? serviceName, + string? clientId, + string? clientSecret, + CancellationToken cancellationToken) + { + if (!string.Equals(grantType, ClientCredentialsDefaults.GrantType, StringComparison.Ordinal)) + { + return ClientCredentialsGrantResult.CreateError( + StatusCodes.Status400BadRequest, + "unsupported_grant_type", + "Only the client_credentials grant type is supported."); + } + + if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(clientSecret)) + { + return ClientCredentialsGrantResult.CreateError( + StatusCodes.Status400BadRequest, + "invalid_request", + "Both client_id and client_secret are required."); + } + + if (!serviceResolver.TryResolveForTokenRequest(serviceName, out var service, out var errorDescription)) + { + return ClientCredentialsGrantResult.CreateError( + StatusCodes.Status400BadRequest, + "invalid_request", + errorDescription); + } + + var verificationResult = await verifier.VerifyAsync(service, clientId, clientSecret, cancellationToken); + if (verificationResult.Status == ClientCredentialsVerificationStatus.Rejected) + { + return ClientCredentialsGrantResult.CreateError( + StatusCodes.Status401Unauthorized, + "invalid_client", + "The client credentials were rejected by the target service."); + } + + if (verificationResult.Status == ClientCredentialsVerificationStatus.Failed) + { + return ClientCredentialsGrantResult.CreateError( + StatusCodes.Status502BadGateway, + "server_error", + "The target service could not verify the supplied client credentials."); + } + + return ClientCredentialsGrantResult.Success( + tokenProtector.CreateToken(service, clientId), + tokenProtector.ExpiresInSeconds); + } +} diff --git a/Source/AuthProxy/Authentication/ClientCredentialsServiceResolver.cs b/Source/AuthProxy/Authentication/ClientCredentialsServiceResolver.cs new file mode 100644 index 0000000..5605228 --- /dev/null +++ b/Source/AuthProxy/Authentication/ClientCredentialsServiceResolver.cs @@ -0,0 +1,157 @@ +// 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.Authentication; + +/// +/// Resolves which configured service should handle a client-credentials request. +/// +/// Provides the current AuthProxy configuration. +public class ClientCredentialsServiceResolver( + IOptionsMonitor config) +{ + /// + /// Tries to resolve the target service for a token request. + /// + /// The requested service name, if provided by the caller. + /// The resolved service configuration. + /// The OAuth error description to return when resolution fails. + /// if a service was resolved; otherwise . + public bool TryResolveForTokenRequest( + string? serviceName, + out ConfiguredClientCredentialsService service, + out string errorDescription) + { + var configuredServices = GetConfiguredServices().ToArray(); + if (configuredServices.Length == 0) + { + service = default!; + errorDescription = "No services are configured for client credentials."; + return false; + } + + if (!string.IsNullOrWhiteSpace(serviceName)) + { + var namedService = configuredServices.FirstOrDefault(_ => string.Equals(_.Name, serviceName, StringComparison.OrdinalIgnoreCase)); + if (namedService is not null) + { + service = namedService; + errorDescription = string.Empty; + return true; + } + + service = default!; + errorDescription = $"The service '{serviceName}' is not configured for client credentials."; + return false; + } + + if (configuredServices.Length == 1) + { + service = configuredServices[0]; + errorDescription = string.Empty; + return true; + } + + service = default!; + errorDescription = "Multiple services are configured for client credentials. Specify the 'service' form field."; + return false; + } + + /// + /// Tries to resolve the target service for an authenticated bearer request. + /// + /// The incoming HTTP request. + /// The resolved service configuration. + /// if a service was resolved; otherwise . + public bool TryResolveForRequest(HttpRequest request, out ConfiguredClientCredentialsService service) + { + var candidates = GetConfiguredServices() + .Where(_ => request.Path.StartsWithSegments(new PathString(_.RoutePrefix), StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + if (candidates.Length == 0) + { + service = default!; + return false; + } + + var requestedService = request.Headers[Headers.ServiceId].FirstOrDefault(); + if (string.IsNullOrWhiteSpace(requestedService)) + { + requestedService = request.Query["service"].FirstOrDefault(); + } + + if (!string.IsNullOrWhiteSpace(requestedService)) + { + var namedService = candidates.FirstOrDefault(_ => string.Equals(_.Name, requestedService, StringComparison.OrdinalIgnoreCase)); + if (namedService is not null) + { + service = namedService; + return true; + } + + service = default!; + return false; + } + + if (candidates.Length == 1) + { + service = candidates[0]; + return true; + } + + service = default!; + return false; + } + + static Uri? CreateVerificationUri(string baseUrl, string verificationPath) + { + if (Uri.TryCreate(verificationPath, UriKind.Absolute, out var absoluteUri)) + { + return absoluteUri; + } + + return Uri.TryCreate(new Uri(baseUrl), verificationPath, out var relativeUri) + ? relativeUri + : null; + } + + static string NormalizeRoutePrefix(string routePrefix) + { + var normalized = string.IsNullOrWhiteSpace(routePrefix) + ? "/api" + : routePrefix.Trim(); + + if (!normalized.StartsWith('/')) + { + normalized = $"/{normalized}"; + } + + return normalized.Length > 1 + ? normalized.TrimEnd('/') + : normalized; + } + + IEnumerable GetConfiguredServices() + { + foreach (var (name, service) in config.CurrentValue.Services) + { + if (service.Backend is null || service.ClientCredentials is null) + { + continue; + } + + var routePrefix = NormalizeRoutePrefix(service.ClientCredentials.RoutePrefix); + var verificationUri = CreateVerificationUri(service.Backend.BaseUrl, service.ClientCredentials.VerificationPath); + if (verificationUri is null) + { + continue; + } + + yield return new ConfiguredClientCredentialsService(name, routePrefix, verificationUri); + } + } +} diff --git a/Source/AuthProxy/Authentication/ClientCredentialsTokenPayload.cs b/Source/AuthProxy/Authentication/ClientCredentialsTokenPayload.cs new file mode 100644 index 0000000..a01637d --- /dev/null +++ b/Source/AuthProxy/Authentication/ClientCredentialsTokenPayload.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.Authentication; + +/// +/// The protected contents of an AuthProxy-issued bearer token. +/// +/// The service the token is scoped to. +/// The route prefix the token is scoped to. +/// The verified client identifier. +public record ClientCredentialsTokenPayload( + string Service, + string RoutePrefix, + string ClientId); diff --git a/Source/AuthProxy/Authentication/ClientCredentialsTokenProtector.cs b/Source/AuthProxy/Authentication/ClientCredentialsTokenProtector.cs new file mode 100644 index 0000000..eeaae5b --- /dev/null +++ b/Source/AuthProxy/Authentication/ClientCredentialsTokenProtector.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 Microsoft.AspNetCore.DataProtection; + +namespace Cratis.AuthProxy.Authentication; + +/// +/// Creates and validates AuthProxy-issued bearer tokens for the client-credentials flow. +/// +/// Creates the data protector used for AuthProxy-issued bearer tokens. +public class ClientCredentialsTokenProtector( + IDataProtectionProvider dataProtectionProvider) +{ + static readonly TimeSpan _tokenLifetime = TimeSpan.FromHours(1); + static readonly JsonSerializerOptions _serializerOptions = new(JsonSerializerDefaults.Web); + + readonly ITimeLimitedDataProtector _protector = dataProtectionProvider + .CreateProtector("Cratis.AuthProxy.Authentication.ClientCredentials.v1") + .ToTimeLimitedDataProtector(); + + /// + /// Gets the number of seconds a newly issued token remains valid. + /// + public int ExpiresInSeconds => (int)_tokenLifetime.TotalSeconds; + + /// + /// Creates a new bearer token for the supplied service and client identifier. + /// + /// The service the token is scoped to. + /// The verified client identifier. + /// The protected bearer token value. + public string CreateToken(ConfiguredClientCredentialsService service, string clientId) + { + var payload = JsonSerializer.Serialize( + new ClientCredentialsTokenPayload(service.Name, service.RoutePrefix, clientId), + _serializerOptions); + + return _protector.Protect(payload, _tokenLifetime); + } + + /// + /// Tries to validate and unprotect a bearer token. + /// + /// The token to validate. + /// The unprotected payload. + /// if the token is valid; otherwise . + public bool TryValidate(string token, out ClientCredentialsTokenPayload payload) + { + try + { + var protectedPayload = _protector.Unprotect(token); + var deserialized = JsonSerializer.Deserialize(protectedPayload, _serializerOptions); + if (deserialized is null) + { + payload = default!; + return false; + } + + payload = deserialized; + return true; + } + catch + { + payload = default!; + return false; + } + } +} diff --git a/Source/AuthProxy/Authentication/ClientCredentialsVerificationRequest.cs b/Source/AuthProxy/Authentication/ClientCredentialsVerificationRequest.cs new file mode 100644 index 0000000..1a375e0 --- /dev/null +++ b/Source/AuthProxy/Authentication/ClientCredentialsVerificationRequest.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.Authentication; + +/// +/// The well-known payload sent to a downstream verification endpoint. +/// +/// The target service key. +/// The route prefix the issued token should be scoped to. +/// The client identifier to verify. +/// The client secret to verify. +public record ClientCredentialsVerificationRequest( + string Service, + string RoutePrefix, + string ClientId, + string ClientSecret); diff --git a/Source/AuthProxy/Authentication/ClientCredentialsVerificationResult.cs b/Source/AuthProxy/Authentication/ClientCredentialsVerificationResult.cs new file mode 100644 index 0000000..e99fc20 --- /dev/null +++ b/Source/AuthProxy/Authentication/ClientCredentialsVerificationResult.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. + +using System.Net; + +namespace Cratis.AuthProxy.Authentication; + +/// +/// The downstream verification outcome. +/// +/// The high-level verification status. +/// The downstream HTTP status code. +public record ClientCredentialsVerificationResult( + ClientCredentialsVerificationStatus Status, + HttpStatusCode StatusCode); diff --git a/Source/AuthProxy/Authentication/ClientCredentialsVerificationStatus.cs b/Source/AuthProxy/Authentication/ClientCredentialsVerificationStatus.cs new file mode 100644 index 0000000..a2b2e30 --- /dev/null +++ b/Source/AuthProxy/Authentication/ClientCredentialsVerificationStatus.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.Authentication; + +/// +/// The status of the downstream verification request. +/// +public enum ClientCredentialsVerificationStatus +{ + /// + /// The downstream service accepted the client credentials. + /// + Succeeded = 0, + + /// + /// The downstream service rejected the client credentials. + /// + Rejected = 1, + + /// + /// The downstream verification call failed unexpectedly. + /// + Failed = 2, +} diff --git a/Source/AuthProxy/Authentication/ClientCredentialsVerifier.cs b/Source/AuthProxy/Authentication/ClientCredentialsVerifier.cs new file mode 100644 index 0000000..eaa1667 --- /dev/null +++ b/Source/AuthProxy/Authentication/ClientCredentialsVerifier.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. + +using System.Net; +namespace Cratis.AuthProxy.Authentication; + +/// +/// Calls the configured downstream verification endpoint for the client-credentials flow. +/// +/// Creates outbound HTTP clients for verification requests. +public class ClientCredentialsVerifier( + IHttpClientFactory httpClientFactory) +{ + /// + /// Verifies the supplied client credentials against the configured downstream service. + /// + /// The target service configuration. + /// The client identifier supplied to AuthProxy. + /// The client secret supplied to AuthProxy. + /// The cancellation token. + /// The result of the downstream verification request. + public async Task VerifyAsync( + ConfiguredClientCredentialsService service, + string clientId, + string clientSecret, + CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(HttpMethod.Post, service.VerificationUri) + { + Content = JsonContent.Create(new ClientCredentialsVerificationRequest( + service.Name, + service.RoutePrefix, + clientId, + clientSecret)) + }; + + try + { + using var response = await httpClientFactory.CreateClient(nameof(ClientCredentialsVerifier)) + .SendAsync(request, cancellationToken); + + if (response.IsSuccessStatusCode) + { + return new(ClientCredentialsVerificationStatus.Succeeded, response.StatusCode); + } + + return (int)response.StatusCode >= 500 + ? new(ClientCredentialsVerificationStatus.Failed, response.StatusCode) + : new(ClientCredentialsVerificationStatus.Rejected, response.StatusCode); + } + catch (HttpRequestException) + { + return new(ClientCredentialsVerificationStatus.Failed, HttpStatusCode.BadGateway); + } + } +} diff --git a/Source/AuthProxy/Authentication/ConfiguredClientCredentialsService.cs b/Source/AuthProxy/Authentication/ConfiguredClientCredentialsService.cs new file mode 100644 index 0000000..d67f704 --- /dev/null +++ b/Source/AuthProxy/Authentication/ConfiguredClientCredentialsService.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.Authentication; + +/// +/// Represents a service that is configured for the back-channel client-credentials flow. +/// +/// The configured service key. +/// The route prefix that issued tokens are scoped to. +/// The downstream verification endpoint. +public record ConfiguredClientCredentialsService( + string Name, + string RoutePrefix, + Uri VerificationUri); diff --git a/Source/AuthProxy/Configuration/Service.cs b/Source/AuthProxy/Configuration/Service.cs index 86d0ba8..f74f899 100644 --- a/Source/AuthProxy/Configuration/Service.cs +++ b/Source/AuthProxy/Configuration/Service.cs @@ -30,4 +30,10 @@ public class Service /// to enrich the identity details cookie. Defaults to when a Backend is configured. /// public bool? ResolveIdentityDetails { get; set; } + + /// + /// Gets or sets the back-channel client-credentials configuration for this service. + /// When configured, AuthProxy can verify client credentials against the service and mint scoped bearer tokens. + /// + public ServiceClientCredentials? ClientCredentials { get; set; } } diff --git a/Source/AuthProxy/Configuration/ServiceClientCredentials.cs b/Source/AuthProxy/Configuration/ServiceClientCredentials.cs new file mode 100644 index 0000000..654b06a --- /dev/null +++ b/Source/AuthProxy/Configuration/ServiceClientCredentials.cs @@ -0,0 +1,26 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.ComponentModel.DataAnnotations; + +namespace Cratis.AuthProxy.Configuration; + +/// +/// Represents the client-credentials configuration for a proxied service. +/// +public class ServiceClientCredentials +{ + /// + /// Gets or sets the route prefix that issued bearer tokens are allowed to access. + /// Defaults to the standard API route prefix. + /// + [Required] + public string RoutePrefix { get; set; } = "/api"; + + /// + /// Gets or sets the internal verification endpoint that AuthProxy should call with the supplied client credentials. + /// Relative values are resolved against the service backend BaseUrl; absolute values are used as-is. + /// + [Required] + public string VerificationPath { get; set; } = "/.cratis/client-credentials/verify"; +} diff --git a/Source/AuthProxy/HttpContextExtensions.cs b/Source/AuthProxy/HttpContextExtensions.cs index 77eac06..2e62da7 100644 --- a/Source/AuthProxy/HttpContextExtensions.cs +++ b/Source/AuthProxy/HttpContextExtensions.cs @@ -61,12 +61,19 @@ public static bool IsLogin(this HttpContext context) => /// if the request targets the providers endpoint; otherwise . public static bool IsProviders(this HttpContext context) => context.Request.Path.StartsWithSegments(WellKnownPaths.Providers); + /// + /// Determines whether the request targets the well-known client-credentials token endpoint. + /// + /// The to evaluate. + /// if the request targets the token endpoint; otherwise . + public static bool IsToken(this HttpContext context) => context.Request.Path.StartsWithSegments(WellKnownPaths.Token); + /// /// Determines whether the request targets the AuthProxy authentication user interface. /// /// The to evaluate. /// if the request is part of the authentication UI; otherwise . - public static bool IsAuthenticationUI(this HttpContext context) => context.IsLogin() || context.IsProviders(); + public static bool IsAuthenticationUI(this HttpContext context) => context.IsLogin() || context.IsProviders() || context.IsToken(); /// /// Determines whether the request targets any authentication bootstrap endpoint. diff --git a/Source/AuthProxy/IngressExtensions.cs b/Source/AuthProxy/IngressExtensions.cs index d867484..57339e9 100644 --- a/Source/AuthProxy/IngressExtensions.cs +++ b/Source/AuthProxy/IngressExtensions.cs @@ -101,6 +101,38 @@ static void MapIngressEndpoints(this WebApplication app) app.MapMethods(WellKnownPaths.Providers, [HttpMethods.Head], () => Results.Ok()) .AllowAnonymous(); + app.MapPost(WellKnownPaths.Token, async (HttpContext context, ClientCredentialsGrantService grantService) => + { + var form = await context.Request.ReadFormAsync(context.RequestAborted); + var result = await grantService.GrantAsync( + form["grant_type"].FirstOrDefault(), + form["service"].FirstOrDefault(), + form["client_id"].FirstOrDefault(), + form["client_secret"].FirstOrDefault(), + context.RequestAborted); + + context.Response.Headers.CacheControl = "no-store"; + context.Response.Headers.Pragma = "no-cache"; + + return result.Succeeded + ? Results.Json( + new + { + access_token = result.AccessToken, + token_type = result.TokenType, + expires_in = result.ExpiresIn, + }, + statusCode: result.StatusCode) + : Results.Json( + new + { + error = result.Error, + error_description = result.ErrorDescription, + }, + statusCode: result.StatusCode); + }) + .AllowAnonymous(); + // Initiates the challenge for the requested provider scheme. app.MapGet($"{WellKnownPaths.LoginPrefix}/{{scheme}}", async (string scheme, HttpContext context, IOptionsMonitor authConfig, ITenantResolver tenantResolver) => { diff --git a/Source/AuthProxy/WellKnownPaths.cs b/Source/AuthProxy/WellKnownPaths.cs index 4327f1f..8fda761 100644 --- a/Source/AuthProxy/WellKnownPaths.cs +++ b/Source/AuthProxy/WellKnownPaths.cs @@ -25,6 +25,11 @@ public static class WellKnownPaths /// public const string LoginPrefix = "/.cratis/login"; + /// + /// The well-known token endpoint for the back-channel client-credentials flow. + /// + public const string Token = "/.cratis/token"; + /// /// The well-known path for the login selection page served by the Web project. /// Unauthenticated users are redirected here when multiple providers are configured. From 6bc079ce89d05a3cc432ffbe91c5f15bb4b2a6ea Mon Sep 17 00:00:00 2001 From: Einar Date: Tue, 7 Jul 2026 19:56:43 +0200 Subject: [PATCH 3/5] Add configurable Data Protection key persistence Both the auth cookie and the new client-credentials bearer tokens are encrypted via ASP.NET Core Data Protection. Without an explicit key store, keys are per-instance and not guaranteed to survive a restart, so a token minted by one AuthProxy replica silently fails to validate on another and every restart invalidates all outstanding tokens and sessions. Add DataProtectionKeysPath so operators can point all replicas at a shared, persistent volume. --- Documentation/configuration/authentication.md | 21 +++++++++++++++++++ Documentation/configuration/index.md | 3 ++- Source/AuthProxy/Configuration/AuthProxy.cs | 10 +++++++++ Source/AuthProxy/IngressExtensions.cs | 8 +++++++ 4 files changed, 41 insertions(+), 1 deletion(-) diff --git a/Documentation/configuration/authentication.md b/Documentation/configuration/authentication.md index a9f8a59..5dcc9ef 100644 --- a/Documentation/configuration/authentication.md +++ b/Documentation/configuration/authentication.md @@ -164,3 +164,24 @@ Successful responses from `/.cratis/token` look like this: The issued bearer token can then be used on the configured route prefix (for example `/api/**`). AuthProxy validates that the token is used against the same configured service and route before forwarding the request. + +### Data Protection keys and horizontal scaling + +Both the authentication cookie and AuthProxy-issued client-credentials bearer tokens are encrypted +using ASP.NET Core Data Protection. By default, keys are not shared across instances. Running more +than one AuthProxy replica, or needing sessions and client-credentials tokens to survive a restart, +requires mounting a persistent, shared volume and pointing `Cratis:AuthProxy:DataProtectionKeysPath` +at it: + +```json +{ + "Cratis": { + "AuthProxy": { + "DataProtectionKeysPath": "/mnt/dataprotection-keys" + } + } +} +``` + +Without this, a client-credentials token minted by one replica will fail to validate on another, +and all outstanding tokens and sessions are invalidated on every restart. diff --git a/Documentation/configuration/index.md b/Documentation/configuration/index.md index 6467fee..6418562 100644 --- a/Documentation/configuration/index.md +++ b/Documentation/configuration/index.md @@ -13,7 +13,8 @@ Cratis AuthProxy is configured entirely through the `Cratis:AuthProxy` section o "Tenants": { ... }, "Services": { ... }, "Invite": { ... }, - "PagesPath": "" + "PagesPath": "", + "DataProtectionKeysPath": "" } } } diff --git a/Source/AuthProxy/Configuration/AuthProxy.cs b/Source/AuthProxy/Configuration/AuthProxy.cs index 54a2893..3a78437 100644 --- a/Source/AuthProxy/Configuration/AuthProxy.cs +++ b/Source/AuthProxy/Configuration/AuthProxy.cs @@ -41,6 +41,16 @@ public class AuthProxy /// public string PagesPath { get; set; } = string.Empty; + /// + /// Gets or sets the absolute path to a directory used to persist ASP.NET Core Data Protection keys. + /// These keys encrypt the authentication cookie and AuthProxy-issued client-credentials bearer tokens. + /// When empty or unset, the default per-machine key ring is used, which is neither guaranteed to + /// survive a restart nor shared across replicas. Any deployment running more than one AuthProxy + /// instance, or that needs sessions and client-credentials tokens to survive a restart, should + /// mount a persistent, shared volume and point this setting at it. + /// + public string DataProtectionKeysPath { get; set; } = string.Empty; + /// /// Gets or sets the . /// Tenants are keyed by tenant ID string. diff --git a/Source/AuthProxy/IngressExtensions.cs b/Source/AuthProxy/IngressExtensions.cs index 57339e9..9080aa4 100644 --- a/Source/AuthProxy/IngressExtensions.cs +++ b/Source/AuthProxy/IngressExtensions.cs @@ -9,6 +9,7 @@ using Cratis.AuthProxy.ReverseProxy; using Cratis.AuthProxy.Tenancy; using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.StaticFiles; using Microsoft.Extensions.Options; @@ -53,6 +54,13 @@ public static WebApplicationBuilder AddIngressConfiguration(this WebApplicationB builder.Services.AddSingleton(); builder.Services.AddSingleton(); + var dataProtectionBuilder = builder.Services.AddDataProtection().SetApplicationName("Cratis.AuthProxy"); + var dataProtectionKeysPath = builder.Configuration[$"{C.AuthProxy.SectionKey}:DataProtectionKeysPath"]; + if (!string.IsNullOrWhiteSpace(dataProtectionKeysPath)) + { + dataProtectionBuilder.PersistKeysToFileSystem(new DirectoryInfo(dataProtectionKeysPath)); + } + return builder; } From 0cffb4da9e3d0dde0f247066da4b56be6d1f9bff Mon Sep 17 00:00:00 2001 From: Einar Date: Tue, 7 Jul 2026 19:56:52 +0200 Subject: [PATCH 4/5] Harden the client-credentials bearer-token auth path - Give the downstream verification HTTP client an explicit 10s timeout instead of the default ~100s, so a slow or hung verification endpoint can't tie up the anonymous /.cratis/token endpoint for that long. - Stash the token payload validated by the composite scheme selector in HttpContext.Items so ClientCredentialsBearerAuthenticationHandler doesn't unprotect the same bearer token a second time on every authenticated request. Falls back to validating directly when the handler is invoked without going through the composite scheme. --- ...thenticationServiceCollectionExtensions.cs | 4 +++- ...tCredentialsBearerAuthenticationHandler.cs | 22 ++++++++++++++++++- .../ClientCredentialsDefaults.cs | 7 ++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/Source/AuthProxy/Authentication/AuthenticationServiceCollectionExtensions.cs b/Source/AuthProxy/Authentication/AuthenticationServiceCollectionExtensions.cs index 01276e4..b03e7e4 100644 --- a/Source/AuthProxy/Authentication/AuthenticationServiceCollectionExtensions.cs +++ b/Source/AuthProxy/Authentication/AuthenticationServiceCollectionExtensions.cs @@ -55,6 +55,7 @@ public static WebApplicationBuilder AddIngressAuthentication(this WebApplication builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddHttpClient(nameof(ClientCredentialsVerifier), client => client.Timeout = TimeSpan.FromSeconds(10)); if (jwtSection.Exists()) { @@ -76,8 +77,9 @@ static string ResolveAuthenticationScheme(HttpContext context, bool hasJwtBearer { var token = authorization["Bearer ".Length..].Trim(); var tokenProtector = context.RequestServices.GetRequiredService(); - if (tokenProtector.TryValidate(token, out _)) + if (tokenProtector.TryValidate(token, out var payload)) { + context.Items[ClientCredentialsDefaults.ValidatedTokenPayloadItemKey] = payload; return ClientCredentialsDefaults.AuthenticationScheme; } diff --git a/Source/AuthProxy/Authentication/ClientCredentialsBearerAuthenticationHandler.cs b/Source/AuthProxy/Authentication/ClientCredentialsBearerAuthenticationHandler.cs index 190691d..ff29f10 100644 --- a/Source/AuthProxy/Authentication/ClientCredentialsBearerAuthenticationHandler.cs +++ b/Source/AuthProxy/Authentication/ClientCredentialsBearerAuthenticationHandler.cs @@ -32,7 +32,7 @@ protected override Task HandleAuthenticateAsync() return Task.FromResult(AuthenticateResult.NoResult()); } - if (!tokenProtector.TryValidate(token, out var payload)) + if (!TryGetValidatedPayload(token, out var payload)) { return Task.FromResult(AuthenticateResult.NoResult()); } @@ -79,4 +79,24 @@ bool TryGetBearerToken(out string token) token = authorization["Bearer ".Length..].Trim(); return !string.IsNullOrWhiteSpace(token); } + + /// + /// Gets the token payload validated earlier by the composite scheme selector, when present, + /// to avoid unprotecting the same token twice. Falls back to validating it directly so the + /// handler also works when invoked without going through the composite scheme (e.g. in specs). + /// + /// The bearer token to validate. + /// The resolved token payload. + /// if a payload was found or validated; otherwise . + bool TryGetValidatedPayload(string token, out ClientCredentialsTokenPayload payload) + { + if (Context.Items.TryGetValue(ClientCredentialsDefaults.ValidatedTokenPayloadItemKey, out var cached) + && cached is ClientCredentialsTokenPayload cachedPayload) + { + payload = cachedPayload; + return true; + } + + return tokenProtector.TryValidate(token, out payload); + } } diff --git a/Source/AuthProxy/Authentication/ClientCredentialsDefaults.cs b/Source/AuthProxy/Authentication/ClientCredentialsDefaults.cs index 5a47053..92884e0 100644 --- a/Source/AuthProxy/Authentication/ClientCredentialsDefaults.cs +++ b/Source/AuthProxy/Authentication/ClientCredentialsDefaults.cs @@ -32,4 +32,11 @@ public static class ClientCredentialsDefaults /// The claim type that stores the route prefix the token is scoped to. /// public const string RoutePrefixClaimType = "cratis/route-prefix"; + + /// + /// The key under which the composite scheme selector stashes an + /// already-validated , so the bearer authentication handler + /// does not have to unprotect the same token a second time. + /// + internal const string ValidatedTokenPayloadItemKey = "Cratis.AuthProxy.ClientCredentials.ValidatedPayload"; } From 818616b341b4817ed418f54c88018732d21814b4 Mon Sep 17 00:00:00 2001 From: Einar Date: Tue, 7 Jul 2026 19:57:01 +0200 Subject: [PATCH 5/5] Stop leaking configured service names via client-credentials errors The anonymous /.cratis/token endpoint returned distinct OAuth error descriptions for "no services configured" vs "this named service isn't configured", letting an unauthenticated caller enumerate which services have client credentials configured by probing the 'service' form field. Both cases now return the same generic description, with the specific reason logged server-side instead. --- .../ClientCredentialsServiceResolver.cs | 16 +++++++++--- ...ClientCredentialsServiceResolverLogging.cs | 25 +++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 Source/AuthProxy/Authentication/ClientCredentialsServiceResolverLogging.cs diff --git a/Source/AuthProxy/Authentication/ClientCredentialsServiceResolver.cs b/Source/AuthProxy/Authentication/ClientCredentialsServiceResolver.cs index 5605228..09d1db0 100644 --- a/Source/AuthProxy/Authentication/ClientCredentialsServiceResolver.cs +++ b/Source/AuthProxy/Authentication/ClientCredentialsServiceResolver.cs @@ -10,9 +10,17 @@ namespace Cratis.AuthProxy.Authentication; /// Resolves which configured service should handle a client-credentials request. /// /// Provides the current AuthProxy configuration. +/// The logger. public class ClientCredentialsServiceResolver( - IOptionsMonitor config) + IOptionsMonitor config, + ILogger logger) { + /// + /// The OAuth error description returned to callers when a service cannot be resolved by name. + /// Deliberately generic so unauthenticated callers cannot enumerate configured service names. + /// + const string UnresolvedServiceErrorDescription = "The requested service is not available for client credentials."; + /// /// Tries to resolve the target service for a token request. /// @@ -28,8 +36,9 @@ public bool TryResolveForTokenRequest( var configuredServices = GetConfiguredServices().ToArray(); if (configuredServices.Length == 0) { + logger.NoServicesConfiguredForClientCredentials(); service = default!; - errorDescription = "No services are configured for client credentials."; + errorDescription = UnresolvedServiceErrorDescription; return false; } @@ -43,8 +52,9 @@ public bool TryResolveForTokenRequest( return true; } + logger.RequestedServiceNotConfiguredForClientCredentials(serviceName); service = default!; - errorDescription = $"The service '{serviceName}' is not configured for client credentials."; + errorDescription = UnresolvedServiceErrorDescription; return false; } diff --git a/Source/AuthProxy/Authentication/ClientCredentialsServiceResolverLogging.cs b/Source/AuthProxy/Authentication/ClientCredentialsServiceResolverLogging.cs new file mode 100644 index 0000000..6d757db --- /dev/null +++ b/Source/AuthProxy/Authentication/ClientCredentialsServiceResolverLogging.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.Authentication; + +/// +/// Holds the log messages for . +/// +internal static partial class ClientCredentialsServiceResolverLogging +{ + /// + /// Logs that no services are configured for client credentials. + /// + /// The logger to write to. + [LoggerMessage(LogLevel.Warning, "A client-credentials token was requested but no services are configured for client credentials.")] + internal static partial void NoServicesConfiguredForClientCredentials(this ILogger logger); + + /// + /// Logs that the requested service is not configured for client credentials. + /// + /// The logger to write to. + /// The requested service name. + [LoggerMessage(LogLevel.Warning, "A client-credentials token was requested for service '{ServiceName}', which is not configured for client credentials.")] + internal static partial void RequestedServiceNotConfiguredForClientCredentials(this ILogger logger, string serviceName); +}