diff --git a/Documentation/configuration/authentication.md b/Documentation/configuration/authentication.md index 5dcc9ef..12cd035 100644 --- a/Documentation/configuration/authentication.md +++ b/Documentation/configuration/authentication.md @@ -157,7 +157,8 @@ Successful responses from `/.cratis/token` look like this: { "access_token": "", "token_type": "Bearer", - "expires_in": 3600 + "expires_in": 3600, + "refresh_token": "" } ``` @@ -165,11 +166,68 @@ The issued bearer token can then be used on the configured route prefix (for exa AuthProxy validates that the token is used against the same configured service and route before forwarding the request. +### Resolving a tenant from the verification response + +The `2xx` response from the verification endpoint may optionally include a JSON body with a `tenant` property: + +```json +{ + "tenant": "acme" +} +``` + +When present, AuthProxy embeds that value in the minted access token (and any refresh token issued +alongside it) as a `cratis/tenant` claim. The claim travels with the token for its entire lifetime, so +every subsequent request authenticated with that token carries it. + +To have AuthProxy resolve the tenant and set the `Tenant-ID` header on proxied requests, add a `Claim` +[tenant resolution strategy](tenancy.md#claim-strategy-options) pointing at that claim type: + +```json +{ + "Cratis": { + "AuthProxy": { + "TenantResolutions": [ + { "Strategy": "Claim", "Options": { "ClaimType": "cratis/tenant" } } + ] + } + } +} +``` + +Like every other `Claim`-resolved value, the tenant returned by the verification endpoint is matched +against the `SourceIdentifiers` configured for each entry in `Cratis:AuthProxy:Tenants` — it is not +used directly as the Cratis tenant ID unless a tenant also lists it as one of its own source identifiers. +See [Tenant registry](tenancy.md#tenant-registry) for how that mapping works. + +### Refreshing a token + +A client can exchange a refresh token for a new access token without resupplying its client +credentials: + +1. The client sends `POST /.cratis/token` +2. The request body uses: + - `grant_type=refresh_token` + - `refresh_token=` +3. AuthProxy validates the refresh token and, if it is still valid, mints a new access token + and a new refresh token for the same service, client, and tenant — the response shape is + identical to the one shown above. + +Refresh tokens are valid for 30 days and are not re-verified against the downstream service on +refresh — since the client secret is not resent, AuthProxy trusts the refresh token itself rather +than calling back to the target service. There is no revocation list: a leaked refresh token +remains usable until it naturally expires, so treat it as a credential and keep its exposure to the +same standard as a client secret. + +An expired or unrecognized refresh token is rejected with `401 Unauthorized` and +`error: "invalid_grant"`. Refresh tokens cannot be used as access tokens (and vice versa) — each is +protected separately, so presenting one where the other is expected is always rejected. + ### 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, +The authentication cookie and AuthProxy-issued client-credentials access and refresh tokens are all +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: diff --git a/Documentation/configuration/services.md b/Documentation/configuration/services.md index 48c9455..5505ac0 100644 --- a/Documentation/configuration/services.md +++ b/Documentation/configuration/services.md @@ -91,10 +91,16 @@ identity details without re-calling the identity endpoint themselves. 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`. +on success, issues a bearer token scoped to the configured `RoutePrefix`, along with a refresh token +that can later be exchanged for a new access token without resupplying the client credentials. 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 + +The verification endpoint's response can optionally include a `tenant` property, which AuthProxy then +carries on the issued tokens and can resolve into the `Tenant-ID` header on proxied requests. +See [Back-channel client credentials](authentication.md#back-channel-client-credentials) for the full +token, tenant-resolution, and refresh-token flow. diff --git a/Documentation/configuration/tenancy.md b/Documentation/configuration/tenancy.md index ece3fb8..d4d430e 100644 --- a/Documentation/configuration/tenancy.md +++ b/Documentation/configuration/tenancy.md @@ -50,6 +50,10 @@ Configure them under `Cratis:AuthProxy:TenantResolutions`: If `ClaimType` is omitted, AuthProxy falls back to reading `X-MS-CLIENT-PRINCIPAL`. +This is also how you resolve a tenant carried on an AuthProxy-issued client-credentials bearer token — +point `ClaimType` at `cratis/tenant`. See +[Resolving a tenant from the verification response](authentication.md#resolving-a-tenant-from-the-verification-response). + --- ## Route strategy options diff --git a/Source/AuthProxy.Specs/Authentication/for_ClientCredentialsBearerAuthenticationHandler/when_a_refresh_token_is_used_as_an_access_token.cs b/Source/AuthProxy.Specs/Authentication/for_ClientCredentialsBearerAuthenticationHandler/when_a_refresh_token_is_used_as_an_access_token.cs new file mode 100644 index 0000000..ff9cd81 --- /dev/null +++ b/Source/AuthProxy.Specs/Authentication/for_ClientCredentialsBearerAuthenticationHandler/when_a_refresh_token_is_used_as_an_access_token.cs @@ -0,0 +1,43 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.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_a_refresh_token_is_used_as_an_access_token : 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 refreshToken = tokenProtector.CreateRefreshToken(service, "orders-api"); + context.Request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", refreshToken).ToString(); + + _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_carries_a_tenant.cs b/Source/AuthProxy.Specs/Authentication/for_ClientCredentialsBearerAuthenticationHandler/when_token_carries_a_tenant.cs new file mode 100644 index 0000000..8191b49 --- /dev/null +++ b/Source/AuthProxy.Specs/Authentication/for_ClientCredentialsBearerAuthenticationHandler/when_token_carries_a_tenant.cs @@ -0,0 +1,44 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using 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_carries_a_tenant : 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", "acme"); + 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_tenant_claim() => _result.Principal!.FindFirst(ClientCredentialsDefaults.TenantClaimType)!.Value.ShouldEqual("acme"); +} diff --git a/Source/AuthProxy.Specs/Authentication/for_ClientCredentialsBearerAuthenticationHandler/when_token_does_not_carry_a_tenant.cs b/Source/AuthProxy.Specs/Authentication/for_ClientCredentialsBearerAuthenticationHandler/when_token_does_not_carry_a_tenant.cs new file mode 100644 index 0000000..1519e50 --- /dev/null +++ b/Source/AuthProxy.Specs/Authentication/for_ClientCredentialsBearerAuthenticationHandler/when_token_does_not_carry_a_tenant.cs @@ -0,0 +1,44 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using 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_does_not_carry_a_tenant : 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_not_expose_a_tenant_claim() => _result.Principal!.FindFirst(ClientCredentialsDefaults.TenantClaimType).ShouldBeNull(); +} 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 index 8c74dd1..50a5aef 100644 --- 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 @@ -23,6 +23,8 @@ public class AuthProxyFactory : WebApplicationFactory protected virtual HttpStatusCode VerificationStatusCode => HttpStatusCode.NoContent; + protected virtual object? VerificationResponseBody => null; + /// protected override void ConfigureWebHost(IWebHostBuilder builder) { @@ -52,7 +54,13 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) CapturedVerificationRequest = await request.Content.ReadFromJsonAsync(); } - return new HttpResponseMessage(VerificationStatusCode); + var response = new HttpResponseMessage(VerificationStatusCode); + if (VerificationResponseBody is not null) + { + response.Content = JsonContent.Create(VerificationResponseBody); + } + + return response; })); }); } diff --git a/Source/AuthProxy.Specs/Scenarios/when_client_credentials_token_is_requested/TenantAuthProxyFactory.cs b/Source/AuthProxy.Specs/Scenarios/when_client_credentials_token_is_requested/TenantAuthProxyFactory.cs new file mode 100644 index 0000000..40e4520 --- /dev/null +++ b/Source/AuthProxy.Specs/Scenarios/when_client_credentials_token_is_requested/TenantAuthProxyFactory.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. + +namespace Cratis.AuthProxy.Scenarios.when_client_credentials_token_is_requested; + +public class TenantAuthProxyFactory : AuthProxyFactory +{ + public const string VerifiedTenant = "acme"; + + protected override object? VerificationResponseBody => new { tenant = VerifiedTenant }; +} diff --git a/Source/AuthProxy.Specs/Scenarios/when_client_credentials_token_is_requested/and_a_valid_refresh_token_is_presented.cs b/Source/AuthProxy.Specs/Scenarios/when_client_credentials_token_is_requested/and_a_valid_refresh_token_is_presented.cs new file mode 100644 index 0000000..48e1083 --- /dev/null +++ b/Source/AuthProxy.Specs/Scenarios/when_client_credentials_token_is_requested/and_a_valid_refresh_token_is_presented.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; +using System.Text.Json; +using Cratis.AuthProxy.Authentication; + +namespace Cratis.AuthProxy.Scenarios.when_client_credentials_token_is_requested; + +[Collection(ClientCredentialsScenarioCollection.Name)] +public class and_a_valid_refresh_token_is_presented : Specification +{ + HttpResponseMessage _response; + JsonDocument _payload; + string _originalAccessToken; + + async Task Establish() + { + var factory = new AuthProxyFactory(); + using var client = factory.CreateTestClient(); + + var initialResponse = await client.PostAsync( + WellKnownPaths.Token, + new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = ClientCredentialsDefaults.GrantType, + ["service"] = "portal", + ["client_id"] = "orders-api", + ["client_secret"] = "super-secret", + })); + + var initialPayload = JsonDocument.Parse(await initialResponse.Content.ReadAsStringAsync()); + _originalAccessToken = initialPayload.RootElement.GetProperty("access_token").GetString()!; + var refreshToken = initialPayload.RootElement.GetProperty("refresh_token").GetString()!; + + _response = await client.PostAsync( + WellKnownPaths.Token, + new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = ClientCredentialsDefaults.RefreshGrantType, + ["refresh_token"] = refreshToken, + })); + + _payload = JsonDocument.Parse(await _response.Content.ReadAsStringAsync()); + } + + [Fact] void should_return_ok() => _response.StatusCode.ShouldEqual(HttpStatusCode.OK); + [Fact] void should_return_a_new_access_token() + { + var accessToken = _payload.RootElement.GetProperty("access_token").GetString(); + string.IsNullOrWhiteSpace(accessToken).ShouldBeFalse(); + (accessToken == _originalAccessToken).ShouldBeFalse(); + } + [Fact] void should_return_the_token_lifetime() => _payload.RootElement.GetProperty("expires_in").GetInt32().ShouldEqual(3600); + [Fact] void should_return_a_new_refresh_token() => string.IsNullOrWhiteSpace(_payload.RootElement.GetProperty("refresh_token").GetString()).ShouldBeFalse(); +} diff --git a/Source/AuthProxy.Specs/Scenarios/when_client_credentials_token_is_requested/and_an_access_token_is_used_as_a_refresh_token.cs b/Source/AuthProxy.Specs/Scenarios/when_client_credentials_token_is_requested/and_an_access_token_is_used_as_a_refresh_token.cs new file mode 100644 index 0000000..c9565c5 --- /dev/null +++ b/Source/AuthProxy.Specs/Scenarios/when_client_credentials_token_is_requested/and_an_access_token_is_used_as_a_refresh_token.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_an_access_token_is_used_as_a_refresh_token : Specification +{ + HttpResponseMessage _response; + JsonDocument _payload; + + async Task Establish() + { + var factory = new AuthProxyFactory(); + using var client = factory.CreateTestClient(); + + var initialResponse = await client.PostAsync( + WellKnownPaths.Token, + new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = ClientCredentialsDefaults.GrantType, + ["service"] = "portal", + ["client_id"] = "orders-api", + ["client_secret"] = "super-secret", + })); + + var initialPayload = JsonDocument.Parse(await initialResponse.Content.ReadAsStringAsync()); + var accessToken = initialPayload.RootElement.GetProperty("access_token").GetString()!; + + _response = await client.PostAsync( + WellKnownPaths.Token, + new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = ClientCredentialsDefaults.RefreshGrantType, + ["refresh_token"] = accessToken, + })); + + _payload = JsonDocument.Parse(await _response.Content.ReadAsStringAsync()); + } + + [Fact] void should_return_unauthorized() => _response.StatusCode.ShouldEqual(HttpStatusCode.Unauthorized); + [Fact] void should_return_an_invalid_grant_error() => _payload.RootElement.GetProperty("error").GetString().ShouldEqual("invalid_grant"); +} diff --git a/Source/AuthProxy.Specs/Scenarios/when_client_credentials_token_is_requested/and_refresh_token_is_invalid.cs b/Source/AuthProxy.Specs/Scenarios/when_client_credentials_token_is_requested/and_refresh_token_is_invalid.cs new file mode 100644 index 0000000..da4e326 --- /dev/null +++ b/Source/AuthProxy.Specs/Scenarios/when_client_credentials_token_is_requested/and_refresh_token_is_invalid.cs @@ -0,0 +1,34 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using 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_refresh_token_is_invalid : Specification +{ + HttpResponseMessage _response; + JsonDocument _payload; + + async Task Establish() + { + await using var factory = new AuthProxyFactory(); + using var client = factory.CreateTestClient(); + + _response = await client.PostAsync( + WellKnownPaths.Token, + new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = ClientCredentialsDefaults.RefreshGrantType, + ["refresh_token"] = "not-a-real-refresh-token", + })); + + _payload = JsonDocument.Parse(await _response.Content.ReadAsStringAsync()); + } + + [Fact] void should_return_unauthorized() => _response.StatusCode.ShouldEqual(HttpStatusCode.Unauthorized); + [Fact] void should_return_an_invalid_grant_error() => _payload.RootElement.GetProperty("error").GetString().ShouldEqual("invalid_grant"); +} 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 index 6effe31..1745f4f 100644 --- 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 @@ -36,6 +36,7 @@ async Task Establish() [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_return_a_refresh_token() => string.IsNullOrWhiteSpace(_payload.RootElement.GetProperty("refresh_token").GetString()).ShouldBeFalse(); [Fact] void should_forward_the_well_known_verification_payload() { _factory.CapturedVerificationRequest.ShouldNotBeNull(); diff --git a/Source/AuthProxy.Specs/Scenarios/when_client_credentials_token_is_requested/and_verification_response_includes_a_tenant.cs b/Source/AuthProxy.Specs/Scenarios/when_client_credentials_token_is_requested/and_verification_response_includes_a_tenant.cs new file mode 100644 index 0000000..2b60189 --- /dev/null +++ b/Source/AuthProxy.Specs/Scenarios/when_client_credentials_token_is_requested/and_verification_response_includes_a_tenant.cs @@ -0,0 +1,51 @@ +// 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; +using Microsoft.Extensions.DependencyInjection; + +namespace Cratis.AuthProxy.Scenarios.when_client_credentials_token_is_requested; + +[Collection(ClientCredentialsScenarioCollection.Name)] +public class and_verification_response_includes_a_tenant : Specification +{ + HttpResponseMessage _response; + JsonDocument _payload; + TenantAuthProxyFactory _factory; + + async Task Establish() + { + _factory = new TenantAuthProxyFactory(); + 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_embed_the_tenant_in_the_access_token() + { + var tokenProtector = _factory.Services.GetRequiredService(); + var accessToken = _payload.RootElement.GetProperty("access_token").GetString()!; + tokenProtector.TryValidate(accessToken, out var tokenPayload).ShouldBeTrue(); + tokenPayload.Tenant.ShouldEqual(TenantAuthProxyFactory.VerifiedTenant); + } + [Fact] void should_embed_the_tenant_in_the_refresh_token() + { + var tokenProtector = _factory.Services.GetRequiredService(); + var refreshToken = _payload.RootElement.GetProperty("refresh_token").GetString()!; + tokenProtector.TryValidateRefreshToken(refreshToken, out var tokenPayload).ShouldBeTrue(); + tokenPayload.Tenant.ShouldEqual(TenantAuthProxyFactory.VerifiedTenant); + } +} diff --git a/Source/AuthProxy/Authentication/ClientCredentialsBearerAuthenticationHandler.cs b/Source/AuthProxy/Authentication/ClientCredentialsBearerAuthenticationHandler.cs index ff29f10..7736de1 100644 --- a/Source/AuthProxy/Authentication/ClientCredentialsBearerAuthenticationHandler.cs +++ b/Source/AuthProxy/Authentication/ClientCredentialsBearerAuthenticationHandler.cs @@ -59,6 +59,11 @@ protected override Task HandleAuthenticateAsync() new("amr", ClientCredentialsDefaults.GrantType), }; + if (!string.IsNullOrWhiteSpace(payload.Tenant)) + { + claims.Add(new(ClientCredentialsDefaults.TenantClaimType, payload.Tenant)); + } + var identity = new ClaimsIdentity(claims, Scheme.Name); var principal = new ClaimsPrincipal(identity); var ticket = new AuthenticationTicket(principal, Scheme.Name); diff --git a/Source/AuthProxy/Authentication/ClientCredentialsDefaults.cs b/Source/AuthProxy/Authentication/ClientCredentialsDefaults.cs index 92884e0..e103533 100644 --- a/Source/AuthProxy/Authentication/ClientCredentialsDefaults.cs +++ b/Source/AuthProxy/Authentication/ClientCredentialsDefaults.cs @@ -19,10 +19,15 @@ public static class ClientCredentialsDefaults public const string AuthenticationScheme = "AuthProxyClientCredentials"; /// - /// The OAuth 2.0 grant type supported by the token endpoint. + /// The OAuth 2.0 grant type used to request a token with client credentials. /// public const string GrantType = "client_credentials"; + /// + /// The OAuth 2.0 grant type used to exchange a refresh token for a new access token. + /// + public const string RefreshGrantType = "refresh_token"; + /// /// The claim type that stores the target service key. /// @@ -33,6 +38,13 @@ public static class ClientCredentialsDefaults /// public const string RoutePrefixClaimType = "cratis/route-prefix"; + /// + /// The claim type that stores the tenant resolved from the downstream verification response. + /// Configure a Claim tenant resolution strategy with this claim type to have AuthProxy + /// resolve the tenant for client-credentials-authenticated requests. + /// + public const string TenantClaimType = "cratis/tenant"; + /// /// The key under which the composite scheme selector stashes an /// already-validated , so the bearer authentication handler diff --git a/Source/AuthProxy/Authentication/ClientCredentialsGrantResult.cs b/Source/AuthProxy/Authentication/ClientCredentialsGrantResult.cs index 72dd2a3..45b38ea 100644 --- a/Source/AuthProxy/Authentication/ClientCredentialsGrantResult.cs +++ b/Source/AuthProxy/Authentication/ClientCredentialsGrantResult.cs @@ -32,6 +32,11 @@ public record ClientCredentialsGrantResult /// public int ExpiresIn { get; init; } + /// + /// Gets the issued refresh token, when the request succeeded. + /// + public string RefreshToken { get; init; } = string.Empty; + /// /// Gets the OAuth error code, when the request failed. /// @@ -51,13 +56,15 @@ public record ClientCredentialsGrantResult /// Creates a successful token result. /// /// The issued access token. - /// The token lifetime in seconds. + /// The access token lifetime in seconds. + /// The issued refresh token. /// A successful token result. - public static ClientCredentialsGrantResult Success(string accessToken, int expiresIn) => new() + public static ClientCredentialsGrantResult Success(string accessToken, int expiresIn, string refreshToken) => new() { StatusCode = StatusCodes.Status200OK, AccessToken = accessToken, ExpiresIn = expiresIn, + RefreshToken = refreshToken, }; /// diff --git a/Source/AuthProxy/Authentication/ClientCredentialsGrantService.cs b/Source/AuthProxy/Authentication/ClientCredentialsGrantService.cs index d0f3c3f..5a7aa79 100644 --- a/Source/AuthProxy/Authentication/ClientCredentialsGrantService.cs +++ b/Source/AuthProxy/Authentication/ClientCredentialsGrantService.cs @@ -15,29 +15,37 @@ public class ClientCredentialsGrantService( ClientCredentialsTokenProtector tokenProtector) { /// - /// Processes a client-credentials token request. + /// Processes a client-credentials or refresh-token request. /// /// The requested OAuth grant type. /// The requested service name. /// The provided client identifier. /// The provided client secret. + /// The provided refresh token, for the refresh_token grant. /// The cancellation token. /// The token issuance result. - public async Task GrantAsync( + public Task GrantAsync( string? grantType, string? serviceName, string? clientId, string? clientSecret, - CancellationToken cancellationToken) - { - if (!string.Equals(grantType, ClientCredentialsDefaults.GrantType, StringComparison.Ordinal)) + string? refreshToken, + CancellationToken cancellationToken) => grantType switch { - return ClientCredentialsGrantResult.CreateError( + ClientCredentialsDefaults.GrantType => GrantFromClientCredentialsAsync(serviceName, clientId, clientSecret, cancellationToken), + ClientCredentialsDefaults.RefreshGrantType => Task.FromResult(GrantFromRefreshToken(refreshToken)), + _ => Task.FromResult(ClientCredentialsGrantResult.CreateError( StatusCodes.Status400BadRequest, "unsupported_grant_type", - "Only the client_credentials grant type is supported."); - } + "Only the client_credentials and refresh_token grant types are supported.")), + }; + async Task GrantFromClientCredentialsAsync( + string? serviceName, + string? clientId, + string? clientSecret, + CancellationToken cancellationToken) + { if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(clientSecret)) { return ClientCredentialsGrantResult.CreateError( @@ -71,8 +79,41 @@ public async Task GrantAsync( "The target service could not verify the supplied client credentials."); } - return ClientCredentialsGrantResult.Success( - tokenProtector.CreateToken(service, clientId), - tokenProtector.ExpiresInSeconds); + return IssueTokens(service, clientId, verificationResult.Tenant); + } + + ClientCredentialsGrantResult GrantFromRefreshToken(string? refreshToken) + { + if (string.IsNullOrWhiteSpace(refreshToken)) + { + return ClientCredentialsGrantResult.CreateError( + StatusCodes.Status400BadRequest, + "invalid_request", + "The refresh_token field is required."); + } + + if (!tokenProtector.TryValidateRefreshToken(refreshToken, out var payload)) + { + return ClientCredentialsGrantResult.CreateError( + StatusCodes.Status401Unauthorized, + "invalid_grant", + "The refresh token is invalid or has expired."); + } + + if (!serviceResolver.TryResolveForTokenRequest(payload.Service, out var service, out var errorDescription)) + { + return ClientCredentialsGrantResult.CreateError( + StatusCodes.Status400BadRequest, + "invalid_grant", + errorDescription); + } + + return IssueTokens(service, payload.ClientId, payload.Tenant); } + + ClientCredentialsGrantResult IssueTokens(ConfiguredClientCredentialsService service, string clientId, string? tenant) => + ClientCredentialsGrantResult.Success( + tokenProtector.CreateToken(service, clientId, tenant), + tokenProtector.ExpiresInSeconds, + tokenProtector.CreateRefreshToken(service, clientId, tenant)); } diff --git a/Source/AuthProxy/Authentication/ClientCredentialsTokenPayload.cs b/Source/AuthProxy/Authentication/ClientCredentialsTokenPayload.cs index a01637d..d25d721 100644 --- a/Source/AuthProxy/Authentication/ClientCredentialsTokenPayload.cs +++ b/Source/AuthProxy/Authentication/ClientCredentialsTokenPayload.cs @@ -9,7 +9,9 @@ namespace Cratis.AuthProxy.Authentication; /// The service the token is scoped to. /// The route prefix the token is scoped to. /// The verified client identifier. +/// The tenant resolved from the downstream verification response, when one was returned. public record ClientCredentialsTokenPayload( string Service, string RoutePrefix, - string ClientId); + string ClientId, + string? Tenant = null); diff --git a/Source/AuthProxy/Authentication/ClientCredentialsTokenProtector.cs b/Source/AuthProxy/Authentication/ClientCredentialsTokenProtector.cs index eeaae5b..61f3913 100644 --- a/Source/AuthProxy/Authentication/ClientCredentialsTokenProtector.cs +++ b/Source/AuthProxy/Authentication/ClientCredentialsTokenProtector.cs @@ -13,43 +13,75 @@ public class ClientCredentialsTokenProtector( IDataProtectionProvider dataProtectionProvider) { static readonly TimeSpan _tokenLifetime = TimeSpan.FromHours(1); + static readonly TimeSpan _refreshTokenLifetime = TimeSpan.FromDays(30); static readonly JsonSerializerOptions _serializerOptions = new(JsonSerializerDefaults.Web); readonly ITimeLimitedDataProtector _protector = dataProtectionProvider .CreateProtector("Cratis.AuthProxy.Authentication.ClientCredentials.v1") .ToTimeLimitedDataProtector(); + readonly ITimeLimitedDataProtector _refreshProtector = dataProtectionProvider + .CreateProtector("Cratis.AuthProxy.Authentication.ClientCredentials.Refresh.v1") + .ToTimeLimitedDataProtector(); + /// - /// Gets the number of seconds a newly issued token remains valid. + /// Gets the number of seconds a newly issued access token remains valid. /// public int ExpiresInSeconds => (int)_tokenLifetime.TotalSeconds; /// - /// Creates a new bearer token for the supplied service and client identifier. + /// Gets the number of seconds a newly issued refresh token remains valid. + /// + public int RefreshExpiresInSeconds => (int)_refreshTokenLifetime.TotalSeconds; + + /// + /// Creates a new access 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); + /// The tenant resolved from the downstream verification response, when one was returned. + /// The protected access token value. + public string CreateToken(ConfiguredClientCredentialsService service, string clientId, string? tenant = null) => + _protector.Protect(Serialize(service, clientId, tenant), _tokenLifetime); - return _protector.Protect(payload, _tokenLifetime); - } + /// + /// Creates a new refresh token for the supplied service and client identifier. + /// + /// The service the token is scoped to. + /// The verified client identifier. + /// The tenant resolved from the downstream verification response, when one was returned. + /// The protected refresh token value. + public string CreateRefreshToken(ConfiguredClientCredentialsService service, string clientId, string? tenant = null) => + _refreshProtector.Protect(Serialize(service, clientId, tenant), _refreshTokenLifetime); + + /// + /// Tries to validate and unprotect an access token. + /// + /// The token to validate. + /// The unprotected payload. + /// if the token is valid; otherwise . + public bool TryValidate(string token, out ClientCredentialsTokenPayload payload) => + TryUnprotect(_protector, token, out payload); /// - /// Tries to validate and unprotect a bearer token. + /// Tries to validate and unprotect a refresh token. /// /// The token to validate. /// The unprotected payload. /// if the token is valid; otherwise . - public bool TryValidate(string token, out ClientCredentialsTokenPayload payload) + public bool TryValidateRefreshToken(string token, out ClientCredentialsTokenPayload payload) => + TryUnprotect(_refreshProtector, token, out payload); + + static string Serialize(ConfiguredClientCredentialsService service, string clientId, string? tenant) => + JsonSerializer.Serialize( + new ClientCredentialsTokenPayload(service.Name, service.RoutePrefix, clientId, tenant), + _serializerOptions); + + static bool TryUnprotect(ITimeLimitedDataProtector protector, string token, out ClientCredentialsTokenPayload payload) { try { - var protectedPayload = _protector.Unprotect(token); + var protectedPayload = protector.Unprotect(token); var deserialized = JsonSerializer.Deserialize(protectedPayload, _serializerOptions); if (deserialized is null) { diff --git a/Source/AuthProxy/Authentication/ClientCredentialsVerificationResponseBody.cs b/Source/AuthProxy/Authentication/ClientCredentialsVerificationResponseBody.cs new file mode 100644 index 0000000..3d824e7 --- /dev/null +++ b/Source/AuthProxy/Authentication/ClientCredentialsVerificationResponseBody.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.Authentication; + +/// +/// The optional JSON body a downstream verification endpoint can return alongside a successful response. +/// +/// The tenant the verified client belongs to, when the target service resolves one. +public record ClientCredentialsVerificationResponseBody(string? Tenant = null); diff --git a/Source/AuthProxy/Authentication/ClientCredentialsVerificationResult.cs b/Source/AuthProxy/Authentication/ClientCredentialsVerificationResult.cs index e99fc20..8e0ca34 100644 --- a/Source/AuthProxy/Authentication/ClientCredentialsVerificationResult.cs +++ b/Source/AuthProxy/Authentication/ClientCredentialsVerificationResult.cs @@ -10,6 +10,8 @@ namespace Cratis.AuthProxy.Authentication; /// /// The high-level verification status. /// The downstream HTTP status code. +/// The tenant the verified client belongs to, when the target service resolved one. public record ClientCredentialsVerificationResult( ClientCredentialsVerificationStatus Status, - HttpStatusCode StatusCode); + HttpStatusCode StatusCode, + string? Tenant = null); diff --git a/Source/AuthProxy/Authentication/ClientCredentialsVerifier.cs b/Source/AuthProxy/Authentication/ClientCredentialsVerifier.cs index eaa1667..534ecba 100644 --- a/Source/AuthProxy/Authentication/ClientCredentialsVerifier.cs +++ b/Source/AuthProxy/Authentication/ClientCredentialsVerifier.cs @@ -11,6 +11,8 @@ namespace Cratis.AuthProxy.Authentication; public class ClientCredentialsVerifier( IHttpClientFactory httpClientFactory) { + static readonly JsonSerializerOptions _responseSerializerOptions = new(JsonSerializerDefaults.Web); + /// /// Verifies the supplied client credentials against the configured downstream service. /// @@ -41,7 +43,8 @@ public async Task VerifyAsync( if (response.IsSuccessStatusCode) { - return new(ClientCredentialsVerificationStatus.Succeeded, response.StatusCode); + var tenant = await TryReadTenantAsync(response, cancellationToken); + return new(ClientCredentialsVerificationStatus.Succeeded, response.StatusCode, tenant); } return (int)response.StatusCode >= 500 @@ -53,4 +56,22 @@ public async Task VerifyAsync( return new(ClientCredentialsVerificationStatus.Failed, HttpStatusCode.BadGateway); } } + + static async Task TryReadTenantAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + if (!string.Equals(response.Content.Headers.ContentType?.MediaType, "application/json", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + try + { + var body = await response.Content.ReadFromJsonAsync(_responseSerializerOptions, cancellationToken); + return string.IsNullOrWhiteSpace(body?.Tenant) ? null : body.Tenant; + } + catch (JsonException) + { + return null; + } + } } diff --git a/Source/AuthProxy/IngressExtensions.cs b/Source/AuthProxy/IngressExtensions.cs index 9080aa4..9b09c67 100644 --- a/Source/AuthProxy/IngressExtensions.cs +++ b/Source/AuthProxy/IngressExtensions.cs @@ -117,6 +117,7 @@ static void MapIngressEndpoints(this WebApplication app) form["service"].FirstOrDefault(), form["client_id"].FirstOrDefault(), form["client_secret"].FirstOrDefault(), + form["refresh_token"].FirstOrDefault(), context.RequestAborted); context.Response.Headers.CacheControl = "no-store"; @@ -129,6 +130,7 @@ static void MapIngressEndpoints(this WebApplication app) access_token = result.AccessToken, token_type = result.TokenType, expires_in = result.ExpiresIn, + refresh_token = result.RefreshToken, }, statusCode: result.StatusCode) : Results.Json(