diff --git a/Documentation/configuration/authentication.md b/Documentation/configuration/authentication.md index b330914..5dcc9ef 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,65 @@ 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. + +### 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/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..b03e7e4 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,12 @@ 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(); + builder.Services.AddHttpClient(nameof(ClientCredentialsVerifier), client => client.Timeout = TimeSpan.FromSeconds(10)); + if (jwtSection.Exists()) { authBuilder.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, jwtSection.Bind); @@ -50,6 +70,28 @@ 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 var payload)) + { + context.Items[ClientCredentialsDefaults.ValidatedTokenPayloadItemKey] = payload; + return ClientCredentialsDefaults.AuthenticationScheme; + } + + if (hasJwtBearer) + { + return JwtBearerDefaults.AuthenticationScheme; + } + } + + return CookieAuthenticationDefaults.AuthenticationScheme; + } + static void ConfigureCookieOptions(CookieAuthenticationOptions options) { options.Cookie.HttpOnly = true; @@ -215,4 +257,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 (!TryGetValidatedPayload(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); + } + + /// + /// 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 new file mode 100644 index 0000000..92884e0 --- /dev/null +++ b/Source/AuthProxy/Authentication/ClientCredentialsDefaults.cs @@ -0,0 +1,42 @@ +// 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"; + + /// + /// 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"; +} 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..09d1db0 --- /dev/null +++ b/Source/AuthProxy/Authentication/ClientCredentialsServiceResolver.cs @@ -0,0 +1,167 @@ +// 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. +/// The logger. +public class ClientCredentialsServiceResolver( + 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. + /// + /// 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) + { + logger.NoServicesConfiguredForClientCredentials(); + service = default!; + errorDescription = UnresolvedServiceErrorDescription; + 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; + } + + logger.RequestedServiceNotConfiguredForClientCredentials(serviceName); + service = default!; + errorDescription = UnresolvedServiceErrorDescription; + 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/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); +} 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/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/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..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; } @@ -101,6 +109,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.