From 800426b690c13da0e04295973625d5c942ca8089 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 1 Oct 2026 13:38:28 +0200 Subject: [PATCH 01/13] Support certificate and federated client credentials for OIDC providers An OIDC provider can now authenticate AuthProxy to its token endpoint with a private_key_jwt client assertion signed by a certificate (file, certificate store or Azure Key Vault), or with a federated token (workload identity token file or Azure managed identity), instead of a client secret. The credential loaders come from Microsoft.Identity.Web; ClientSecret stays the default and configuring both fails at startup. --- Directory.Packages.props | 1 + Documentation/configuration/authentication.md | 99 ++++++++++++++++++- ..._provider_uses_a_certificate_credential.cs | 39 ++++++++ .../when_signing_with_an_rsa_certificate.cs | 49 +++++++++ ...when_the_certificate_has_no_private_key.cs | 25 +++++ .../given/oidc_client_assertions.cs | 39 ++++++++ .../when_the_credential_cannot_be_loaded.cs | 20 ++++ ...en_the_credential_is_a_certificate_file.cs | 42 ++++++++ ...he_credential_is_a_federated_token_file.cs | 35 +++++++ ...when_the_loaded_certificate_has_expired.cs | 65 ++++++++++++ .../when_the_provider_uses_a_client_secret.cs | 19 ++++ ...ating_a_token_request_with_an_assertion.cs | 41 ++++++++ .../an_oidc_client_credential_validator.cs | 27 +++++ ...rtificate_file_credential_names_no_file.cs | 14 +++ ...te_store_credential_names_no_thumbprint.cs | 14 +++ ...rated_token_file_credential_has_no_file.cs | 14 +++ ...le_credential_relies_on_the_environment.cs | 17 ++++ ...when_a_key_vault_credential_is_complete.cs | 18 ++++ ...lt_credential_is_not_reached_over_https.cs | 19 ++++ ...naged_identity_credential_is_configured.cs | 13 +++ ...secret_and_a_certificate_are_configured.cs | 19 ++++ ...when_no_client_credential_is_configured.cs | 17 ++++ .../given/ClientCertificates.cs | 32 ++++++ Source/AuthProxy/AuthProxy.csproj | 1 + ...thenticationServiceCollectionExtensions.cs | 20 +++- .../CertificateClientAssertion.cs | 61 ++++++++++++ .../Authentication/IOidcClientAssertions.cs | 23 +++++ .../Authentication/OidcClientAssertions.cs | 95 ++++++++++++++++++ .../OidcClientAssertionsLogging.cs | 13 +++ .../OidcClientAuthentication.cs | 64 ++++++++++++ ...cClientCredentialConfigurationValidator.cs | 83 ++++++++++++++++ .../OidcClientCredentialDescription.cs | 60 +++++++++++ .../OidcClientCredentialUnavailable.cs | 12 +++ .../Configuration/OidcClientCredential.cs | 89 +++++++++++++++++ .../OidcClientCredentialSource.cs | 46 +++++++++ .../AuthProxy/Configuration/OidcProvider.cs | 16 +++ 36 files changed, 1259 insertions(+), 2 deletions(-) create mode 100644 Source/AuthProxy.Specs/Authentication/for_AuthenticationServiceCollectionExtensions/when_an_oidc_provider_uses_a_certificate_credential.cs create mode 100644 Source/AuthProxy.Specs/Authentication/for_CertificateClientAssertion/when_signing_with_an_rsa_certificate.cs create mode 100644 Source/AuthProxy.Specs/Authentication/for_CertificateClientAssertion/when_the_certificate_has_no_private_key.cs create mode 100644 Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/given/oidc_client_assertions.cs create mode 100644 Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_the_credential_cannot_be_loaded.cs create mode 100644 Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_the_credential_is_a_certificate_file.cs create mode 100644 Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_the_credential_is_a_federated_token_file.cs create mode 100644 Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_the_loaded_certificate_has_expired.cs create mode 100644 Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_the_provider_uses_a_client_secret.cs create mode 100644 Source/AuthProxy.Specs/Authentication/for_OidcClientAuthentication/when_authenticating_a_token_request_with_an_assertion.cs create mode 100644 Source/AuthProxy.Specs/Authentication/for_OidcClientCredentialConfigurationValidator/given/an_oidc_client_credential_validator.cs create mode 100644 Source/AuthProxy.Specs/Authentication/for_OidcClientCredentialConfigurationValidator/when_a_certificate_file_credential_names_no_file.cs create mode 100644 Source/AuthProxy.Specs/Authentication/for_OidcClientCredentialConfigurationValidator/when_a_certificate_store_credential_names_no_thumbprint.cs create mode 100644 Source/AuthProxy.Specs/Authentication/for_OidcClientCredentialConfigurationValidator/when_a_federated_token_file_credential_has_no_file.cs create mode 100644 Source/AuthProxy.Specs/Authentication/for_OidcClientCredentialConfigurationValidator/when_a_federated_token_file_credential_relies_on_the_environment.cs create mode 100644 Source/AuthProxy.Specs/Authentication/for_OidcClientCredentialConfigurationValidator/when_a_key_vault_credential_is_complete.cs create mode 100644 Source/AuthProxy.Specs/Authentication/for_OidcClientCredentialConfigurationValidator/when_a_key_vault_credential_is_not_reached_over_https.cs create mode 100644 Source/AuthProxy.Specs/Authentication/for_OidcClientCredentialConfigurationValidator/when_a_managed_identity_credential_is_configured.cs create mode 100644 Source/AuthProxy.Specs/Authentication/for_OidcClientCredentialConfigurationValidator/when_both_a_client_secret_and_a_certificate_are_configured.cs create mode 100644 Source/AuthProxy.Specs/Authentication/for_OidcClientCredentialConfigurationValidator/when_no_client_credential_is_configured.cs create mode 100644 Source/AuthProxy.Specs/Authentication/given/ClientCertificates.cs create mode 100644 Source/AuthProxy/Authentication/CertificateClientAssertion.cs create mode 100644 Source/AuthProxy/Authentication/IOidcClientAssertions.cs create mode 100644 Source/AuthProxy/Authentication/OidcClientAssertions.cs create mode 100644 Source/AuthProxy/Authentication/OidcClientAssertionsLogging.cs create mode 100644 Source/AuthProxy/Authentication/OidcClientAuthentication.cs create mode 100644 Source/AuthProxy/Authentication/OidcClientCredentialConfigurationValidator.cs create mode 100644 Source/AuthProxy/Authentication/OidcClientCredentialDescription.cs create mode 100644 Source/AuthProxy/Authentication/OidcClientCredentialUnavailable.cs create mode 100644 Source/AuthProxy/Configuration/OidcClientCredential.cs create mode 100644 Source/AuthProxy/Configuration/OidcClientCredentialSource.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 0dc03791..614481b4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -11,6 +11,7 @@ + diff --git a/Documentation/configuration/authentication.md b/Documentation/configuration/authentication.md index c9ea5970..e6d70b70 100644 --- a/Documentation/configuration/authentication.md +++ b/Documentation/configuration/authentication.md @@ -103,7 +103,8 @@ This allows a common callback endpoint while still restoring tenant-specific beh | `Type` | `string` | Provider type hint (`Microsoft`, `Google`, or `Custom`). | | `Authority` | `string` | OIDC authority URL. | | `ClientId` | `string` | OAuth 2.0 client ID. | -| `ClientSecret` | `string` | OAuth 2.0 client secret. | +| `ClientSecret` | `string` | OAuth 2.0 client secret. Leave empty when `ClientCredential` selects a certificate or federated credential. | +| `ClientCredential` | `object` | Optional certificate or federated credential used instead of `ClientSecret`. See [Client credentials](#client-credentials-certificates-and-federated-credentials). | | `Scopes` | `string[]` | Additional scopes to request (beyond `openid`, `profile`, `email`). | | `ResponseMode` | `string` | How the provider returns the authorization code: `Query` (default) or `FormPost`. See below. | @@ -120,6 +121,102 @@ correlation and nonce cookies to `SameSite=None; Secure` — a cross-site POST o cookies, and `None` requires HTTPS. Do not choose `FormPost` for providers that support `Query`; it trades away the `Lax` hardening for nothing. +#### Client credentials: certificates and federated credentials + +By default AuthProxy authenticates to a provider's token endpoint with `ClientSecret`. Many organizations +disallow long-lived client secrets, and Microsoft recommends certificates or workload identity federation for +Microsoft Entra ID confidential clients. Set `ClientCredential` on the provider to authenticate with a +`client_assertion` ([RFC 7523](https://www.rfc-editor.org/rfc/rfc7523)) instead. Leave `ClientSecret` +empty: AuthProxy refuses to start when both are configured. + +AuthProxy presents the credential every time it calls the provider's token endpoint, including pushed +authorization requests when the provider supports them. The credential loaders come from +[Microsoft.Identity.Web](https://github.com/AzureAD/microsoft-identity-web/wiki/Certificates), +so certificate stores, Key Vault, workload identity and managed identity behave as they do in any other +Microsoft.Identity.Web application. + +| `Source` | Credential | Required properties | +| -------- | ---------- | ------------------- | +| `ClientSecret` | `ClientSecret` sent as `client_secret` (the default). | — | +| `CertificateFile` | A PKCS#12 (`.pfx`) file with the private key signs the assertion. | `CertificatePath`; `CertificatePassword` when the file has one. | +| `CertificateStore` | A certificate found by thumbprint in a certificate store signs the assertion. | `CertificateThumbprint`; `CertificateStorePath` defaults to `CurrentUser/My`. | +| `KeyVaultCertificate` | A certificate downloaded from Azure Key Vault signs the assertion. | `KeyVaultUrl` (https), `KeyVaultCertificateName`. | +| `FederatedTokenFile` | A platform-issued federated token read from a file is the assertion (Kubernetes workload identity). | `TokenFilePath`, or the `AZURE_FEDERATED_TOKEN_FILE` environment variable. | +| `ManagedIdentity` | An Azure managed identity token for the token-exchange audience is the assertion. | None. `ManagedIdentityClientId` selects a user-assigned identity. | + +A certificate assertion is a short-lived JWT signed with `RS256` (RSA keys) or `ES256` (ECDSA keys). Its issuer +and subject are `ClientId`, its audience is the provider's token endpoint, and its header carries the +certificate thumbprint (`x5t`). Upload the certificate's public part to the app registration. AuthProxy +loads a certificate once and loads it again after it expires, so put the renewed certificate in the same +file, store or vault entry before the old one expires, or restart AuthProxy to pick it up straight away. + +`KeyVaultCertificate` authenticates to Key Vault with the default Azure credential chain. Set +`ManagedIdentityClientId` (or `AZURE_CLIENT_ID`) to use a user-assigned managed identity. The identity needs +permission to read the certificate's secret, because the private key is stored there. + +`FederatedTokenFile` and `ManagedIdentity` need a federated identity credential on the app registration +that trusts the platform issuer: the cluster's OIDC issuer and service account for workload identity, or the +managed identity. `ManagedIdentity` requests its token for `api://AzureADTokenExchange` (or the national-cloud +equivalent resolved from `Authority`). Set `TokenExchangeAudience` to override it. + +**Certificate from Key Vault:** + +```json +{ + "Cratis": { + "AuthProxy": { + "Authentication": { + "OidcProviders": [ + { + "Name": "Microsoft", + "Type": "Microsoft", + "Authority": "https://login.microsoftonline.com//v2.0", + "ClientId": "", + "ClientCredential": { + "Source": "KeyVaultCertificate", + "KeyVaultUrl": "https://.vault.azure.net", + "KeyVaultCertificateName": "authproxy-client" + } + } + ] + } + } + } +} +``` + +**Managed identity on Azure Container Apps or App Service:** + +```json +{ + "Cratis": { + "AuthProxy": { + "Authentication": { + "OidcProviders": [ + { + "Name": "Microsoft", + "Type": "Microsoft", + "Authority": "https://login.microsoftonline.com//v2.0", + "ClientId": "", + "ClientCredential": { + "Source": "ManagedIdentity", + "ManagedIdentityClientId": "" + } + } + ] + } + } + } +} +``` + +With environment variables, the same settings are +`Cratis__AuthProxy__Authentication__OidcProviders__0__ClientCredential__Source=ManagedIdentity` and so on. + +If the credential cannot be loaded or produces no assertion, the sign-in fails and is handled as a +[failed sign-in](failed-sign-ins.md), and AuthProxy logs an error that names the provider and credential +source. OAuth 2.0 providers (below) still authenticate with `ClientSecret` only. + ### Canonical federated identity Provider registrations can opt into a stable, provider-aware account tuple. Without this section, diff --git a/Source/AuthProxy.Specs/Authentication/for_AuthenticationServiceCollectionExtensions/when_an_oidc_provider_uses_a_certificate_credential.cs b/Source/AuthProxy.Specs/Authentication/for_AuthenticationServiceCollectionExtensions/when_an_oidc_provider_uses_a_certificate_credential.cs new file mode 100644 index 00000000..4b4d06cd --- /dev/null +++ b/Source/AuthProxy.Specs/Authentication/for_AuthenticationServiceCollectionExtensions/when_an_oidc_provider_uses_a_certificate_credential.cs @@ -0,0 +1,39 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Cratis.AuthProxy.Authentication.for_AuthenticationServiceCollectionExtensions; + +public class when_an_oidc_provider_uses_a_certificate_credential : Specification +{ + OpenIdConnectOptions _options; + IServiceProvider _services; + + void Establish() + { + var builder = WebApplication.CreateBuilder(); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + [$"{C.Authentication.SectionKey}:OidcProviders:0:Name"] = "Workforce", + [$"{C.Authentication.SectionKey}:OidcProviders:0:Authority"] = "https://login.microsoftonline.com/tenant/v2.0", + [$"{C.Authentication.SectionKey}:OidcProviders:0:ClientId"] = "client-id", + [$"{C.Authentication.SectionKey}:OidcProviders:0:ClientCredential:Source"] = "KeyVaultCertificate", + [$"{C.Authentication.SectionKey}:OidcProviders:0:ClientCredential:KeyVaultUrl"] = "https://contoso.vault.azure.net", + [$"{C.Authentication.SectionKey}:OidcProviders:0:ClientCredential:KeyVaultCertificateName"] = "authproxy" + }); + + builder.AddIngressAuthentication(); + _services = builder.Services.BuildServiceProvider(); + } + + void Because() => _options = _services.GetRequiredService>().Get("workforce"); + + [Fact] void should_configure_no_client_secret() => _options.ClientSecret.ShouldBeNull(); + [Fact] void should_authenticate_the_code_redemption() => _options.Events.OnAuthorizationCodeReceived.ShouldNotBeNull(); + [Fact] void should_authenticate_pushed_authorization_requests() => _options.Events.OnPushAuthorization.ShouldNotBeNull(); + [Fact] void should_provide_client_assertions() => _services.GetRequiredService().ShouldBeOfExactType(); +} diff --git a/Source/AuthProxy.Specs/Authentication/for_CertificateClientAssertion/when_signing_with_an_rsa_certificate.cs b/Source/AuthProxy.Specs/Authentication/for_CertificateClientAssertion/when_signing_with_an_rsa_certificate.cs new file mode 100644 index 00000000..58b33802 --- /dev/null +++ b/Source/AuthProxy.Specs/Authentication/for_CertificateClientAssertion/when_signing_with_an_rsa_certificate.cs @@ -0,0 +1,49 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Security.Cryptography.X509Certificates; +using Cratis.AuthProxy.Authentication.given; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +namespace Cratis.AuthProxy.Authentication.for_CertificateClientAssertion; + +public class when_signing_with_an_rsa_certificate : Specification +{ + const string ClientId = "client-id"; + const string TokenEndpoint = "https://login.example.com/tenant/oauth2/v2.0/token"; + + X509Certificate2 _certificate; + DateTimeOffset _now; + JsonWebToken _assertion; + TokenValidationResult _validation; + + void Establish() + { + _certificate = ClientCertificates.Rsa(); + _now = DateTimeOffset.UtcNow; + } + + async Task Because() + { + var serialized = CertificateClientAssertion.Create(_certificate, ClientId, TokenEndpoint, _now); + _assertion = new JsonWebToken(serialized); + _validation = await new JsonWebTokenHandler().ValidateTokenAsync(serialized, new TokenValidationParameters + { + ValidIssuer = ClientId, + ValidAudience = TokenEndpoint, + IssuerSigningKey = new X509SecurityKey(_certificate) + }); + } + + void Destroy() => _certificate.Dispose(); + + [Fact] void should_carry_a_signature_the_certificate_verifies() => _validation.IsValid.ShouldBeTrue(); + [Fact] void should_sign_with_rs256() => _assertion.Alg.ShouldEqual(SecurityAlgorithms.RsaSha256); + [Fact] void should_be_issued_by_the_client() => _assertion.Issuer.ShouldEqual(ClientId); + [Fact] void should_be_about_the_client() => _assertion.Subject.ShouldEqual(ClientId); + [Fact] void should_be_addressed_to_the_token_endpoint() => _assertion.Audiences.ShouldContainOnly(TokenEndpoint); + [Fact] void should_carry_a_unique_identifier() => string.IsNullOrEmpty(_assertion.Id).ShouldBeFalse(); + [Fact] void should_name_the_certificate_by_thumbprint() => _assertion.X5t.ShouldEqual(Base64UrlEncoder.Encode(_certificate.GetCertHash())); + [Fact] void should_expire_shortly() => (_assertion.ValidTo - _assertion.IssuedAt).ShouldEqual(CertificateClientAssertion.Lifetime); +} diff --git a/Source/AuthProxy.Specs/Authentication/for_CertificateClientAssertion/when_the_certificate_has_no_private_key.cs b/Source/AuthProxy.Specs/Authentication/for_CertificateClientAssertion/when_the_certificate_has_no_private_key.cs new file mode 100644 index 00000000..59b0e9e1 --- /dev/null +++ b/Source/AuthProxy.Specs/Authentication/for_CertificateClientAssertion/when_the_certificate_has_no_private_key.cs @@ -0,0 +1,25 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Security.Cryptography.X509Certificates; +using Cratis.AuthProxy.Authentication.given; + +namespace Cratis.AuthProxy.Authentication.for_CertificateClientAssertion; + +public class when_the_certificate_has_no_private_key : Specification +{ + X509Certificate2 _certificate; + Exception _error; + + void Establish() + { + using var withKey = ClientCertificates.Rsa(); + _certificate = X509CertificateLoader.LoadCertificate(withKey.Export(X509ContentType.Cert)); + } + + void Because() => _error = Catch.Exception(() => CertificateClientAssertion.Create(_certificate, "client-id", "https://login.example.com/token", DateTimeOffset.UtcNow)); + + void Destroy() => _certificate.Dispose(); + + [Fact] void should_refuse_to_sign() => _error.ShouldBeOfExactType(); +} diff --git a/Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/given/oidc_client_assertions.cs b/Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/given/oidc_client_assertions.cs new file mode 100644 index 00000000..045af02e --- /dev/null +++ b/Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/given/oidc_client_assertions.cs @@ -0,0 +1,39 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Web; + +namespace Cratis.AuthProxy.Authentication.for_OidcClientAssertions.given; + +/// +/// Provides over the real Microsoft.Identity.Web credential loader and a scratch +/// directory for credential files. +/// +public class oidc_client_assertions : Specification +{ + protected const string Scheme = "workforce"; + protected const string TokenEndpoint = "https://login.example.com/tenant/oauth2/v2.0/token"; + + protected string _directory; + protected ICredentialsLoader _loader; + protected C.OidcProvider _provider; + protected OidcClientAssertions _assertions; + + void Establish() + { + _directory = Directory.CreateTempSubdirectory("authproxy-client-credential-").FullName; + _loader = new DefaultCredentialsLoader(NullLogger.Instance); + _provider = new() + { + Name = "Workforce", + Authority = "https://login.example.com/tenant/v2.0", + ClientId = "client-id", + ClientCredential = new() + }; + _assertions = new(_loader, TimeProvider.System, NullLogger.Instance); + } + + void Destroy() => Directory.Delete(_directory, recursive: true); +} diff --git a/Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_the_credential_cannot_be_loaded.cs b/Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_the_credential_cannot_be_loaded.cs new file mode 100644 index 00000000..48819a7d --- /dev/null +++ b/Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_the_credential_cannot_be_loaded.cs @@ -0,0 +1,20 @@ +// 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.for_OidcClientAssertions; + +public class when_the_credential_cannot_be_loaded : given.oidc_client_assertions +{ + Exception _error; + + void Establish() + { + _provider.ClientCredential!.Source = C.OidcClientCredentialSource.CertificateFile; + _provider.ClientCredential.CertificatePath = Path.Combine(_directory, "missing.pfx"); + } + + async Task Because() => _error = await Catch.Exception(() => _assertions.Create(Scheme, _provider, TokenEndpoint, CancellationToken.None)); + + [Fact] void should_fail_with_an_unavailable_credential() => _error.ShouldBeOfExactType(); + [Fact] void should_name_the_provider() => _error.Message.ShouldContain("Workforce"); +} diff --git a/Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_the_credential_is_a_certificate_file.cs b/Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_the_credential_is_a_certificate_file.cs new file mode 100644 index 00000000..fc042489 --- /dev/null +++ b/Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_the_credential_is_a_certificate_file.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. + +using System.Security.Cryptography.X509Certificates; +using Cratis.AuthProxy.Authentication.given; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +namespace Cratis.AuthProxy.Authentication.for_OidcClientAssertions; + +public class when_the_credential_is_a_certificate_file : given.oidc_client_assertions +{ + X509Certificate2 _certificate; + string _assertion; + TokenValidationResult _validation; + + void Establish() + { + _certificate = ClientCertificates.Rsa(); + var path = Path.Combine(_directory, "client.pfx"); + File.WriteAllBytes(path, _certificate.Export(X509ContentType.Pfx, "certificate-password")); + _provider.ClientCredential!.Source = C.OidcClientCredentialSource.CertificateFile; + _provider.ClientCredential.CertificatePath = path; + _provider.ClientCredential.CertificatePassword = "certificate-password"; + } + + async Task Because() + { + _assertion = await _assertions.Create(Scheme, _provider, TokenEndpoint, CancellationToken.None); + _validation = await new JsonWebTokenHandler().ValidateTokenAsync(_assertion, new TokenValidationParameters + { + ValidIssuer = _provider.ClientId, + ValidAudience = TokenEndpoint, + IssuerSigningKey = new X509SecurityKey(_certificate) + }); + } + + void Destroy() => _certificate.Dispose(); + + [Fact] void should_sign_the_assertion_with_the_certificate_from_the_file() => _validation.IsValid.ShouldBeTrue(); + [Fact] void should_address_the_assertion_to_the_token_endpoint() => new JsonWebToken(_assertion).Audiences.ShouldContainOnly(TokenEndpoint); +} diff --git a/Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_the_credential_is_a_federated_token_file.cs b/Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_the_credential_is_a_federated_token_file.cs new file mode 100644 index 00000000..bee01b3b --- /dev/null +++ b/Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_the_credential_is_a_federated_token_file.cs @@ -0,0 +1,35 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +namespace Cratis.AuthProxy.Authentication.for_OidcClientAssertions; + +public class when_the_credential_is_a_federated_token_file : given.oidc_client_assertions +{ + string _federatedToken; + string _assertion; + + void Establish() + { + // The platform's token is opaque to AuthProxy: it is read, never re-signed, so any well-formed JWT will do. + _federatedToken = new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor + { + Issuer = "https://oidc.prod-aks.azure.com/cluster", + Audience = "api://AzureADTokenExchange", + Subject = new ClaimsIdentity([new Claim("sub", "system:serviceaccount:default:authproxy")]), + Expires = DateTime.UtcNow.AddHours(1), + SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(new byte[32]), SecurityAlgorithms.HmacSha256) + }); + + var path = Path.Combine(_directory, "azure-identity-token"); + File.WriteAllText(path, _federatedToken); + _provider.ClientCredential!.Source = C.OidcClientCredentialSource.FederatedTokenFile; + _provider.ClientCredential.TokenFilePath = path; + } + + async Task Because() => _assertion = await _assertions.Create(Scheme, _provider, TokenEndpoint, CancellationToken.None); + + [Fact] void should_present_the_platform_token_as_the_assertion() => _assertion.ShouldEqual(_federatedToken); +} diff --git a/Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_the_loaded_certificate_has_expired.cs b/Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_the_loaded_certificate_has_expired.cs new file mode 100644 index 00000000..f166a9eb --- /dev/null +++ b/Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_the_loaded_certificate_has_expired.cs @@ -0,0 +1,65 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Security.Cryptography.X509Certificates; +using Cratis.AuthProxy.Authentication.given; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Identity.Abstractions; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +namespace Cratis.AuthProxy.Authentication.for_OidcClientAssertions; + +public class when_the_loaded_certificate_has_expired : Specification +{ + X509Certificate2 _expired; + X509Certificate2 _rotated; + ICredentialsLoader _loader; + C.OidcProvider _provider; + OidcClientAssertions _assertions; + JsonWebToken _assertion; + + void Establish() + { + _expired = ClientCertificates.Rsa(DateTimeOffset.UtcNow.AddDays(-30), DateTimeOffset.UtcNow.AddDays(-1)); + _rotated = ClientCertificates.Rsa(); + var loads = new Queue([_expired, _rotated]); + + _loader = Substitute.For(); + _loader + .When(_ => _.LoadCredentialsIfNeededAsync(Arg.Any(), Arg.Any())) + .Do(call => + { + var description = call.Arg(); + description.Certificate ??= loads.Dequeue(); + }); + _loader + .When(_ => _.ResetCredentials(Arg.Any>())) + .Do(call => + { + foreach (var description in call.Arg>()) + { + description.Certificate = null; + } + }); + + _provider = new() + { + Name = "Workforce", + Authority = "https://login.example.com/tenant/v2.0", + ClientId = "client-id", + ClientCredential = new() { Source = C.OidcClientCredentialSource.KeyVaultCertificate } + }; + _assertions = new(_loader, TimeProvider.System, NullLogger.Instance); + } + + async Task Because() => _assertion = new(await _assertions.Create("workforce", _provider, "https://login.example.com/token", CancellationToken.None)); + + void Destroy() + { + _expired.Dispose(); + _rotated.Dispose(); + } + + [Fact] void should_sign_with_the_rotated_certificate() => _assertion.X5t.ShouldEqual(Base64UrlEncoder.Encode(_rotated.GetCertHash())); +} diff --git a/Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_the_provider_uses_a_client_secret.cs b/Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_the_provider_uses_a_client_secret.cs new file mode 100644 index 00000000..4485ec74 --- /dev/null +++ b/Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_the_provider_uses_a_client_secret.cs @@ -0,0 +1,19 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Authentication.for_OidcClientAssertions; + +public class when_the_provider_uses_a_client_secret : given.oidc_client_assertions +{ + Exception _error; + + void Establish() + { + _provider.ClientCredential = null; + _provider.ClientSecret = "client-secret"; + } + + async Task Because() => _error = await Catch.Exception(() => _assertions.Create(Scheme, _provider, TokenEndpoint, CancellationToken.None)); + + [Fact] void should_refuse_to_create_an_assertion() => _error.ShouldBeOfExactType(); +} diff --git a/Source/AuthProxy.Specs/Authentication/for_OidcClientAuthentication/when_authenticating_a_token_request_with_an_assertion.cs b/Source/AuthProxy.Specs/Authentication/for_OidcClientAuthentication/when_authenticating_a_token_request_with_an_assertion.cs new file mode 100644 index 00000000..3b336ff1 --- /dev/null +++ b/Source/AuthProxy.Specs/Authentication/for_OidcClientAuthentication/when_authenticating_a_token_request_with_an_assertion.cs @@ -0,0 +1,41 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; + +namespace Cratis.AuthProxy.Authentication.for_OidcClientAuthentication; + +public class when_authenticating_a_token_request_with_an_assertion : Specification +{ + const string TokenEndpoint = "https://login.example.com/tenant/oauth2/v2.0/token"; + + IOidcClientAssertions _assertions; + C.OidcProvider _provider; + OpenIdConnectOptions _options; + DefaultHttpContext _httpContext; + OpenIdConnectMessage _request; + + void Establish() + { + _assertions = Substitute.For(); + _assertions.Create(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns("signed-assertion"); + _provider = new() + { + Name = "Workforce", + ClientId = "client-id", + ClientCredential = new() { Source = C.OidcClientCredentialSource.ManagedIdentity } + }; + _options = new() { Configuration = new OpenIdConnectConfiguration { TokenEndpoint = TokenEndpoint } }; + _httpContext = new() { RequestServices = new ServiceCollection().AddSingleton(_assertions).BuildServiceProvider() }; + _request = new() { ClientId = "client-id", ClientSecret = string.Empty, Code = "authorization-code" }; + } + + Task Because() => OidcClientAuthentication.Apply(_httpContext, "workforce", _provider, _options, _request); + + [Fact] void should_send_no_client_secret() => _request.Parameters.ContainsKey(OpenIdConnectParameterNames.ClientSecret).ShouldBeFalse(); + [Fact] void should_declare_a_jwt_assertion() => _request.ClientAssertionType.ShouldEqual("urn:ietf:params:oauth:client-assertion-type:jwt-bearer"); + [Fact] void should_send_the_assertion() => _request.ClientAssertion.ShouldEqual("signed-assertion"); + [Fact] void should_address_the_assertion_to_the_token_endpoint() => _assertions.Received(1).Create("workforce", _provider, TokenEndpoint, Arg.Any()); +} diff --git a/Source/AuthProxy.Specs/Authentication/for_OidcClientCredentialConfigurationValidator/given/an_oidc_client_credential_validator.cs b/Source/AuthProxy.Specs/Authentication/for_OidcClientCredentialConfigurationValidator/given/an_oidc_client_credential_validator.cs new file mode 100644 index 00000000..05a719b6 --- /dev/null +++ b/Source/AuthProxy.Specs/Authentication/for_OidcClientCredentialConfigurationValidator/given/an_oidc_client_credential_validator.cs @@ -0,0 +1,27 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Authentication.for_OidcClientCredentialConfigurationValidator.given; + +public class an_oidc_client_credential_validator : Specification +{ + protected Dictionary _environment; + protected C.OidcProvider _provider; + protected OidcClientCredentialConfigurationValidator _validator; + protected ValidateOptionsResult _result; + + void Establish() + { + _environment = []; + _provider = new() + { + Name = "Workforce", + Authority = "https://login.microsoftonline.com/tenant/v2.0", + ClientId = "client-id", + ClientCredential = new() + }; + _validator = new(_ => _environment.GetValueOrDefault(_)); + } + + protected void Validate() => _result = _validator.Validate(null, new C.Authentication { OidcProviders = [_provider] }); +} diff --git a/Source/AuthProxy.Specs/Authentication/for_OidcClientCredentialConfigurationValidator/when_a_certificate_file_credential_names_no_file.cs b/Source/AuthProxy.Specs/Authentication/for_OidcClientCredentialConfigurationValidator/when_a_certificate_file_credential_names_no_file.cs new file mode 100644 index 00000000..9d8bce58 --- /dev/null +++ b/Source/AuthProxy.Specs/Authentication/for_OidcClientCredentialConfigurationValidator/when_a_certificate_file_credential_names_no_file.cs @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Authentication.for_OidcClientCredentialConfigurationValidator; + +public class when_a_certificate_file_credential_names_no_file : given.an_oidc_client_credential_validator +{ + void Establish() => _provider.ClientCredential!.Source = C.OidcClientCredentialSource.CertificateFile; + + void Because() => Validate(); + + [Fact] void should_fail() => _result.Failed.ShouldBeTrue(); + [Fact] void should_name_the_missing_path() => _result.FailureMessage.ShouldContain("CertificatePath"); +} diff --git a/Source/AuthProxy.Specs/Authentication/for_OidcClientCredentialConfigurationValidator/when_a_certificate_store_credential_names_no_thumbprint.cs b/Source/AuthProxy.Specs/Authentication/for_OidcClientCredentialConfigurationValidator/when_a_certificate_store_credential_names_no_thumbprint.cs new file mode 100644 index 00000000..961e0d88 --- /dev/null +++ b/Source/AuthProxy.Specs/Authentication/for_OidcClientCredentialConfigurationValidator/when_a_certificate_store_credential_names_no_thumbprint.cs @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Authentication.for_OidcClientCredentialConfigurationValidator; + +public class when_a_certificate_store_credential_names_no_thumbprint : given.an_oidc_client_credential_validator +{ + void Establish() => _provider.ClientCredential!.Source = C.OidcClientCredentialSource.CertificateStore; + + void Because() => Validate(); + + [Fact] void should_fail() => _result.Failed.ShouldBeTrue(); + [Fact] void should_name_the_missing_thumbprint() => _result.FailureMessage.ShouldContain("CertificateThumbprint"); +} diff --git a/Source/AuthProxy.Specs/Authentication/for_OidcClientCredentialConfigurationValidator/when_a_federated_token_file_credential_has_no_file.cs b/Source/AuthProxy.Specs/Authentication/for_OidcClientCredentialConfigurationValidator/when_a_federated_token_file_credential_has_no_file.cs new file mode 100644 index 00000000..bb3100a4 --- /dev/null +++ b/Source/AuthProxy.Specs/Authentication/for_OidcClientCredentialConfigurationValidator/when_a_federated_token_file_credential_has_no_file.cs @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Authentication.for_OidcClientCredentialConfigurationValidator; + +public class when_a_federated_token_file_credential_has_no_file : given.an_oidc_client_credential_validator +{ + void Establish() => _provider.ClientCredential!.Source = C.OidcClientCredentialSource.FederatedTokenFile; + + void Because() => Validate(); + + [Fact] void should_fail() => _result.Failed.ShouldBeTrue(); + [Fact] void should_name_the_environment_variable() => _result.FailureMessage.ShouldContain("AZURE_FEDERATED_TOKEN_FILE"); +} diff --git a/Source/AuthProxy.Specs/Authentication/for_OidcClientCredentialConfigurationValidator/when_a_federated_token_file_credential_relies_on_the_environment.cs b/Source/AuthProxy.Specs/Authentication/for_OidcClientCredentialConfigurationValidator/when_a_federated_token_file_credential_relies_on_the_environment.cs new file mode 100644 index 00000000..44b7c3bb --- /dev/null +++ b/Source/AuthProxy.Specs/Authentication/for_OidcClientCredentialConfigurationValidator/when_a_federated_token_file_credential_relies_on_the_environment.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.for_OidcClientCredentialConfigurationValidator; + +public class when_a_federated_token_file_credential_relies_on_the_environment : given.an_oidc_client_credential_validator +{ + void Establish() + { + _provider.ClientCredential!.Source = C.OidcClientCredentialSource.FederatedTokenFile; + _environment["AZURE_FEDERATED_TOKEN_FILE"] = "/var/run/secrets/azure/tokens/azure-identity-token"; + } + + void Because() => Validate(); + + [Fact] void should_succeed() => _result.Succeeded.ShouldBeTrue(); +} diff --git a/Source/AuthProxy.Specs/Authentication/for_OidcClientCredentialConfigurationValidator/when_a_key_vault_credential_is_complete.cs b/Source/AuthProxy.Specs/Authentication/for_OidcClientCredentialConfigurationValidator/when_a_key_vault_credential_is_complete.cs new file mode 100644 index 00000000..5d8d4291 --- /dev/null +++ b/Source/AuthProxy.Specs/Authentication/for_OidcClientCredentialConfigurationValidator/when_a_key_vault_credential_is_complete.cs @@ -0,0 +1,18 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Authentication.for_OidcClientCredentialConfigurationValidator; + +public class when_a_key_vault_credential_is_complete : given.an_oidc_client_credential_validator +{ + void Establish() + { + _provider.ClientCredential!.Source = C.OidcClientCredentialSource.KeyVaultCertificate; + _provider.ClientCredential.KeyVaultUrl = "https://contoso.vault.azure.net"; + _provider.ClientCredential.KeyVaultCertificateName = "authproxy"; + } + + void Because() => Validate(); + + [Fact] void should_succeed() => _result.Succeeded.ShouldBeTrue(); +} diff --git a/Source/AuthProxy.Specs/Authentication/for_OidcClientCredentialConfigurationValidator/when_a_key_vault_credential_is_not_reached_over_https.cs b/Source/AuthProxy.Specs/Authentication/for_OidcClientCredentialConfigurationValidator/when_a_key_vault_credential_is_not_reached_over_https.cs new file mode 100644 index 00000000..2e00b364 --- /dev/null +++ b/Source/AuthProxy.Specs/Authentication/for_OidcClientCredentialConfigurationValidator/when_a_key_vault_credential_is_not_reached_over_https.cs @@ -0,0 +1,19 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Authentication.for_OidcClientCredentialConfigurationValidator; + +public class when_a_key_vault_credential_is_not_reached_over_https : given.an_oidc_client_credential_validator +{ + void Establish() + { + _provider.ClientCredential!.Source = C.OidcClientCredentialSource.KeyVaultCertificate; + _provider.ClientCredential.KeyVaultUrl = "http://contoso.vault.azure.net"; + _provider.ClientCredential.KeyVaultCertificateName = "authproxy"; + } + + void Because() => Validate(); + + [Fact] void should_fail() => _result.Failed.ShouldBeTrue(); + [Fact] void should_name_the_vault_url() => _result.FailureMessage.ShouldContain("KeyVaultUrl"); +} diff --git a/Source/AuthProxy.Specs/Authentication/for_OidcClientCredentialConfigurationValidator/when_a_managed_identity_credential_is_configured.cs b/Source/AuthProxy.Specs/Authentication/for_OidcClientCredentialConfigurationValidator/when_a_managed_identity_credential_is_configured.cs new file mode 100644 index 00000000..bfac7c1e --- /dev/null +++ b/Source/AuthProxy.Specs/Authentication/for_OidcClientCredentialConfigurationValidator/when_a_managed_identity_credential_is_configured.cs @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Authentication.for_OidcClientCredentialConfigurationValidator; + +public class when_a_managed_identity_credential_is_configured : given.an_oidc_client_credential_validator +{ + void Establish() => _provider.ClientCredential!.Source = C.OidcClientCredentialSource.ManagedIdentity; + + void Because() => Validate(); + + [Fact] void should_succeed() => _result.Succeeded.ShouldBeTrue(); +} diff --git a/Source/AuthProxy.Specs/Authentication/for_OidcClientCredentialConfigurationValidator/when_both_a_client_secret_and_a_certificate_are_configured.cs b/Source/AuthProxy.Specs/Authentication/for_OidcClientCredentialConfigurationValidator/when_both_a_client_secret_and_a_certificate_are_configured.cs new file mode 100644 index 00000000..7a7fc213 --- /dev/null +++ b/Source/AuthProxy.Specs/Authentication/for_OidcClientCredentialConfigurationValidator/when_both_a_client_secret_and_a_certificate_are_configured.cs @@ -0,0 +1,19 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Authentication.for_OidcClientCredentialConfigurationValidator; + +public class when_both_a_client_secret_and_a_certificate_are_configured : given.an_oidc_client_credential_validator +{ + void Establish() + { + _provider.ClientSecret = "client-secret"; + _provider.ClientCredential!.Source = C.OidcClientCredentialSource.CertificateFile; + _provider.ClientCredential.CertificatePath = "/certificates/client.pfx"; + } + + void Because() => Validate(); + + [Fact] void should_fail() => _result.Failed.ShouldBeTrue(); + [Fact] void should_name_both_credentials() => _result.FailureMessage.ShouldContain("ClientSecret and a CertificateFile ClientCredential"); +} diff --git a/Source/AuthProxy.Specs/Authentication/for_OidcClientCredentialConfigurationValidator/when_no_client_credential_is_configured.cs b/Source/AuthProxy.Specs/Authentication/for_OidcClientCredentialConfigurationValidator/when_no_client_credential_is_configured.cs new file mode 100644 index 00000000..40c07d18 --- /dev/null +++ b/Source/AuthProxy.Specs/Authentication/for_OidcClientCredentialConfigurationValidator/when_no_client_credential_is_configured.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.for_OidcClientCredentialConfigurationValidator; + +public class when_no_client_credential_is_configured : given.an_oidc_client_credential_validator +{ + void Establish() + { + _provider.ClientCredential = null; + _provider.ClientSecret = "client-secret"; + } + + void Because() => Validate(); + + [Fact] void should_succeed() => _result.Succeeded.ShouldBeTrue(); +} diff --git a/Source/AuthProxy.Specs/Authentication/given/ClientCertificates.cs b/Source/AuthProxy.Specs/Authentication/given/ClientCertificates.cs new file mode 100644 index 00000000..719f160f --- /dev/null +++ b/Source/AuthProxy.Specs/Authentication/given/ClientCertificates.cs @@ -0,0 +1,32 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace Cratis.AuthProxy.Authentication.given; + +/// +/// Creates self-signed client certificates for client-assertion specs. +/// +public static class ClientCertificates +{ + /// + /// Creates a self-signed RSA certificate with its private key. + /// + /// The start of the validity period. + /// The end of the validity period. + /// The certificate. + public static X509Certificate2 Rsa(DateTimeOffset notBefore, DateTimeOffset notAfter) + { + using var key = RSA.Create(2048); + var request = new CertificateRequest("CN=authproxy-client", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + return request.CreateSelfSigned(notBefore, notAfter); + } + + /// + /// Creates a self-signed RSA certificate valid around now. + /// + /// The certificate. + public static X509Certificate2 Rsa() => Rsa(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(30)); +} diff --git a/Source/AuthProxy/AuthProxy.csproj b/Source/AuthProxy/AuthProxy.csproj index 4b6a5b1e..facabc9e 100644 --- a/Source/AuthProxy/AuthProxy.csproj +++ b/Source/AuthProxy/AuthProxy.csproj @@ -11,6 +11,7 @@ + diff --git a/Source/AuthProxy/Authentication/AuthenticationServiceCollectionExtensions.cs b/Source/AuthProxy/Authentication/AuthenticationServiceCollectionExtensions.cs index 34ad42f6..53e5c493 100644 --- a/Source/AuthProxy/Authentication/AuthenticationServiceCollectionExtensions.cs +++ b/Source/AuthProxy/Authentication/AuthenticationServiceCollectionExtensions.cs @@ -11,7 +11,10 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.OAuth; using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Web; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using C = Cratis.AuthProxy.Configuration; @@ -78,6 +81,10 @@ public static WebApplicationBuilder AddIngressAuthentication(this WebApplication builder.Services.AddSingleton(); builder.Services.AddSingleton, CanonicalIdentityConfigurationValidator>(); builder.Services.AddSingleton, OAuthAuthorizationParametersConfigurationValidator>(); + builder.Services.AddSingleton, OidcClientCredentialConfigurationValidator>(); + builder.Services.TryAddSingleton(TimeProvider.System); + builder.Services.TryAddSingleton(services => new DefaultCredentialsLoader(services.GetRequiredService>())); + builder.Services.TryAddSingleton(); builder.Services.AddHttpClient(nameof(ClientCredentialsVerifier), client => client.Timeout = TimeSpan.FromSeconds(10)); if (jwtSection.Exists()) @@ -208,7 +215,7 @@ static void RegisterOidcProviders(AuthenticationBuilder authBuilder, IList capturedProvider.UsesClientAssertion + ? OidcClientAuthentication.Apply(context.HttpContext, scheme, capturedProvider, context.Options, context.TokenEndpointRequest!) + : Task.CompletedTask, + OnPushAuthorization = async context => + { + if (capturedProvider.UsesClientAssertion) + { + await OidcClientAuthentication.Apply(context.HttpContext, scheme, capturedProvider, context.Options, context.ProtocolMessage); + context.HandleClientAuthentication(); + } + }, OnRemoteFailure = RemoteAuthenticationFailureHandler.HandleRemoteFailure, OnAccessDenied = RemoteAuthenticationFailureHandler.HandleAccessDenied, OnTicketReceived = context => HandleTicketReceived( diff --git a/Source/AuthProxy/Authentication/CertificateClientAssertion.cs b/Source/AuthProxy/Authentication/CertificateClientAssertion.cs new file mode 100644 index 00000000..744bfd80 --- /dev/null +++ b/Source/AuthProxy/Authentication/CertificateClientAssertion.cs @@ -0,0 +1,61 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Security.Cryptography.X509Certificates; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +namespace Cratis.AuthProxy.Authentication; + +/// +/// Creates private_key_jwt client assertions (RFC 7523, OpenID Connect Core section 9) signed with a +/// certificate. +/// +static class CertificateClientAssertion +{ + const string RsaKeyAlgorithm = "1.2.840.113549.1.1.1"; + const string EcKeyAlgorithm = "1.2.840.10045.2.1"; + + /// + /// The lifetime of each assertion. It is presented once, immediately, so it only needs to survive clock skew. + /// + internal static TimeSpan Lifetime { get; } = TimeSpan.FromMinutes(5); + + /// + /// Creates a signed client assertion. + /// + /// The certificate whose private key signs the assertion. + /// The client ID, used as issuer and subject. + /// The endpoint the assertion is presented to. + /// The current time. + /// The serialized assertion. + /// The certificate has no usable private key. + internal static string Create(X509Certificate2 certificate, string clientId, string audience, DateTimeOffset now) + { + var signingCredentials = SigningCredentialsFor(certificate); + var descriptor = new SecurityTokenDescriptor + { + Issuer = clientId, + Audience = audience, + IssuedAt = now.UtcDateTime, + NotBefore = now.UtcDateTime, + Expires = now.Add(Lifetime).UtcDateTime, + Claims = new Dictionary + { + [JwtRegisteredClaimNames.Sub] = clientId, + [JwtRegisteredClaimNames.Jti] = Guid.NewGuid().ToString() + }, + SigningCredentials = signingCredentials + }; + + return new JsonWebTokenHandler { SetDefaultTimesOnTokenCreation = false }.CreateToken(descriptor); + } + + static X509SigningCredentials SigningCredentialsFor(X509Certificate2 certificate) => (certificate.HasPrivateKey, certificate.PublicKey.Oid.Value) switch + { + (true, RsaKeyAlgorithm) => new X509SigningCredentials(certificate, SecurityAlgorithms.RsaSha256), + (true, EcKeyAlgorithm) => new X509SigningCredentials(certificate, SecurityAlgorithms.EcdsaSha256), + _ => throw new OidcClientCredentialUnavailable( + $"The client certificate '{certificate.Subject}' has no RSA or ECDSA private key to sign a client assertion with.") + }; +} diff --git a/Source/AuthProxy/Authentication/IOidcClientAssertions.cs b/Source/AuthProxy/Authentication/IOidcClientAssertions.cs new file mode 100644 index 00000000..e6e1a859 --- /dev/null +++ b/Source/AuthProxy/Authentication/IOidcClientAssertions.cs @@ -0,0 +1,23 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using C = Cratis.AuthProxy.Configuration; + +namespace Cratis.AuthProxy.Authentication; + +/// +/// Defines a system that creates the client_assertion an OIDC provider authenticates AuthProxy with. +/// +public interface IOidcClientAssertions +{ + /// + /// Creates a client assertion for one request to the provider. + /// + /// The authentication scheme of the provider; the loaded credential is kept per scheme. + /// The provider registration. Its client credential must use a client assertion. + /// The endpoint the assertion is presented to, normally the provider's token endpoint. + /// The for the operation. + /// The serialized client assertion. + /// The credential could not be loaded or produced no assertion. + Task Create(string scheme, C.OidcProvider provider, string audience, CancellationToken cancellationToken); +} diff --git a/Source/AuthProxy/Authentication/OidcClientAssertions.cs b/Source/AuthProxy/Authentication/OidcClientAssertions.cs new file mode 100644 index 00000000..ad50b96d --- /dev/null +++ b/Source/AuthProxy/Authentication/OidcClientAssertions.cs @@ -0,0 +1,95 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Concurrent; +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Client; +using Microsoft.Identity.Web; +using C = Cratis.AuthProxy.Configuration; + +namespace Cratis.AuthProxy.Authentication; + +/// +/// Represents an implementation of built on the Microsoft.Identity.Web credential +/// loaders. +/// +/// +/// A certificate is loaded once per provider and kept until it expires; each request gets a freshly signed, +/// short-lived assertion. Federated sources (workload identity token file, managed identity) are kept as the loader's +/// assertion provider, which caches the platform token and fetches a new one before it expires. +/// +/// The that loads certificates and federated assertion providers. +/// The stamping the assertions. +/// The for diagnostics. +public sealed class OidcClientAssertions( + ICredentialsLoader loader, + TimeProvider timeProvider, + ILogger logger) : IOidcClientAssertions +{ + readonly ConcurrentDictionary _descriptions = new(StringComparer.Ordinal); + + /// + public async Task Create(string scheme, C.OidcProvider provider, string audience, CancellationToken cancellationToken) + { + if (provider.ClientCredential is not { UsesClientAssertion: true } credential) + { + throw new OidcClientCredentialUnavailable($"The OIDC provider '{provider.Name}' is not configured with a client-assertion credential."); + } + + var description = _descriptions.GetOrAdd(scheme, static (_, configured) => OidcClientCredentialDescription.From(configured), credential); + await Load(description, provider); + + var now = timeProvider.GetUtcNow(); + if (description.Certificate is not null && description.Certificate.NotAfter.ToUniversalTime() <= now.UtcDateTime) + { + // A rotated certificate replaces the expired one in its file, store or vault; load it again rather than + // signing with a certificate the provider will refuse. + logger.ClientCertificateExpired(provider.Name); + loader.ResetCredentials([description]); + await Load(description, provider); + } + + if (description.Certificate is { } certificate) + { + return CertificateClientAssertion.Create(certificate, provider.ClientId, audience, now); + } + + if (description.CachedValue is ClientAssertionProviderBase assertionProvider) + { + try + { + return await assertionProvider.GetSignedAssertionAsync(new AssertionRequestOptions + { + ClientID = provider.ClientId, + TokenEndpoint = audience, + CancellationToken = cancellationToken + }); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + logger.ClientCredentialUnavailable(provider.Name, credential.Source.ToString(), exception); + throw new OidcClientCredentialUnavailable( + $"The {credential.Source} client credential of OIDC provider '{provider.Name}' produced no client assertion.", + exception); + } + } + + throw new OidcClientCredentialUnavailable( + $"The {credential.Source} client credential of OIDC provider '{provider.Name}' could not be loaded."); + } + + async Task Load(CredentialDescription description, C.OidcProvider provider) + { + try + { + await loader.LoadCredentialsIfNeededAsync(description, new CredentialSourceLoaderParameters(provider.ClientId, provider.Authority)); + } + catch (Exception exception) + { + logger.ClientCredentialUnavailable(provider.Name, provider.ClientCredential!.Source.ToString(), exception); + throw new OidcClientCredentialUnavailable( + $"The {provider.ClientCredential.Source} client credential of OIDC provider '{provider.Name}' could not be loaded.", + exception); + } + } +} diff --git a/Source/AuthProxy/Authentication/OidcClientAssertionsLogging.cs b/Source/AuthProxy/Authentication/OidcClientAssertionsLogging.cs new file mode 100644 index 00000000..13a349fe --- /dev/null +++ b/Source/AuthProxy/Authentication/OidcClientAssertionsLogging.cs @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Authentication; + +internal static partial class OidcClientAssertionsLogging +{ + [LoggerMessage(LogLevel.Error, "The {Source} client credential of OIDC provider {Provider} is unavailable; the provider's token requests will fail")] + internal static partial void ClientCredentialUnavailable(this ILogger logger, string provider, string source, Exception exception); + + [LoggerMessage(LogLevel.Warning, "The client certificate of OIDC provider {Provider} has expired; loading it again")] + internal static partial void ClientCertificateExpired(this ILogger logger, string provider); +} diff --git a/Source/AuthProxy/Authentication/OidcClientAuthentication.cs b/Source/AuthProxy/Authentication/OidcClientAuthentication.cs new file mode 100644 index 00000000..63a03de7 --- /dev/null +++ b/Source/AuthProxy/Authentication/OidcClientAuthentication.cs @@ -0,0 +1,64 @@ +// 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.Authentication.OpenIdConnect; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using C = Cratis.AuthProxy.Configuration; + +namespace Cratis.AuthProxy.Authentication; + +/// +/// Authenticates AuthProxy's own requests to an OIDC provider with a client_assertion when the provider is +/// configured with a certificate or federated credential. +/// +static class OidcClientAuthentication +{ + /// + /// The client_assertion_type of a JWT client assertion (RFC 7523). + /// + internal const string JwtBearerAssertionType = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; + + /// + /// Replaces any client secret on a request to the provider with a client assertion. + /// + /// The current . + /// The provider's authentication scheme. + /// The provider registration. + /// The provider's effective handler options, used to discover its token endpoint. + /// The request to the provider. + /// A representing the asynchronous operation. + /// No assertion could be created. + internal static async Task Apply( + HttpContext httpContext, + string scheme, + C.OidcProvider provider, + OpenIdConnectOptions options, + OpenIdConnectMessage message) + { + var audience = await TokenEndpointOf(options, httpContext.RequestAborted); + var assertions = httpContext.RequestServices.GetRequiredService(); + var assertion = await assertions.Create(scheme, provider, audience, httpContext.RequestAborted); + + message.ClientSecret = null; + message.ClientAssertionType = JwtBearerAssertionType; + message.ClientAssertion = assertion; + } + + /// + /// Resolves the provider's token endpoint, which is the audience of every client assertion (OpenID Connect Core + /// section 9). + /// + /// The provider's effective handler options. + /// The for the operation. + /// The token endpoint. + /// The provider metadata names no token endpoint. + internal static async Task TokenEndpointOf(OpenIdConnectOptions options, CancellationToken cancellationToken) + { + var configuration = options.Configuration + ?? (options.ConfigurationManager is null ? null : await options.ConfigurationManager.GetConfigurationAsync(cancellationToken)); + + return string.IsNullOrEmpty(configuration?.TokenEndpoint) + ? throw new OidcClientCredentialUnavailable($"The OIDC provider at '{options.Authority}' publishes no token endpoint to present a client assertion to.") + : configuration.TokenEndpoint; + } +} diff --git a/Source/AuthProxy/Authentication/OidcClientCredentialConfigurationValidator.cs b/Source/AuthProxy/Authentication/OidcClientCredentialConfigurationValidator.cs new file mode 100644 index 00000000..375fcc46 --- /dev/null +++ b/Source/AuthProxy/Authentication/OidcClientCredentialConfigurationValidator.cs @@ -0,0 +1,83 @@ +// 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; + +/// +/// Validates the client credential of every OIDC provider at startup, so a credential that cannot work stops the host +/// with a clear message instead of failing the first sign-in. +/// +/// Reads an environment variable; replaceable so the federated token default can be specified. +public sealed class OidcClientCredentialConfigurationValidator(Func environment) : IValidateOptions +{ + /// + /// The environment variable naming the federated token file on Kubernetes workload identity. + /// + public const string FederatedTokenFileEnvironmentVariable = "AZURE_FEDERATED_TOKEN_FILE"; + + /// + /// Initializes a new instance of the class reading the + /// process environment. + /// + public OidcClientCredentialConfigurationValidator() + : this(Environment.GetEnvironmentVariable) + { + } + + /// + /// Validates the client credentials of the configured OIDC providers. + /// + /// The options instance name. Validation applies identically to every name. + /// The authentication provider configuration to validate. + /// A successful result when every credential is usable; otherwise, every problem found. + public ValidateOptionsResult Validate(string? name, C.Authentication options) + { + var failures = options.OidcProviders + .Where(_ => _.UsesClientAssertion) + .SelectMany(_ => Problems(_, _.ClientCredential!).Select(problem => $"OIDC provider '{_.Name}': {problem}")) + .ToArray(); + + return failures.Length == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(failures); + } + + IEnumerable Problems(C.OidcProvider provider, C.OidcClientCredential credential) + { + if (!string.IsNullOrEmpty(provider.ClientSecret)) + { + yield return $"ClientSecret and a {credential.Source} ClientCredential are both configured. Configure only one credential."; + } + + switch (credential.Source) + { + case C.OidcClientCredentialSource.CertificateFile when string.IsNullOrWhiteSpace(credential.CertificatePath): + yield return "ClientCredential.CertificatePath is required for a CertificateFile credential."; + break; + + case C.OidcClientCredentialSource.CertificateStore when string.IsNullOrWhiteSpace(credential.CertificateThumbprint): + yield return "ClientCredential.CertificateThumbprint is required for a CertificateStore credential."; + break; + + case C.OidcClientCredentialSource.KeyVaultCertificate: + if (!Uri.TryCreate(credential.KeyVaultUrl, UriKind.Absolute, out var vault) || vault.Scheme != Uri.UriSchemeHttps) + { + yield return "ClientCredential.KeyVaultUrl must be an absolute https URL for a KeyVaultCertificate credential."; + } + + if (string.IsNullOrWhiteSpace(credential.KeyVaultCertificateName)) + { + yield return "ClientCredential.KeyVaultCertificateName is required for a KeyVaultCertificate credential."; + } + + break; + + case C.OidcClientCredentialSource.FederatedTokenFile + when string.IsNullOrWhiteSpace(credential.TokenFilePath) + && string.IsNullOrWhiteSpace(environment(FederatedTokenFileEnvironmentVariable)): + yield return $"A FederatedTokenFile credential needs ClientCredential.TokenFilePath or the {FederatedTokenFileEnvironmentVariable} environment variable."; + break; + } + } +} diff --git a/Source/AuthProxy/Authentication/OidcClientCredentialDescription.cs b/Source/AuthProxy/Authentication/OidcClientCredentialDescription.cs new file mode 100644 index 00000000..8af28d29 --- /dev/null +++ b/Source/AuthProxy/Authentication/OidcClientCredentialDescription.cs @@ -0,0 +1,60 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Identity.Abstractions; +using C = Cratis.AuthProxy.Configuration; + +namespace Cratis.AuthProxy.Authentication; + +/// +/// Maps an AuthProxy onto the model of +/// Microsoft.Identity.Web, whose credential loaders do the platform work (certificate stores, Key Vault, workload +/// identity token files and managed identity). +/// +static class OidcClientCredentialDescription +{ + /// + /// Creates the credential description for a client-assertion credential. + /// + /// The configured credential. Its source must use a client assertion. + /// The the credential loader loads. + /// The source does not use a client assertion. + internal static CredentialDescription From(C.OidcClientCredential credential) => credential.Source switch + { + C.OidcClientCredentialSource.CertificateFile => new() + { + SourceType = CredentialSource.Path, + CertificateDiskPath = credential.CertificatePath, + CertificatePassword = NullIfEmpty(credential.CertificatePassword) + }, + C.OidcClientCredentialSource.CertificateStore => new() + { + SourceType = CredentialSource.StoreWithThumbprint, + CertificateStorePath = string.IsNullOrWhiteSpace(credential.CertificateStorePath) + ? C.OidcClientCredential.DefaultCertificateStorePath + : credential.CertificateStorePath, + CertificateThumbprint = credential.CertificateThumbprint + }, + C.OidcClientCredentialSource.KeyVaultCertificate => new() + { + SourceType = CredentialSource.KeyVault, + KeyVaultUrl = credential.KeyVaultUrl, + KeyVaultCertificateName = credential.KeyVaultCertificateName, + ManagedIdentityClientId = NullIfEmpty(credential.ManagedIdentityClientId) + }, + C.OidcClientCredentialSource.FederatedTokenFile => new() + { + SourceType = CredentialSource.SignedAssertionFilePath, + SignedAssertionFileDiskPath = NullIfEmpty(credential.TokenFilePath) + }, + C.OidcClientCredentialSource.ManagedIdentity => new() + { + SourceType = CredentialSource.SignedAssertionFromManagedIdentity, + ManagedIdentityClientId = NullIfEmpty(credential.ManagedIdentityClientId), + TokenExchangeUrl = NullIfEmpty(credential.TokenExchangeAudience) + }, + _ => throw new OidcClientCredentialUnavailable($"The client credential source '{credential.Source}' does not use a client assertion.") + }; + + static string? NullIfEmpty(string value) => string.IsNullOrWhiteSpace(value) ? null : value; +} diff --git a/Source/AuthProxy/Authentication/OidcClientCredentialUnavailable.cs b/Source/AuthProxy/Authentication/OidcClientCredentialUnavailable.cs new file mode 100644 index 00000000..054cd98a --- /dev/null +++ b/Source/AuthProxy/Authentication/OidcClientCredentialUnavailable.cs @@ -0,0 +1,12 @@ +// 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 exception that is thrown when the client credential configured for an OIDC provider cannot be loaded or +/// cannot produce a client assertion. +/// +/// The message describing why the credential is unavailable. +/// The underlying failure, when there is one. +public class OidcClientCredentialUnavailable(string message, Exception? innerException = null) : Exception(message, innerException); diff --git a/Source/AuthProxy/Configuration/OidcClientCredential.cs b/Source/AuthProxy/Configuration/OidcClientCredential.cs new file mode 100644 index 00000000..71d87ad6 --- /dev/null +++ b/Source/AuthProxy/Configuration/OidcClientCredential.cs @@ -0,0 +1,89 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Configuration; + +/// +/// Represents the credential AuthProxy presents to an OIDC provider's token endpoint instead of a client secret. +/// +/// +/// Every source other than authenticates with a +/// client_assertion (RFC 7523): either a JWT AuthProxy signs with a certificate, or a token the +/// platform issues (workload identity federation, managed identity). Only the properties for the selected +/// are read. +/// +public class OidcClientCredential +{ + /// + /// The default certificate store path searched for . + /// + public const string DefaultCertificateStorePath = "CurrentUser/My"; + + /// + /// Gets or sets where the credential comes from. Defaults to . + /// + public OidcClientCredentialSource Source { get; set; } = OidcClientCredentialSource.ClientSecret; + + /// + /// Gets or sets the path of the PKCS#12 (.pfx) file holding the certificate and its private key, + /// for . + /// + public string CertificatePath { get; set; } = string.Empty; + + /// + /// Gets or sets the password protecting the certificate file, when it has one. + /// + public string CertificatePassword { get; set; } = string.Empty; + + /// + /// Gets or sets the thumbprint of the certificate to find, for . + /// + public string CertificateThumbprint { get; set; } = string.Empty; + + /// + /// Gets or sets the certificate store to search, as StoreLocation/StoreName, for + /// . Defaults to . + /// + public string CertificateStorePath { get; set; } = DefaultCertificateStorePath; + + /// + /// Gets or sets the URL of the Azure Key Vault holding the certificate, for + /// . + /// + public string KeyVaultUrl { get; set; } = string.Empty; + + /// + /// Gets or sets the name of the certificate in Azure Key Vault, for + /// . + /// + public string KeyVaultCertificateName { get; set; } = string.Empty; + + /// + /// Gets or sets the path of the file holding the federated token, for + /// . When empty, the file named by the + /// AZURE_FEDERATED_TOKEN_FILE environment variable is used. The file is re-read when the + /// token it held expires, so a platform that rotates it is followed. + /// + public string TokenFilePath { get; set; } = string.Empty; + + /// + /// Gets or sets the client ID of a user-assigned managed identity. Used by + /// to select the identity, and by + /// to authenticate to Key Vault. When empty, the + /// system-assigned identity (or, for Key Vault, the default Azure credential chain) is used. + /// + public string ManagedIdentityClientId { get; set; } = string.Empty; + + /// + /// Gets or sets the audience of the managed identity token, for . + /// When empty, the audience is resolved from the provider authority: api://AzureADTokenExchange for the + /// public cloud, and the matching value for national clouds. + /// + public string TokenExchangeAudience { get; set; } = string.Empty; + + /// + /// Gets a value indicating whether the credential is presented as a client_assertion rather than a + /// client secret. + /// + public bool UsesClientAssertion => Source != OidcClientCredentialSource.ClientSecret; +} diff --git a/Source/AuthProxy/Configuration/OidcClientCredentialSource.cs b/Source/AuthProxy/Configuration/OidcClientCredentialSource.cs new file mode 100644 index 00000000..08022c08 --- /dev/null +++ b/Source/AuthProxy/Configuration/OidcClientCredentialSource.cs @@ -0,0 +1,46 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Configuration; + +/// +/// Defines how AuthProxy authenticates itself to an OIDC provider's token endpoint. +/// +public enum OidcClientCredentialSource +{ + /// + /// The provider's is sent as client_secret. + /// This is the default and the behavior of providers that configure no client credential. + /// + ClientSecret = 0, + + /// + /// A certificate with a private key, loaded from a PKCS#12 (.pfx) file, signs a + /// private_key_jwt client assertion. + /// + CertificateFile = 1, + + /// + /// A certificate with a private key, found by thumbprint in an operating-system certificate store, signs a + /// private_key_jwt client assertion. + /// + CertificateStore = 2, + + /// + /// A certificate with a private key, downloaded from Azure Key Vault, signs a + /// private_key_jwt client assertion. + /// + KeyVaultCertificate = 3, + + /// + /// A platform-issued federated token read from a file (Kubernetes workload identity, by default the file named + /// by AZURE_FEDERATED_TOKEN_FILE) is sent as the client assertion. + /// + FederatedTokenFile = 4, + + /// + /// An Azure managed identity token for the token-exchange audience is sent as the client assertion, so no + /// secret or certificate exists to rotate. + /// + ManagedIdentity = 5, +} diff --git a/Source/AuthProxy/Configuration/OidcProvider.cs b/Source/AuthProxy/Configuration/OidcProvider.cs index 31094ae4..a3f0a0d5 100644 --- a/Source/AuthProxy/Configuration/OidcProvider.cs +++ b/Source/AuthProxy/Configuration/OidcProvider.cs @@ -37,8 +37,24 @@ public class OidcProvider /// /// Gets or sets the OAuth client secret. /// + /// + /// Leave empty when selects a certificate or federated credential. + /// public string ClientSecret { get; set; } = string.Empty; + /// + /// Gets or sets the credential AuthProxy presents to the provider's token endpoint instead of + /// : a certificate (file, certificate store or Azure Key Vault) or a federated + /// credential (workload identity token file or Azure managed identity). + /// When absent, is used. + /// + public OidcClientCredential? ClientCredential { get; set; } + + /// + /// Gets a value indicating whether the provider authenticates to its token endpoint with a client assertion. + /// + public bool UsesClientAssertion => ClientCredential?.UsesClientAssertion == true; + /// /// Gets or sets extra OAuth scopes to request (in addition to openid profile email). /// From 5cd592b2b4d906b0da2cf27cd3e9bd5afb033ab0 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 1 Oct 2026 14:03:20 +0200 Subject: [PATCH 02/13] Forward a per-audience access token for the signed-in user to backends A service can declare AccessToken scopes (or an RFC 8707 resource). AuthProxy keeps the refresh token issued at sign-in server-side, keyed by an unguessable reference inside the encrypted session cookie, redeems it at the provider's token endpoint for the service's audience (client secret or client assertion), caches the access token per session and audience until shortly before expiry, and forwards it as Authorization: Bearer on requests to the service's backend. Requests that cannot get a token are refused with 401. --- Documentation/configuration/services.md | 91 +++++++ ...backend_receives_the_users_access_token.cs | 55 ++++ .../given/AccessTokenForwardingHarness.cs | 173 +++++++++++++ .../AccessTokenForwardingSpecCollection.cs | 14 ++ .../given/an_access_token_validator.cs | 28 +++ .../when_no_oidc_provider_is_configured.cs | 13 + ...when_the_access_token_names_an_audience.cs | 13 + ..._access_token_names_an_unknown_provider.cs | 13 + ...when_the_access_token_names_no_audience.cs | 13 + .../when_the_service_has_no_backend.cs | 13 + .../given/a_forwarding_middleware.cs | 81 ++++++ ..._authenticated_caller_calls_the_backend.cs | 15 ++ ...when_a_signed_in_user_calls_the_backend.cs | 12 + .../when_no_token_can_be_obtained.cs | 14 ++ .../when_the_request_goes_to_the_frontend.cs | 14 ++ ...hen_the_request_is_on_an_anonymous_path.cs | 14 ++ ...when_the_session_holds_no_token_session.cs | 14 ++ .../given/user_access_tokens.cs | 54 ++++ .../when_obtaining_a_token_for_an_audience.cs | 25 ++ ...hen_the_cached_token_is_about_to_expire.cs | 22 ++ ...provider_answers_without_a_bearer_token.cs | 19 ++ ...r_authenticates_with_a_client_assertion.cs | 20 ++ .../when_the_provider_cannot_be_reached.cs | 20 ++ ..._the_provider_rejects_the_refresh_token.cs | 22 ++ ..._the_provider_rotates_the_refresh_token.cs | 19 ++ ...n_the_service_requires_another_provider.cs | 18 ++ ...when_the_session_holds_no_refresh_token.cs | 14 ++ .../when_two_audiences_are_requested.cs | 24 ++ .../given/a_sign_in_redeeming_its_code.cs | 53 ++++ .../when_a_link_callback_redeems_its_code.cs | 13 + .../when_a_session_is_signed_out.cs | 35 +++ .../when_a_sign_in_redeems_its_code.cs | 15 ++ .../when_no_service_forwards_user_tokens.cs | 14 ++ ...en_the_provider_issues_no_refresh_token.cs | 13 + ...a_session_is_read_with_another_key_ring.cs | 23 ++ .../when_a_session_is_removed.cs | 27 ++ .../when_a_session_is_stored.cs | 29 +++ ...cess_token_arrives_for_an_ended_session.cs | 17 ++ .../AccessTokens/given/ManualTime.cs | 22 ++ .../given/RecordingDistributedCache.cs | 53 ++++ .../AccessTokens/given/TokenEndpoint.cs | 60 +++++ .../AccessTokens/given/a_user_token_store.cs | 25 ++ .../when_registering_user_token_capture.cs | 37 +++ .../when_building_clusters.cs | 38 +++ .../AccessTokenConfigurationValidator.cs | 51 ++++ .../AccessTokenForwardingMiddleware.cs | 95 +++++++ .../AccessTokenForwardingMiddlewareLogging.cs | 10 + .../AccessTokens/CachedUserAccessToken.cs | 11 + .../AccessTokens/IUserAccessTokens.cs | 21 ++ .../AuthProxy/AccessTokens/IUserTokenStore.cs | 67 +++++ .../AccessTokens/UserAccessTokenFailure.cs | 36 +++ .../AccessTokens/UserAccessTokenResult.cs | 31 +++ .../AccessTokens/UserAccessTokens.cs | 236 ++++++++++++++++++ .../AccessTokens/UserAccessTokensLogging.cs | 19 ++ .../AccessTokens/UserTokenSession.cs | 11 + .../AccessTokens/UserTokenSessions.cs | 106 ++++++++ .../AccessTokens/UserTokenSessionsLogging.cs | 10 + .../AuthProxy/AccessTokens/UserTokenStore.cs | 141 +++++++++++ ...thenticationServiceCollectionExtensions.cs | 23 ++ Source/AuthProxy/Configuration/Service.cs | 11 + .../Configuration/ServiceAccessToken.cs | 35 +++ .../MicroserviceReverseProxyConfigProvider.cs | 28 +++ .../ReverseProxy/ReverseProxyExtensions.cs | 20 +- 63 files changed, 2282 insertions(+), 1 deletion(-) create mode 100644 Source/AuthProxy.Security.Specs/for_AccessTokenForwarding/when_a_backend_receives_the_users_access_token.cs create mode 100644 Source/AuthProxy.Security.Specs/given/AccessTokenForwardingHarness.cs create mode 100644 Source/AuthProxy.Security.Specs/given/AccessTokenForwardingSpecCollection.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_AccessTokenConfigurationValidator/given/an_access_token_validator.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_AccessTokenConfigurationValidator/when_no_oidc_provider_is_configured.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_AccessTokenConfigurationValidator/when_the_access_token_names_an_audience.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_AccessTokenConfigurationValidator/when_the_access_token_names_an_unknown_provider.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_AccessTokenConfigurationValidator/when_the_access_token_names_no_audience.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_AccessTokenConfigurationValidator/when_the_service_has_no_backend.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/given/a_forwarding_middleware.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_a_bearer_authenticated_caller_calls_the_backend.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_a_signed_in_user_calls_the_backend.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_no_token_can_be_obtained.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_the_request_goes_to_the_frontend.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_the_request_is_on_an_anonymous_path.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_the_session_holds_no_token_session.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/given/user_access_tokens.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_obtaining_a_token_for_an_audience.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_cached_token_is_about_to_expire.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_provider_answers_without_a_bearer_token.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_provider_authenticates_with_a_client_assertion.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_provider_cannot_be_reached.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_provider_rejects_the_refresh_token.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_provider_rotates_the_refresh_token.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_service_requires_another_provider.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_session_holds_no_refresh_token.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_two_audiences_are_requested.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/given/a_sign_in_redeeming_its_code.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_link_callback_redeems_its_code.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_session_is_signed_out.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_sign_in_redeems_its_code.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_no_service_forwards_user_tokens.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_the_provider_issues_no_refresh_token.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_a_session_is_read_with_another_key_ring.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_a_session_is_removed.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_a_session_is_stored.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_an_access_token_arrives_for_an_ended_session.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/given/ManualTime.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/given/RecordingDistributedCache.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/given/TokenEndpoint.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/given/a_user_token_store.cs create mode 100644 Source/AuthProxy.Specs/Authentication/for_AuthenticationServiceCollectionExtensions/when_registering_user_token_capture.cs create mode 100644 Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_building_clusters.cs create mode 100644 Source/AuthProxy/AccessTokens/AccessTokenConfigurationValidator.cs create mode 100644 Source/AuthProxy/AccessTokens/AccessTokenForwardingMiddleware.cs create mode 100644 Source/AuthProxy/AccessTokens/AccessTokenForwardingMiddlewareLogging.cs create mode 100644 Source/AuthProxy/AccessTokens/CachedUserAccessToken.cs create mode 100644 Source/AuthProxy/AccessTokens/IUserAccessTokens.cs create mode 100644 Source/AuthProxy/AccessTokens/IUserTokenStore.cs create mode 100644 Source/AuthProxy/AccessTokens/UserAccessTokenFailure.cs create mode 100644 Source/AuthProxy/AccessTokens/UserAccessTokenResult.cs create mode 100644 Source/AuthProxy/AccessTokens/UserAccessTokens.cs create mode 100644 Source/AuthProxy/AccessTokens/UserAccessTokensLogging.cs create mode 100644 Source/AuthProxy/AccessTokens/UserTokenSession.cs create mode 100644 Source/AuthProxy/AccessTokens/UserTokenSessions.cs create mode 100644 Source/AuthProxy/AccessTokens/UserTokenSessionsLogging.cs create mode 100644 Source/AuthProxy/AccessTokens/UserTokenStore.cs create mode 100644 Source/AuthProxy/Configuration/ServiceAccessToken.cs diff --git a/Documentation/configuration/services.md b/Documentation/configuration/services.md index dd2a4fa3..95ee7c7c 100644 --- a/Documentation/configuration/services.md +++ b/Documentation/configuration/services.md @@ -42,6 +42,7 @@ Services are configured under `Cratis:AuthProxy:Services`, keyed by a friendly n | `IdentityVerificationTimeout` | `TimeSpan` | `00:00:10` under `Required`, unbounded under `BestEffort` | How long to wait for the answer. Zero or negative leaves the wait unbounded. See [Two settings, two questions](#two-settings-two-questions). | | `AnonymousPaths` | `string[]` | `[]` | Path prefixes on this service served to unauthenticated callers. See [Anonymous paths](#anonymous-paths). | | `ClientCredentials` | `ServiceClientCredentialsConfig` | `null` | Enables back-channel client-credentials verification and token minting for this service. | +| `AccessToken` | `ServiceAccessTokenConfig` | `null` | Forwards the signed-in user's access token for this service's audience to its backend. See [Forwarding the user's access token](#forwarding-the-users-access-token). | ### ServiceEndpointConfig properties @@ -396,3 +397,93 @@ The verification endpoint's response can optionally include a `tenant` property, carries on the issued tokens and can resolve into the `Tenant-ID` header on proxied requests. See [Back-channel client credentials](authentication.md#back-channel-client-credentials) for the full token, tenant-resolution, and refresh-token flow. + +--- + +## Forwarding the user's access token + +By default a backend learns who the user is from the identity headers only. A backend that has to call another +API on the user's behalf, such as Microsoft Graph or a downstream domain API through the on-behalf-of flow, +needs a real access token issued for it. With `AccessToken`, AuthProxy works as a backend for frontend (BFF). +It obtains an access token for the backend's audience for the signed-in user and forwards it as +`Authorization: Bearer `: + +```json +{ + "Cratis": { + "AuthProxy": { + "Services": { + "reporting": { + "Backend": { "BaseUrl": "http://reporting-api:8080/" }, + "Frontend": { "BaseUrl": "http://reporting-web:3000/" }, + "AccessToken": { + "Scopes": [ "api://reporting/access_as_user" ] + } + } + }, + "Authentication": { + "OidcProviders": [ + { + "Name": "Microsoft", + "Authority": "https://login.microsoftonline.com//v2.0", + "ClientId": "", + "ClientSecret": "", + "Scopes": [ "offline_access" ] + } + ] + } + } + } +} +``` + +The backend then validates an ordinary JWT. Its audience is the backend's own app registration, so the +backend can exchange it for downstream tokens without signing anyone in itself. + +### ServiceAccessTokenConfig properties + +| Property | Type | Description | +|----------|------|-------------| +| `Scopes` | `string[]` | Scopes to request for the backend's audience, for example `api://reporting/access_as_user` (Microsoft Entra ID). | +| `Resource` | `string` | Optional resource indicator ([RFC 8707](https://www.rfc-editor.org/rfc/rfc8707)) for identity providers that select the audience with `resource`. | +| `Provider` | `string` | Optional OIDC provider name. When set, only users who signed in with that provider get a token. Everyone else is refused. | + +At least one of `Scopes` or `Resource` is required, the service needs a `Backend`, and at least one OIDC +provider must be configured. AuthProxy refuses to start otherwise. + +### How the token is obtained + +- At sign-in, AuthProxy keeps the refresh token the OIDC provider issues **server-side**. The session cookie + carries only an unguessable reference to it, inside its encrypted ticket. The refresh token, the access + tokens and the ID token never reach the browser. +- For each request to the backend, AuthProxy uses that refresh token at the provider's token endpoint + (`grant_type=refresh_token`) to get a token for the service's scopes. The token is cached per session and + audience, and renewed shortly before it expires. AuthProxy authenticates to the token endpoint with the + provider's `ClientSecret` or [client credential](authentication.md#client-credentials-certificates-and-federated-credentials), + and stores a rotated refresh token when the provider issues one. +- Request `offline_access` (or your provider's equivalent) in the provider's `Scopes`. Without a refresh + token AuthProxy cannot get access tokens, and logs a warning at each such sign-in. +- Signing out removes the refresh token and every access token kept for the session. + +### What is forwarded, and when it is refused + +- Only requests that are authenticated by the AuthProxy session and routed to the service's `Backend` get a + token. The token replaces any `Authorization` header the browser sent. +- Requests to the `Frontend`, requests on [anonymous paths](#anonymous-paths), and machine callers that + authenticate with their own bearer token ([client credentials](#client-credentials) or JWT bearer) are + forwarded as before. +- When no token can be obtained, the request is refused with `401` instead of being forwarded without one. + This happens when the session has no refresh token, the provider rejects the refresh token, the provider + cannot be reached, or the user signed in with another provider than `Provider`. A rejected refresh token + is discarded. The frontend should treat the `401` as a signal to sign in again through + `/.cratis/login/{scheme}`. + +### Running more than one instance + +Refresh and access tokens are kept in AuthProxy's memory, encrypted with its +[Data Protection keys](authentication.md#data-protection-keys-and-horizontal-scaling). They do not survive a +restart and are not shared between replicas. With several replicas, route each session to the same replica +(sticky sessions). Otherwise a request that lands on another replica is refused with `401` until the user +signs in again. Sessions that began before `AccessToken` was configured hold no refresh token either, so +their users sign in again once. + diff --git a/Source/AuthProxy.Security.Specs/for_AccessTokenForwarding/when_a_backend_receives_the_users_access_token.cs b/Source/AuthProxy.Security.Specs/for_AccessTokenForwarding/when_a_backend_receives_the_users_access_token.cs new file mode 100644 index 00000000..f3d77fb0 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_AccessTokenForwarding/when_a_backend_receives_the_users_access_token.cs @@ -0,0 +1,55 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_AccessTokenForwarding; + +/// +/// OWASP A01/A07. A backend that accepts the user's access token from AuthProxy must receive the token AuthProxy +/// obtained, never a value the browser sent, and must receive nothing at all when no token could be obtained. The +/// frontend never needs a token and never gets one. +/// +/// The running proxy and its origins. +[Collection(AccessTokenForwardingSpecCollection.Name)] +public class when_a_backend_receives_the_users_access_token(AccessTokenForwardingHarness harness) : IAsyncLifetime +{ + ForwardedRequest? _backendRequest; + ForwardedRequest? _frontendRequest; + HttpResponseMessage? _rejected; + bool _backendSawTheRejectedSession; + + public async Task InitializeAsync() + { + using var client = harness.CreateSecurityClient(); + + harness.ClearOrigins(); + var request = AccessTokenForwardingHarness.FromSession("/api/orders", "session-one"); + request.Headers.TryAddWithoutValidation("Authorization", "Bearer forged-by-the-browser"); + await client.SendAsync(request); + _backendRequest = harness.Backend.LastRequestTo("/api/orders"); + + await client.SendAsync(AccessTokenForwardingHarness.FromSession("/dashboard", "session-one")); + _frontendRequest = harness.Frontend.LastRequestTo("/dashboard"); + + harness.ClearOrigins(); + _rejected = await client.SendAsync(AccessTokenForwardingHarness.FromSession("/api/orders", AccessTokenForwardingHarness.RejectedSession)); + _backendSawTheRejectedSession = harness.Backend.ReceivedAnythingFor("/api/orders"); + } + + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] + public void should_forward_the_users_access_token_to_the_backend() => + Assert.Equal($"Bearer {AccessTokenForwardingHarness.TokenFor("session-one")}", _backendRequest!.Value("Authorization")); + + [Fact] + public void should_not_forward_an_authorization_header_to_the_frontend() => + Assert.False(_frontendRequest!.Has("Authorization")); + + [Fact] + public void should_refuse_a_session_with_no_obtainable_token() => + Assert.Equal(HttpStatusCode.Unauthorized, _rejected!.StatusCode); + + [Fact] + public void should_not_reach_the_backend_without_a_token() => + Assert.False(_backendSawTheRejectedSession); +} diff --git a/Source/AuthProxy.Security.Specs/given/AccessTokenForwardingHarness.cs b/Source/AuthProxy.Security.Specs/given/AccessTokenForwardingHarness.cs new file mode 100644 index 00000000..200a6891 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/given/AccessTokenForwardingHarness.cs @@ -0,0 +1,173 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.AccessTokens; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Cratis.AuthProxy.Security.given; + +/// +/// A running AuthProxy in front of a service whose backend receives the signed-in user's access token, with separate +/// recording origins for the backend and the frontend. +/// +/// +/// The session cookie and the identity provider are stood in for: a request carrying +/// is treated as a cookie session holding that token session, and the stand-in mints +/// a token naming it, or refuses for . What is under test is the request path: that the +/// forwarding stage runs in the proxy pipeline at all, that it replaces what the caller sent, and that it refuses +/// rather than forwards when no token is available. +/// +public class AccessTokenForwardingHarness : WebApplicationFactory +{ + /// The request header naming the token session of a simulated cookie session. + public const string TokenSessionHeader = "X-Security-Spec-Token-Session"; + + /// A token session for which no token can be obtained. + public const string RejectedSession = "rejected-session"; + + /// The tenant every request resolves to. + public const string TenantId = "44444444-4444-4444-4444-444444444444"; + + readonly string _pagesPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + + /// + /// Initializes a new instance of the class. + /// + public AccessTokenForwardingHarness() + { + Directory.CreateDirectory(_pagesPath); + File.WriteAllText(Path.Combine(_pagesPath, WellKnownPageNames.SelectProvider), "Select Provider"); + + Backend = RecordingBackend.Start().GetAwaiter().GetResult(); + Frontend = RecordingBackend.Start().GetAwaiter().GetResult(); + } + + /// Gets the origin of the service's backend. + public RecordingBackend Backend { get; } + + /// Gets the origin of the service's frontend. + public RecordingBackend Frontend { get; } + + /// + /// Gets the token the stand-in provider issues for a token session. + /// + /// The token session. + /// The token. + public static string TokenFor(string session) => $"user-token-for-{session}"; + + /// + /// Builds a request from a signed-in cookie session. + /// + /// The path and query to request. + /// The token session the cookie session holds. + /// The request. + public static HttpRequestMessage FromSession(string pathAndQuery, string session) + { + var request = SecurityHarness.Authenticated(HttpMethod.Get, pathAndQuery, SecurityHarness.UniqueUser("token-forwarding")); + request.Headers.TryAddWithoutValidation(TokenSessionHeader, session); + return request; + } + + /// + /// Forgets what both origins received. + /// + public void ClearOrigins() + { + Backend.Clear(); + Frontend.Clear(); + } + + /// + /// Creates a client that surfaces redirects as responses rather than following them. + /// + /// A configured . + public HttpClient CreateSecurityClient() => + CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false, HandleCookies = false }); + + /// + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + if (!disposing) + { + return; + } + + Backend.DisposeAsync().AsTask().GetAwaiter().GetResult(); + Frontend.DisposeAsync().AsTask().GetAwaiter().GetResult(); + + if (Directory.Exists(_pagesPath)) + { + Directory.Delete(_pagesPath, recursive: true); + } + } + + /// + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder + .UseEnvironment("Production") + .ConfigureAppConfiguration((_, config) => config.AddInMemoryCollection(new Dictionary + { + [$"{C.AuthProxy.SectionKey}:Services:reporting:Backend:BaseUrl"] = Backend.BaseUrl, + [$"{C.AuthProxy.SectionKey}:Services:reporting:Frontend:BaseUrl"] = Frontend.BaseUrl, + [$"{C.AuthProxy.SectionKey}:Services:reporting:ResolveIdentityDetails"] = "false", + [$"{C.AuthProxy.SectionKey}:Services:reporting:AccessToken:Scopes:0"] = "api://reporting/access_as_user", + + [$"{C.AuthProxy.SectionKey}:PagesPath"] = _pagesPath, + + [$"{C.AuthProxy.SectionKey}:TenantResolutions:0:Strategy"] = nameof(C.TenantSourceIdentifierResolverType.Specified), + [$"{C.AuthProxy.SectionKey}:TenantResolutions:0:Options:TenantId"] = TenantId, + + [$"{C.Authentication.SectionKey}:OidcProviders:0:Name"] = "Provider One", + [$"{C.Authentication.SectionKey}:OidcProviders:0:Authority"] = "https://login.example.test/one", + [$"{C.Authentication.SectionKey}:OidcProviders:0:ClientId"] = "client-one", + })) + .ConfigureTestServices(services => + { + services + .AddAuthentication(HeaderAuthenticationHandler.Scheme) + .AddScheme(HeaderAuthenticationHandler.Scheme, _ => { }); + services.AddSingleton(); + services.AddSingleton(); + }); + } + + sealed class StandInUserAccessTokens : IUserAccessTokens + { + public Task GetFor(string sessionId, C.ServiceAccessToken accessToken, CancellationToken cancellationToken) => + Task.FromResult(sessionId == RejectedSession + ? UserAccessTokenResult.Failed(UserAccessTokenFailure.RefreshTokenRejected) + : UserAccessTokenResult.Success(TokenFor(sessionId))); + } + + sealed class SimulatedCookieSessionStartupFilter : IStartupFilter + { + public Action Configure(Action next) => + app => + { + app.Use(async (context, proceed) => + { + var session = context.Request.Headers[TokenSessionHeader].ToString(); + context.Request.Headers.Remove(TokenSessionHeader); + if (session.Length > 0) + { + context.Items[Authentication.AuthenticationServiceCollectionExtensions.SelectedSchemeItemKey] = CookieAuthenticationDefaults.AuthenticationScheme; + context.Items["Cratis.AuthProxy.TokenSession"] = session; + } + + await proceed(); + }); + + next(app); + }; + } +} diff --git a/Source/AuthProxy.Security.Specs/given/AccessTokenForwardingSpecCollection.cs b/Source/AuthProxy.Security.Specs/given/AccessTokenForwardingSpecCollection.cs new file mode 100644 index 00000000..4c0e76ee --- /dev/null +++ b/Source/AuthProxy.Security.Specs/given/AccessTokenForwardingSpecCollection.cs @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.given; + +/// +/// Shares one across the access-token forwarding specs. +/// +[CollectionDefinition(Name, DisableParallelization = true)] +public class AccessTokenForwardingSpecCollection : ICollectionFixture +{ + /// The collection name every access-token forwarding spec joins. + public const string Name = "AccessTokenForwarding"; +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenConfigurationValidator/given/an_access_token_validator.cs b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenConfigurationValidator/given/an_access_token_validator.cs new file mode 100644 index 00000000..d36a222c --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenConfigurationValidator/given/an_access_token_validator.cs @@ -0,0 +1,28 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_AccessTokenConfigurationValidator.given; + +public class an_access_token_validator : Specification +{ + protected C.Authentication _authentication; + protected C.Service _service; + protected ValidateOptionsResult _result; + + void Establish() + { + _authentication = new() { OidcProviders = [new() { Name = "Workforce", Authority = "https://login.example.com", ClientId = "client-id" }] }; + _service = new() + { + Backend = new C.ServiceEndpoint { BaseUrl = "http://reporting/" }, + AccessToken = new() { Scopes = ["api://reporting/access_as_user"] }, + }; + } + + protected void Validate() + { + var monitor = Substitute.For>(); + monitor.CurrentValue.Returns(_authentication); + _result = new AccessTokenConfigurationValidator(monitor).Validate(null, new C.AuthProxy { Services = new Dictionary { ["reporting"] = _service } }); + } +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenConfigurationValidator/when_no_oidc_provider_is_configured.cs b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenConfigurationValidator/when_no_oidc_provider_is_configured.cs new file mode 100644 index 00000000..e185e968 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenConfigurationValidator/when_no_oidc_provider_is_configured.cs @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_AccessTokenConfigurationValidator; + +public class when_no_oidc_provider_is_configured : given.an_access_token_validator +{ + void Establish() => _authentication.OidcProviders = []; + + void Because() => Validate(); + + [Fact] void should_fail() => _result.Failed.ShouldBeTrue(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenConfigurationValidator/when_the_access_token_names_an_audience.cs b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenConfigurationValidator/when_the_access_token_names_an_audience.cs new file mode 100644 index 00000000..91489787 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenConfigurationValidator/when_the_access_token_names_an_audience.cs @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_AccessTokenConfigurationValidator; + +public class when_the_access_token_names_an_audience : given.an_access_token_validator +{ + void Establish() => _service.AccessToken!.Provider = "workforce"; + + void Because() => Validate(); + + [Fact] void should_succeed() => _result.Succeeded.ShouldBeTrue(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenConfigurationValidator/when_the_access_token_names_an_unknown_provider.cs b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenConfigurationValidator/when_the_access_token_names_an_unknown_provider.cs new file mode 100644 index 00000000..50d01601 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenConfigurationValidator/when_the_access_token_names_an_unknown_provider.cs @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_AccessTokenConfigurationValidator; + +public class when_the_access_token_names_an_unknown_provider : given.an_access_token_validator +{ + void Establish() => _service.AccessToken!.Provider = "Partners"; + + void Because() => Validate(); + + [Fact] void should_fail() => _result.Failed.ShouldBeTrue(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenConfigurationValidator/when_the_access_token_names_no_audience.cs b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenConfigurationValidator/when_the_access_token_names_no_audience.cs new file mode 100644 index 00000000..29c236b2 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenConfigurationValidator/when_the_access_token_names_no_audience.cs @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_AccessTokenConfigurationValidator; + +public class when_the_access_token_names_no_audience : given.an_access_token_validator +{ + void Establish() => _service.AccessToken!.Scopes = []; + + void Because() => Validate(); + + [Fact] void should_fail() => _result.Failed.ShouldBeTrue(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenConfigurationValidator/when_the_service_has_no_backend.cs b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenConfigurationValidator/when_the_service_has_no_backend.cs new file mode 100644 index 00000000..027519ee --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenConfigurationValidator/when_the_service_has_no_backend.cs @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_AccessTokenConfigurationValidator; + +public class when_the_service_has_no_backend : given.an_access_token_validator +{ + void Establish() => _service.Backend = null; + + void Because() => Validate(); + + [Fact] void should_fail() => _result.Failed.ShouldBeTrue(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/given/a_forwarding_middleware.cs b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/given/a_forwarding_middleware.cs new file mode 100644 index 00000000..a939d52c --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/given/a_forwarding_middleware.cs @@ -0,0 +1,81 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.Extensions.Logging.Abstractions; +using Yarp.ReverseProxy.Configuration; +using Yarp.ReverseProxy.Forwarder; +using Yarp.ReverseProxy.Model; + +namespace Cratis.AuthProxy.AccessTokens.for_AccessTokenForwardingMiddleware.given; + +/// +/// The middleware in front of a request that the route table has sent to the backend of a service declaring an +/// access token, from a caller signed in with the session cookie. +/// +public class a_forwarding_middleware : Specification +{ + protected IUserAccessTokens _tokens; + protected C.ServiceAccessToken _accessToken; + protected DefaultHttpContext _context; + protected bool _forwarded; + protected AccessTokenForwardingMiddleware _middleware; + protected string _authorizationPolicy = "default"; + protected string _endpoint = ReverseProxy.MicroserviceReverseProxyConfigProvider.BackendEndpoint; + + void Establish() + { + _accessToken = new() { Scopes = ["api://reporting/access_as_user"] }; + var config = Substitute.For>(); + config.CurrentValue.Returns(new C.AuthProxy + { + Services = new Dictionary + { + ["Reporting"] = new() { Backend = new C.ServiceEndpoint { BaseUrl = "http://reporting/" }, AccessToken = _accessToken }, + }, + }); + + _tokens = Substitute.For(); + _tokens.GetFor("session-id", _accessToken, Arg.Any()).Returns(UserAccessTokenResult.Success("user-access-token")); + + _context = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity([new Claim("sub", "user")], "Cookies")) + }; + _context.Items[AuthenticationServiceCollectionExtensions.SelectedSchemeItemKey] = CookieAuthenticationDefaults.AuthenticationScheme; + _context.Items[UserTokenSessions.HttpContextItemKey] = "session-id"; + _context.Request.Headers.Authorization = "Bearer something-the-browser-sent"; + + _middleware = new( + _ => + { + _forwarded = true; + return Task.CompletedTask; + }, + config, + NullLogger.Instance); + } + + protected Task Invoke() + { + var cluster = new ClusterModel( + new ClusterConfig + { + ClusterId = "reporting-cluster", + Metadata = new Dictionary + { + [ReverseProxy.MicroserviceReverseProxyConfigProvider.ServiceMetadataKey] = "reporting", + [ReverseProxy.MicroserviceReverseProxyConfigProvider.EndpointMetadataKey] = _endpoint, + }, + }, + new HttpMessageInvoker(new SocketsHttpHandler())); + var route = new RouteModel(new RouteConfig { RouteId = "route", AuthorizationPolicy = _authorizationPolicy }, null, HttpTransformer.Empty); + var feature = Substitute.For(); + feature.Route.Returns(route); + feature.Cluster.Returns(cluster); + _context.Features.Set(feature); + + return _middleware.InvokeAsync(_context, _tokens); + } +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_a_bearer_authenticated_caller_calls_the_backend.cs b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_a_bearer_authenticated_caller_calls_the_backend.cs new file mode 100644 index 00000000..0665f5cc --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_a_bearer_authenticated_caller_calls_the_backend.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.AccessTokens.for_AccessTokenForwardingMiddleware; + +public class when_a_bearer_authenticated_caller_calls_the_backend : given.a_forwarding_middleware +{ + void Establish() => _context.Items[Authentication.AuthenticationServiceCollectionExtensions.SelectedSchemeItemKey] = Authentication.ClientCredentialsDefaults.AuthenticationScheme; + + Task Because() => Invoke(); + + [Fact] void should_forward_the_request() => _forwarded.ShouldBeTrue(); + [Fact] void should_keep_the_callers_own_authorization() => _context.Request.Headers.Authorization.ToString().ShouldEqual("Bearer something-the-browser-sent"); + [Fact] void should_obtain_no_user_token() => _tokens.DidNotReceive().GetFor(Arg.Any(), Arg.Any(), Arg.Any()); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_a_signed_in_user_calls_the_backend.cs b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_a_signed_in_user_calls_the_backend.cs new file mode 100644 index 00000000..266ee0ce --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_a_signed_in_user_calls_the_backend.cs @@ -0,0 +1,12 @@ +// 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.AccessTokens.for_AccessTokenForwardingMiddleware; + +public class when_a_signed_in_user_calls_the_backend : given.a_forwarding_middleware +{ + Task Because() => Invoke(); + + [Fact] void should_forward_the_request() => _forwarded.ShouldBeTrue(); + [Fact] void should_replace_the_authorization_header_with_the_users_access_token() => _context.Request.Headers.Authorization.ToString().ShouldEqual("Bearer user-access-token"); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_no_token_can_be_obtained.cs b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_no_token_can_be_obtained.cs new file mode 100644 index 00000000..6d5f746d --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_no_token_can_be_obtained.cs @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_AccessTokenForwardingMiddleware; + +public class when_no_token_can_be_obtained : given.a_forwarding_middleware +{ + void Establish() => _tokens.GetFor("session-id", _accessToken, Arg.Any()).Returns(UserAccessTokenResult.Failed(UserAccessTokenFailure.RefreshTokenRejected)); + + Task Because() => Invoke(); + + [Fact] void should_not_forward_the_request() => _forwarded.ShouldBeFalse(); + [Fact] void should_refuse_it_as_unauthenticated() => _context.Response.StatusCode.ShouldEqual(StatusCodes.Status401Unauthorized); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_the_request_goes_to_the_frontend.cs b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_the_request_goes_to_the_frontend.cs new file mode 100644 index 00000000..0515373e --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_the_request_goes_to_the_frontend.cs @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_AccessTokenForwardingMiddleware; + +public class when_the_request_goes_to_the_frontend : given.a_forwarding_middleware +{ + void Establish() => _endpoint = ReverseProxy.MicroserviceReverseProxyConfigProvider.FrontendEndpoint; + + Task Because() => Invoke(); + + [Fact] void should_forward_the_request() => _forwarded.ShouldBeTrue(); + [Fact] void should_obtain_no_user_token() => _tokens.DidNotReceive().GetFor(Arg.Any(), Arg.Any(), Arg.Any()); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_the_request_is_on_an_anonymous_path.cs b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_the_request_is_on_an_anonymous_path.cs new file mode 100644 index 00000000..efd670bd --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_the_request_is_on_an_anonymous_path.cs @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_AccessTokenForwardingMiddleware; + +public class when_the_request_is_on_an_anonymous_path : given.a_forwarding_middleware +{ + void Establish() => _authorizationPolicy = ReverseProxy.MicroserviceReverseProxyConfigProvider.AnonymousAuthorizationPolicy; + + Task Because() => Invoke(); + + [Fact] void should_forward_the_request() => _forwarded.ShouldBeTrue(); + [Fact] void should_obtain_no_user_token() => _tokens.DidNotReceive().GetFor(Arg.Any(), Arg.Any(), Arg.Any()); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_the_session_holds_no_token_session.cs b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_the_session_holds_no_token_session.cs new file mode 100644 index 00000000..eb0f0e8b --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_the_session_holds_no_token_session.cs @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_AccessTokenForwardingMiddleware; + +public class when_the_session_holds_no_token_session : given.a_forwarding_middleware +{ + void Establish() => _context.Items.Remove(UserTokenSessions.HttpContextItemKey); + + Task Because() => Invoke(); + + [Fact] void should_not_forward_the_request() => _forwarded.ShouldBeFalse(); + [Fact] void should_refuse_it_as_unauthenticated() => _context.Response.StatusCode.ShouldEqual(StatusCodes.Status401Unauthorized); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/given/user_access_tokens.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/given/user_access_tokens.cs new file mode 100644 index 00000000..71db2167 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/given/user_access_tokens.cs @@ -0,0 +1,54 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.AccessTokens.given; +using Cratis.AuthProxy.Authentication; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; + +namespace Cratis.AuthProxy.AccessTokens.for_UserAccessTokens.given; + +/// +/// over a real token store, a stand-in token endpoint, and one OIDC provider +/// authenticated by client secret. +/// +public class user_access_tokens : a_user_token_store +{ + protected const string Scheme = "workforce"; + protected const string TokenEndpointUrl = "https://login.example.com/tenant/oauth2/v2.0/token"; + + protected TokenEndpoint _endpoint; + protected C.OidcProvider _provider; + protected OpenIdConnectOptions _options; + protected IOidcClientAssertions _assertions; + protected UserAccessTokens _tokens; + protected C.ServiceAccessToken _accessToken; + protected string _sessionId; + + async Task Establish() + { + _endpoint = new TokenEndpoint(); + _provider = new() { Name = "Workforce", Authority = "https://login.example.com/tenant/v2.0", ClientId = "client-id", ClientSecret = "client-secret" }; + _options = new() + { + ClientId = "client-id", + ClientSecret = "client-secret", + Configuration = new OpenIdConnectConfiguration { TokenEndpoint = TokenEndpointUrl }, + }; + + var oidcOptions = Substitute.For>(); + oidcOptions.Get(Scheme).Returns(_ => _options); + var authentication = Substitute.For>(); + authentication.CurrentValue.Returns(_ => new C.Authentication { OidcProviders = [_provider] }); + _assertions = Substitute.For(); + var httpClientFactory = Substitute.For(); + httpClientFactory.CreateClient(UserAccessTokens.HttpClientName).Returns(_ => new HttpClient(_endpoint, disposeHandler: false)); + + _tokens = new(_store, oidcOptions, authentication, _assertions, httpClientFactory, _time, NullLogger.Instance); + _accessToken = new() { Scopes = ["api://reporting/access_as_user"] }; + _sessionId = await _store.Create(new(Scheme, "refresh-token"), CancellationToken.None); + } + + protected Task Get() => _tokens.GetFor(_sessionId, _accessToken, CancellationToken.None); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_obtaining_a_token_for_an_audience.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_obtaining_a_token_for_an_audience.cs new file mode 100644 index 00000000..503a25ff --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_obtaining_a_token_for_an_audience.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.AccessTokens.for_UserAccessTokens; + +public class when_obtaining_a_token_for_an_audience : given.user_access_tokens +{ + UserAccessTokenResult _first; + UserAccessTokenResult _second; + + async Task Because() + { + _first = await Get(); + _second = await Get(); + } + + [Fact] void should_obtain_the_token() => _first.Token.ShouldEqual("access-token"); + [Fact] void should_redeem_the_refresh_token() => _endpoint.Received[0]["grant_type"].ShouldEqual("refresh_token"); + [Fact] void should_present_the_sessions_refresh_token() => _endpoint.Received[0]["refresh_token"].ShouldEqual("refresh-token"); + [Fact] void should_ask_for_the_audiences_scopes() => _endpoint.Received[0]["scope"].ShouldEqual("api://reporting/access_as_user"); + [Fact] void should_authenticate_with_the_client_secret() => _endpoint.Received[0]["client_secret"].ShouldEqual("client-secret"); + [Fact] void should_identify_the_client() => _endpoint.Received[0]["client_id"].ShouldEqual("client-id"); + [Fact] void should_answer_again_from_the_cache() => _second.Token.ShouldEqual("access-token"); + [Fact] void should_call_the_provider_once() => _endpoint.Received.Count.ShouldEqual(1); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_cached_token_is_about_to_expire.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_cached_token_is_about_to_expire.cs new file mode 100644 index 00000000..9c4e6c0b --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_cached_token_is_about_to_expire.cs @@ -0,0 +1,22 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.AccessTokens.given; + +namespace Cratis.AuthProxy.AccessTokens.for_UserAccessTokens; + +public class when_the_cached_token_is_about_to_expire : given.user_access_tokens +{ + UserAccessTokenResult _renewed; + + async Task Because() + { + await Get(); + _endpoint.Answer = () => TokenEndpoint.Bearer("renewed-token", 3600); + _time.Advance(TimeSpan.FromMinutes(59.5)); + _renewed = await Get(); + } + + [Fact] void should_renew_it_before_it_expires() => _renewed.Token.ShouldEqual("renewed-token"); + [Fact] void should_call_the_provider_again() => _endpoint.Received.Count.ShouldEqual(2); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_provider_answers_without_a_bearer_token.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_provider_answers_without_a_bearer_token.cs new file mode 100644 index 00000000..ccc0c019 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_provider_answers_without_a_bearer_token.cs @@ -0,0 +1,19 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.AccessTokens.given; + +namespace Cratis.AuthProxy.AccessTokens.for_UserAccessTokens; + +public class when_the_provider_answers_without_a_bearer_token : given.user_access_tokens +{ + UserAccessTokenResult _result; + + async Task Because() + { + _endpoint.Answer = () => TokenEndpoint.Json(System.Net.HttpStatusCode.OK, """{"access_token":"token","token_type":"DPoP","expires_in":3600}"""); + _result = await Get(); + } + + [Fact] void should_fail() => _result.Failure.ShouldEqual(UserAccessTokenFailure.ProviderUnavailable); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_provider_authenticates_with_a_client_assertion.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_provider_authenticates_with_a_client_assertion.cs new file mode 100644 index 00000000..6ce47505 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_provider_authenticates_with_a_client_assertion.cs @@ -0,0 +1,20 @@ +// 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.AccessTokens.for_UserAccessTokens; + +public class when_the_provider_authenticates_with_a_client_assertion : given.user_access_tokens +{ + async Task Because() + { + _provider.ClientSecret = string.Empty; + _provider.ClientCredential = new() { Source = C.OidcClientCredentialSource.ManagedIdentity }; + _options.ClientSecret = null; + _assertions.Create(Scheme, Arg.Any(), TokenEndpointUrl, Arg.Any()).Returns("signed-assertion"); + await Get(); + } + + [Fact] void should_send_the_assertion() => _endpoint.Received[0]["client_assertion"].ShouldEqual("signed-assertion"); + [Fact] void should_declare_a_jwt_assertion() => _endpoint.Received[0]["client_assertion_type"].ShouldEqual("urn:ietf:params:oauth:client-assertion-type:jwt-bearer"); + [Fact] void should_send_no_client_secret() => _endpoint.Received[0].ContainsKey("client_secret").ShouldBeFalse(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_provider_cannot_be_reached.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_provider_cannot_be_reached.cs new file mode 100644 index 00000000..be0a9d1f --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_provider_cannot_be_reached.cs @@ -0,0 +1,20 @@ +// 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.AccessTokens.for_UserAccessTokens; + +public class when_the_provider_cannot_be_reached : given.user_access_tokens +{ + UserAccessTokenResult _result; + UserTokenSession? _session; + + async Task Because() + { + _endpoint.Answer = () => throw new HttpRequestException("connection refused"); + _result = await Get(); + _session = await _store.Get(_sessionId, CancellationToken.None); + } + + [Fact] void should_fail() => _result.Failure.ShouldEqual(UserAccessTokenFailure.ProviderUnavailable); + [Fact] void should_keep_the_refresh_token_for_a_later_attempt() => _session.ShouldNotBeNull(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_provider_rejects_the_refresh_token.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_provider_rejects_the_refresh_token.cs new file mode 100644 index 00000000..a445923c --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_provider_rejects_the_refresh_token.cs @@ -0,0 +1,22 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.AccessTokens.given; + +namespace Cratis.AuthProxy.AccessTokens.for_UserAccessTokens; + +public class when_the_provider_rejects_the_refresh_token : given.user_access_tokens +{ + UserAccessTokenResult _result; + UserTokenSession? _session; + + async Task Because() + { + _endpoint.Answer = () => TokenEndpoint.Error("invalid_grant"); + _result = await Get(); + _session = await _store.Get(_sessionId, CancellationToken.None); + } + + [Fact] void should_fail() => _result.Failure.ShouldEqual(UserAccessTokenFailure.RefreshTokenRejected); + [Fact] void should_forget_the_dead_refresh_token() => _session.ShouldBeNull(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_provider_rotates_the_refresh_token.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_provider_rotates_the_refresh_token.cs new file mode 100644 index 00000000..5a84d3ab --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_provider_rotates_the_refresh_token.cs @@ -0,0 +1,19 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.AccessTokens.given; + +namespace Cratis.AuthProxy.AccessTokens.for_UserAccessTokens; + +public class when_the_provider_rotates_the_refresh_token : given.user_access_tokens +{ + async Task Because() + { + _endpoint.Answer = () => TokenEndpoint.Bearer("access-token", 3600, refreshToken: "rotated-refresh-token"); + await Get(); + _accessToken = new() { Scopes = ["api://billing/access_as_user"] }; + await Get(); + } + + [Fact] void should_present_the_rotated_refresh_token_next() => _endpoint.Received[1]["refresh_token"].ShouldEqual("rotated-refresh-token"); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_service_requires_another_provider.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_service_requires_another_provider.cs new file mode 100644 index 00000000..81063d67 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_service_requires_another_provider.cs @@ -0,0 +1,18 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_UserAccessTokens; + +public class when_the_service_requires_another_provider : given.user_access_tokens +{ + UserAccessTokenResult _result; + + async Task Because() + { + _accessToken.Provider = "Partners"; + _result = await Get(); + } + + [Fact] void should_fail() => _result.Failure.ShouldEqual(UserAccessTokenFailure.WrongProvider); + [Fact] void should_not_call_the_provider() => _endpoint.Received.ShouldBeEmpty(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_session_holds_no_refresh_token.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_session_holds_no_refresh_token.cs new file mode 100644 index 00000000..f1cc3ebd --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_session_holds_no_refresh_token.cs @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_UserAccessTokens; + +public class when_the_session_holds_no_refresh_token : given.user_access_tokens +{ + UserAccessTokenResult _result; + + async Task Because() => _result = await _tokens.GetFor("unknown-session", _accessToken, CancellationToken.None); + + [Fact] void should_fail() => _result.Failure.ShouldEqual(UserAccessTokenFailure.NoRefreshToken); + [Fact] void should_not_call_the_provider() => _endpoint.Received.ShouldBeEmpty(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_two_audiences_are_requested.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_two_audiences_are_requested.cs new file mode 100644 index 00000000..5d7eafce --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_two_audiences_are_requested.cs @@ -0,0 +1,24 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.AccessTokens.given; + +namespace Cratis.AuthProxy.AccessTokens.for_UserAccessTokens; + +public class when_two_audiences_are_requested : given.user_access_tokens +{ + UserAccessTokenResult _reporting; + UserAccessTokenResult _billing; + + async Task Because() + { + _endpoint.Answer = () => TokenEndpoint.Bearer("reporting-token", 3600); + _reporting = await Get(); + _accessToken = new() { Resource = "https://billing.example.com" }; + _endpoint.Answer = () => TokenEndpoint.Bearer("billing-token", 3600); + _billing = await Get(); + } + + [Fact] void should_keep_a_token_per_audience() => (_reporting.Token, _billing.Token).ShouldEqual(("reporting-token", "billing-token")); + [Fact] void should_send_the_resource_indicator() => _endpoint.Received[1]["resource"].ShouldEqual("https://billing.example.com"); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/given/a_sign_in_redeeming_its_code.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/given/a_sign_in_redeeming_its_code.cs new file mode 100644 index 00000000..a1a0873f --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/given/a_sign_in_redeeming_its_code.cs @@ -0,0 +1,53 @@ +// 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.Authentication; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; + +namespace Cratis.AuthProxy.AccessTokens.for_UserTokenSessions.given; + +/// +/// An OIDC sign-in whose token response has just arrived, in a deployment where one service forwards user tokens. +/// +public class a_sign_in_redeeming_its_code : Specification +{ + protected C.AuthProxy _config; + protected IUserTokenStore _store; + protected AuthenticationProperties _properties; + protected TokenResponseReceivedContext _context; + + void Establish() + { + _config = new C.AuthProxy + { + Services = new Dictionary + { + ["reporting"] = new() { AccessToken = new() { Scopes = ["api://reporting/.default"] } }, + }, + }; + var monitor = Substitute.For>(); + monitor.CurrentValue.Returns(_ => _config); + + _store = Substitute.For(); + _store.Create(Arg.Any(), Arg.Any()).Returns("session-id"); + + var services = new ServiceCollection() + .AddLogging() + .AddSingleton(monitor) + .AddSingleton(_store) + .BuildServiceProvider(); + + _properties = new AuthenticationProperties(); + _context = new TokenResponseReceivedContext( + new DefaultHttpContext { RequestServices = services }, + new AuthenticationScheme("workforce", null, typeof(OpenIdConnectHandler)), + new OpenIdConnectOptions(), + new ClaimsPrincipal(), + _properties) + { + TokenEndpointResponse = new OpenIdConnectMessage { AccessToken = "sign-in-access-token", IdToken = "id-token", RefreshToken = "refresh-token" }, + }; + } +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_link_callback_redeems_its_code.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_link_callback_redeems_its_code.cs new file mode 100644 index 00000000..d1e1af9a --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_link_callback_redeems_its_code.cs @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_UserTokenSessions; + +public class when_a_link_callback_redeems_its_code : given.a_sign_in_redeeming_its_code +{ + void Establish() => _properties.Items[Links.LinkMiddleware.LinkModePropertyKey] = "true"; + + Task Because() => UserTokenSessions.Capture(_context); + + [Fact] void should_keep_nothing_for_an_identity_that_is_not_signed_in() => _store.DidNotReceive().Create(Arg.Any(), Arg.Any()); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_session_is_signed_out.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_session_is_signed_out.cs new file mode 100644 index 00000000..5fb8b9e6 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_session_is_signed_out.cs @@ -0,0 +1,35 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.Extensions.DependencyInjection; + +namespace Cratis.AuthProxy.AccessTokens.for_UserTokenSessions; + +public class when_a_session_is_signed_out : Specification +{ + IUserTokenStore _store; + DefaultHttpContext _httpContext; + + void Establish() + { + _store = Substitute.For(); + _httpContext = new DefaultHttpContext { RequestServices = new ServiceCollection().AddSingleton(_store).BuildServiceProvider() }; + + var properties = new AuthenticationProperties(); + properties.Items[UserTokenSessions.PropertiesKey] = "session-id"; + var ticket = new AuthenticationTicket(new ClaimsPrincipal(new ClaimsIdentity("Cookies")), properties, "Cookies"); + UserTokenSessions.Remember(new CookieValidatePrincipalContext(_httpContext, new AuthenticationScheme("Cookies", null, typeof(CookieAuthenticationHandler)), new CookieAuthenticationOptions(), ticket)); + } + + Task Because() => UserTokenSessions.Forget(new CookieSigningOutContext( + _httpContext, + new AuthenticationScheme("Cookies", null, typeof(CookieAuthenticationHandler)), + new CookieAuthenticationOptions(), + new AuthenticationProperties(), + new CookieOptions())); + + [Fact] void should_remove_the_token_session() => _store.Received(1).Remove("session-id", Arg.Any()); + [Fact] void should_forget_it_for_the_rest_of_the_request() => UserTokenSessions.Of(_httpContext).ShouldBeNull(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_sign_in_redeems_its_code.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_sign_in_redeems_its_code.cs new file mode 100644 index 00000000..ea444e4b --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_sign_in_redeems_its_code.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 Microsoft.AspNetCore.Authentication; + +namespace Cratis.AuthProxy.AccessTokens.for_UserTokenSessions; + +public class when_a_sign_in_redeems_its_code : given.a_sign_in_redeeming_its_code +{ + Task Because() => UserTokenSessions.Capture(_context); + + [Fact] void should_keep_the_refresh_token_server_side() => _store.Received(1).Create(new UserTokenSession("workforce", "refresh-token"), Arg.Any()); + [Fact] void should_put_only_the_session_identifier_on_the_session() => _properties.Items[UserTokenSessions.PropertiesKey].ShouldEqual("session-id"); + [Fact] void should_put_no_tokens_on_the_session() => _properties.GetTokens().ShouldBeEmpty(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_no_service_forwards_user_tokens.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_no_service_forwards_user_tokens.cs new file mode 100644 index 00000000..548ae699 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_no_service_forwards_user_tokens.cs @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_UserTokenSessions; + +public class when_no_service_forwards_user_tokens : given.a_sign_in_redeeming_its_code +{ + void Establish() => _config.Services["reporting"].AccessToken = null; + + Task Because() => UserTokenSessions.Capture(_context); + + [Fact] void should_keep_nothing() => _store.DidNotReceive().Create(Arg.Any(), Arg.Any()); + [Fact] void should_leave_the_session_untouched() => _properties.Items.ContainsKey(UserTokenSessions.PropertiesKey).ShouldBeFalse(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_the_provider_issues_no_refresh_token.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_the_provider_issues_no_refresh_token.cs new file mode 100644 index 00000000..838f060f --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_the_provider_issues_no_refresh_token.cs @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_UserTokenSessions; + +public class when_the_provider_issues_no_refresh_token : given.a_sign_in_redeeming_its_code +{ + void Establish() => _context.TokenEndpointResponse.RefreshToken = null; + + Task Because() => UserTokenSessions.Capture(_context); + + [Fact] void should_keep_nothing() => _store.DidNotReceive().Create(Arg.Any(), Arg.Any()); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_a_session_is_read_with_another_key_ring.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_a_session_is_read_with_another_key_ring.cs new file mode 100644 index 00000000..c778a3a6 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_a_session_is_read_with_another_key_ring.cs @@ -0,0 +1,23 @@ +// 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.AccessTokens.for_UserTokenStore; + +public class when_a_session_is_read_with_another_key_ring : given.a_user_token_store +{ + string _sessionId; + UserTokenSession? _read; + + async Task Because() + { + _sessionId = await _store.Create(new("workforce", "refresh-token"), CancellationToken.None); + var config = Substitute.For>(); + config.CurrentValue.Returns(new C.AuthProxy()); + var other = new UserTokenStore(_cache, new EphemeralDataProtectionProvider(), config, _time); + _read = await other.Get(_sessionId, CancellationToken.None); + } + + [Fact] void should_treat_it_as_absent() => _read.ShouldBeNull(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_a_session_is_removed.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_a_session_is_removed.cs new file mode 100644 index 00000000..ea359de0 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_a_session_is_removed.cs @@ -0,0 +1,27 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_UserTokenStore; + +public class when_a_session_is_removed : given.a_user_token_store +{ + string _sessionId; + UserTokenSession? _session; + CachedUserAccessToken? _accessToken; + + async Task Establish() + { + _sessionId = await _store.Create(new("workforce", "refresh-token"), CancellationToken.None); + await _store.SetAccessToken(_sessionId, "audience", new("access-token", _time.GetUtcNow().AddHours(1)), _time.GetUtcNow().AddMinutes(59), CancellationToken.None); + } + + async Task Because() + { + await _store.Remove(_sessionId, CancellationToken.None); + _session = await _store.Get(_sessionId, CancellationToken.None); + _accessToken = await _store.GetAccessToken(_sessionId, "audience", CancellationToken.None); + } + + [Fact] void should_forget_the_refresh_token() => _session.ShouldBeNull(); + [Fact] void should_forget_the_access_tokens_obtained_for_it() => _accessToken.ShouldBeNull(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_a_session_is_stored.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_a_session_is_stored.cs new file mode 100644 index 00000000..c647e88e --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_a_session_is_stored.cs @@ -0,0 +1,29 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text; + +namespace Cratis.AuthProxy.AccessTokens.for_UserTokenStore; + +public class when_a_session_is_stored : given.a_user_token_store +{ + const string RefreshToken = "refresh-token-value"; + + string _sessionId; + UserTokenSession? _read; + bool _cacheHoldsTheRefreshTokenInClear; + + async Task Because() + { + _sessionId = await _store.Create(new("workforce", RefreshToken), CancellationToken.None); + _read = await _store.Get(_sessionId, CancellationToken.None); + + _cacheHoldsTheRefreshTokenInClear = _cache.Written + .Any(_ => _.Key.Contains(_sessionId, StringComparison.Ordinal) + || Encoding.UTF8.GetString(_.Value).Contains(RefreshToken, StringComparison.Ordinal)); + } + + [Fact] void should_read_back_the_session() => _read.ShouldEqual(new UserTokenSession("workforce", RefreshToken)); + [Fact] void should_identify_it_unguessably() => _sessionId.Length.ShouldEqual(43); + [Fact] void should_keep_neither_the_refresh_token_nor_the_session_identifier_in_clear() => _cacheHoldsTheRefreshTokenInClear.ShouldBeFalse(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_an_access_token_arrives_for_an_ended_session.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_an_access_token_arrives_for_an_ended_session.cs new file mode 100644 index 00000000..d155ce98 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_an_access_token_arrives_for_an_ended_session.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.AccessTokens.for_UserTokenStore; + +public class when_an_access_token_arrives_for_an_ended_session : given.a_user_token_store +{ + CachedUserAccessToken? _accessToken; + + async Task Because() + { + await _store.SetAccessToken("ended-session", "audience", new("access-token", _time.GetUtcNow().AddHours(1)), _time.GetUtcNow().AddMinutes(59), CancellationToken.None); + _accessToken = await _store.GetAccessToken("ended-session", "audience", CancellationToken.None); + } + + [Fact] void should_keep_nothing() => _accessToken.ShouldBeNull(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/given/ManualTime.cs b/Source/AuthProxy.Specs/AccessTokens/given/ManualTime.cs new file mode 100644 index 00000000..81c2fd62 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/given/ManualTime.cs @@ -0,0 +1,22 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.given; + +/// +/// A whose clock only moves when a spec moves it. +/// +/// The starting time. +public class ManualTime(DateTimeOffset now) : TimeProvider +{ + DateTimeOffset _now = now; + + /// + public override DateTimeOffset GetUtcNow() => _now; + + /// + /// Moves the clock forward. + /// + /// How far to move it. + public void Advance(TimeSpan by) => _now = _now.Add(by); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/given/RecordingDistributedCache.cs b/Source/AuthProxy.Specs/AccessTokens/given/RecordingDistributedCache.cs new file mode 100644 index 00000000..bf198624 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/given/RecordingDistributedCache.cs @@ -0,0 +1,53 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Concurrent; +using Microsoft.Extensions.Caching.Distributed; + +namespace Cratis.AuthProxy.AccessTokens.given; + +/// +/// An in-memory that also records every value written, so a spec can inspect what +/// would have reached a shared cache. +/// +public class RecordingDistributedCache : IDistributedCache +{ + readonly MemoryDistributedCache _inner = new(Options.Create(new MemoryDistributedCacheOptions())); + + /// + /// Gets every value written, by key. + /// + public ConcurrentDictionary Written { get; } = new(StringComparer.Ordinal); + + /// + public byte[]? Get(string key) => _inner.Get(key); + + /// + public Task GetAsync(string key, CancellationToken token = default) => _inner.GetAsync(key, token); + + /// + public void Refresh(string key) => _inner.Refresh(key); + + /// + public Task RefreshAsync(string key, CancellationToken token = default) => _inner.RefreshAsync(key, token); + + /// + public void Remove(string key) => _inner.Remove(key); + + /// + public Task RemoveAsync(string key, CancellationToken token = default) => _inner.RemoveAsync(key, token); + + /// + public void Set(string key, byte[] value, DistributedCacheEntryOptions options) + { + Written[key] = value; + _inner.Set(key, value, options); + } + + /// + public Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default) + { + Written[key] = value; + return _inner.SetAsync(key, value, options, token); + } +} diff --git a/Source/AuthProxy.Specs/AccessTokens/given/TokenEndpoint.cs b/Source/AuthProxy.Specs/AccessTokens/given/TokenEndpoint.cs new file mode 100644 index 00000000..f8aa3034 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/given/TokenEndpoint.cs @@ -0,0 +1,60 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Net; + +namespace Cratis.AuthProxy.AccessTokens.given; + +/// +/// A stand-in identity-provider token endpoint that records each form it receives and answers with whatever the spec +/// queued. +/// +public class TokenEndpoint : HttpMessageHandler +{ + /// + /// Gets the forms received, in order. + /// + public List> Received { get; } = []; + + /// + /// Gets or sets how the endpoint answers the next request. + /// + public Func Answer { get; set; } = () => Bearer("access-token", 3600); + + /// + /// Builds a successful bearer token answer. + /// + /// The access token. + /// The lifetime in seconds. + /// An optional rotated refresh token. + /// The response. + public static HttpResponseMessage Bearer(string accessToken, int expiresIn, string? refreshToken = null) + { + var rotation = refreshToken is null ? string.Empty : $$""","refresh_token":"{{refreshToken}}" """.TrimEnd(); + return Json(HttpStatusCode.OK, $$"""{"access_token":"{{accessToken}}","token_type":"Bearer","expires_in":{{expiresIn}}{{rotation}}}"""); + } + + /// + /// Builds an OAuth error answer. + /// + /// The error code. + /// The response. + public static HttpResponseMessage Error(string error) => Json(HttpStatusCode.BadRequest, $$"""{"error":"{{error}}"}"""); + + /// + /// Builds a JSON answer. + /// + /// The status code. + /// The body. + /// The response. + public static HttpResponseMessage Json(HttpStatusCode status, string json) => + new(status) { Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json") }; + + /// + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var form = await request.Content!.ReadAsStringAsync(cancellationToken); + Received.Add(Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(form).ToDictionary(_ => _.Key, _ => _.Value.ToString(), StringComparer.Ordinal)); + return Answer(); + } +} diff --git a/Source/AuthProxy.Specs/AccessTokens/given/a_user_token_store.cs b/Source/AuthProxy.Specs/AccessTokens/given/a_user_token_store.cs new file mode 100644 index 00000000..9226a5e7 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/given/a_user_token_store.cs @@ -0,0 +1,25 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.AspNetCore.DataProtection; + +namespace Cratis.AuthProxy.AccessTokens.given; + +/// +/// A real on an in-memory cache and an ephemeral key ring. +/// +public class a_user_token_store : Specification +{ + protected RecordingDistributedCache _cache; + protected ManualTime _time; + protected UserTokenStore _store; + + void Establish() + { + _cache = new RecordingDistributedCache(); + _time = new ManualTime(new DateTimeOffset(2026, 10, 1, 12, 0, 0, TimeSpan.Zero)); + var config = Substitute.For>(); + config.CurrentValue.Returns(new C.AuthProxy()); + _store = new UserTokenStore(_cache, new EphemeralDataProtectionProvider(), config, _time); + } +} diff --git a/Source/AuthProxy.Specs/Authentication/for_AuthenticationServiceCollectionExtensions/when_registering_user_token_capture.cs b/Source/AuthProxy.Specs/Authentication/for_AuthenticationServiceCollectionExtensions/when_registering_user_token_capture.cs new file mode 100644 index 00000000..646f2477 --- /dev/null +++ b/Source/AuthProxy.Specs/Authentication/for_AuthenticationServiceCollectionExtensions/when_registering_user_token_capture.cs @@ -0,0 +1,37 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Cratis.AuthProxy.Authentication.for_AuthenticationServiceCollectionExtensions; + +public class when_registering_user_token_capture : Specification +{ + OpenIdConnectOptions _oidc; + CookieAuthenticationOptions _cookie; + + void Establish() + { + var builder = WebApplication.CreateBuilder(); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + [$"{C.Authentication.SectionKey}:OidcProviders:0:Name"] = "Workforce", + [$"{C.Authentication.SectionKey}:OidcProviders:0:Authority"] = "https://login.microsoftonline.com/tenant/v2.0", + [$"{C.Authentication.SectionKey}:OidcProviders:0:ClientId"] = "client-id", + [$"{C.Authentication.SectionKey}:OidcProviders:0:ClientSecret"] = "client-secret", + }); + + builder.AddIngressAuthentication(); + var services = builder.Services.BuildServiceProvider(); + _oidc = services.GetRequiredService>().Get("workforce"); + _cookie = services.GetRequiredService>().Get(CookieAuthenticationDefaults.AuthenticationScheme); + } + + [Fact] void should_capture_the_token_response() => _oidc.Events.OnTokenResponseReceived.ShouldNotBeNull(); + [Fact] void should_not_save_tokens_in_the_session_cookie() => _oidc.SaveTokens.ShouldBeFalse(); + [Fact] void should_forget_token_sessions_on_sign_out() => _cookie.Events.OnSigningOut.ShouldNotBeNull(); +} diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_building_clusters.cs b/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_building_clusters.cs new file mode 100644 index 00000000..7265b70f --- /dev/null +++ b/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_building_clusters.cs @@ -0,0 +1,38 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Yarp.ReverseProxy.Configuration; + +namespace Cratis.AuthProxy.ReverseProxy.for_MicroserviceReverseProxyConfigProvider; + +/// +/// Each cluster names the service and endpoint it belongs to, so the proxy pipeline can tell a service's backend +/// from its frontend without parsing cluster identifiers. +/// +public class when_building_clusters : Specification +{ + IReadOnlyList _clusters; + + void Establish() + { + var monitor = Substitute.For>(); + monitor.CurrentValue.Returns(new C.AuthProxy + { + Services = new Dictionary + { + ["Reporting"] = new() + { + Backend = new C.ServiceEndpoint { BaseUrl = "http://reporting-api/" }, + Frontend = new C.ServiceEndpoint { BaseUrl = "http://reporting-web/" }, + }, + }, + }); + _clusters = new MicroserviceReverseProxyConfigProvider(monitor, Substitute.For>()).GetConfig().Clusters; + } + + ClusterConfig Cluster(string id) => _clusters.Single(_ => _.ClusterId == id); + + [Fact] void should_name_the_service_of_the_backend() => Cluster("reporting-backend-cluster").Metadata![MicroserviceReverseProxyConfigProvider.ServiceMetadataKey].ShouldEqual("reporting"); + [Fact] void should_mark_the_backend() => Cluster("reporting-backend-cluster").Metadata![MicroserviceReverseProxyConfigProvider.EndpointMetadataKey].ShouldEqual(MicroserviceReverseProxyConfigProvider.BackendEndpoint); + [Fact] void should_mark_the_frontend() => Cluster("reporting-frontend-cluster").Metadata![MicroserviceReverseProxyConfigProvider.EndpointMetadataKey].ShouldEqual(MicroserviceReverseProxyConfigProvider.FrontendEndpoint); +} diff --git a/Source/AuthProxy/AccessTokens/AccessTokenConfigurationValidator.cs b/Source/AuthProxy/AccessTokens/AccessTokenConfigurationValidator.cs new file mode 100644 index 00000000..297195b6 --- /dev/null +++ b/Source/AuthProxy/AccessTokens/AccessTokenConfigurationValidator.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 Microsoft.Extensions.Options; +using C = Cratis.AuthProxy.Configuration; + +namespace Cratis.AuthProxy.AccessTokens; + +/// +/// Validates every service's at startup. +/// +/// The authentication configuration, naming the OIDC providers tokens can come from. +public class AccessTokenConfigurationValidator(IOptionsMonitor authentication) : IValidateOptions +{ + /// + public ValidateOptionsResult Validate(string? name, C.AuthProxy options) + { + var providers = authentication.CurrentValue.OidcProviders; + var failures = options.Services + .Where(_ => _.Value.AccessToken is not null) + .SelectMany(_ => Problems(_.Key, _.Value, providers)) + .ToArray(); + + return failures.Length == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(failures); + } + + static IEnumerable Problems(string serviceName, C.Service service, IList providers) + { + var accessToken = service.AccessToken!; + + if (service.Backend is null) + { + yield return $"Service '{serviceName}' declares an AccessToken but has no Backend to forward it to."; + } + + if (accessToken.Scopes.All(string.IsNullOrWhiteSpace) && string.IsNullOrWhiteSpace(accessToken.Resource)) + { + yield return $"Service '{serviceName}': AccessToken needs Scopes or a Resource naming the backend's audience."; + } + + if (providers.Count == 0) + { + yield return $"Service '{serviceName}' declares an AccessToken, but no OIDC provider is configured to obtain it from."; + } + else if (!string.IsNullOrWhiteSpace(accessToken.Provider) + && !providers.Any(_ => string.Equals(OidcProviderScheme.FromName(_.Name), OidcProviderScheme.FromName(accessToken.Provider), StringComparison.Ordinal))) + { + yield return $"Service '{serviceName}': AccessToken.Provider '{accessToken.Provider}' is not a configured OIDC provider."; + } + } +} diff --git a/Source/AuthProxy/AccessTokens/AccessTokenForwardingMiddleware.cs b/Source/AuthProxy/AccessTokens/AccessTokenForwardingMiddleware.cs new file mode 100644 index 00000000..d7280839 --- /dev/null +++ b/Source/AuthProxy/AccessTokens/AccessTokenForwardingMiddleware.cs @@ -0,0 +1,95 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.ReverseProxy; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.Extensions.Options; +using Yarp.ReverseProxy.Model; +using C = Cratis.AuthProxy.Configuration; + +namespace Cratis.AuthProxy.AccessTokens; + +/// +/// Puts the signed-in user's access token for a service's audience on requests proxied to that service's backend. +/// +/// +/// Runs in the reverse-proxy pipeline, after a route and cluster are selected, so it knows exactly which service and +/// endpoint the request goes to. It acts only on requests authenticated by the AuthProxy session cookie and routed +/// to the backend of a service that declares . Machine callers authenticated by a +/// bearer token keep their own Authorization header, and anonymous paths are forwarded as they are. +/// When no token can be obtained the request is refused with 401, never forwarded without the token +/// the backend expects. +/// +/// The next middleware in the proxy pipeline. +/// The configuration. +/// The for diagnostics. +public class AccessTokenForwardingMiddleware( + RequestDelegate next, + IOptionsMonitor config, + ILogger logger) +{ + /// + /// Handles the request. + /// + /// The current . + /// The obtaining tokens. + /// A representing the asynchronous operation. + public async Task InvokeAsync(HttpContext context, IUserAccessTokens tokens) + { + var proxy = context.Features.Get(); + if (proxy is null + || proxy.Route.Config.AuthorizationPolicy == MicroserviceReverseProxyConfigProvider.AnonymousAuthorizationPolicy + || !TryGetAccessToken(proxy, config.CurrentValue, out var serviceName, out var accessToken) + || !IsSessionRequest(context)) + { + await next(context); + return; + } + + var result = UserTokenSessions.Of(context) is { } sessionId + ? await tokens.GetFor(sessionId, accessToken, context.RequestAborted) + : UserAccessTokenResult.Failed(UserAccessTokenFailure.NoRefreshToken); + + if (!result.Succeeded) + { + logger.AccessTokenUnavailable(serviceName, result.Failure); + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + + context.Request.Headers.Authorization = $"Bearer {result.Token}"; + await next(context); + } + + static bool TryGetAccessToken(IReverseProxyFeature proxy, C.AuthProxy config, out string serviceName, out C.ServiceAccessToken accessToken) + { + serviceName = string.Empty; + accessToken = default!; + + var metadata = proxy.Cluster.Config.Metadata; + if (metadata is null + || !metadata.TryGetValue(MicroserviceReverseProxyConfigProvider.ServiceMetadataKey, out var key) + || !metadata.TryGetValue(MicroserviceReverseProxyConfigProvider.EndpointMetadataKey, out var endpoint) + || endpoint != MicroserviceReverseProxyConfigProvider.BackendEndpoint) + { + return false; + } + + var service = config.Services.FirstOrDefault(_ => string.Equals(_.Key, key, StringComparison.OrdinalIgnoreCase)); + if (service.Value?.AccessToken is null) + { + return false; + } + + serviceName = service.Key; + accessToken = service.Value.AccessToken; + return true; + } + + static bool IsSessionRequest(HttpContext context) => + context.User.Identity?.IsAuthenticated == true + && string.Equals( + context.Items[Authentication.AuthenticationServiceCollectionExtensions.SelectedSchemeItemKey] as string, + CookieAuthenticationDefaults.AuthenticationScheme, + StringComparison.Ordinal); +} diff --git a/Source/AuthProxy/AccessTokens/AccessTokenForwardingMiddlewareLogging.cs b/Source/AuthProxy/AccessTokens/AccessTokenForwardingMiddlewareLogging.cs new file mode 100644 index 00000000..6a948b1b --- /dev/null +++ b/Source/AuthProxy/AccessTokens/AccessTokenForwardingMiddlewareLogging.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.AccessTokens; + +internal static partial class AccessTokenForwardingMiddlewareLogging +{ + [LoggerMessage(LogLevel.Warning, "No access token could be obtained for the signed-in user for service {Service} ({Reason}); the request is refused with 401")] + internal static partial void AccessTokenUnavailable(this ILogger logger, string service, UserAccessTokenFailure reason); +} diff --git a/Source/AuthProxy/AccessTokens/CachedUserAccessToken.cs b/Source/AuthProxy/AccessTokens/CachedUserAccessToken.cs new file mode 100644 index 00000000..7a2cf64d --- /dev/null +++ b/Source/AuthProxy/AccessTokens/CachedUserAccessToken.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.AccessTokens; + +/// +/// Represents an access token held for a user and audience until shortly before it expires. +/// +/// The access token. +/// When the provider said the token expires. +public sealed record CachedUserAccessToken(string Value, DateTimeOffset ExpiresAt); diff --git a/Source/AuthProxy/AccessTokens/IUserAccessTokens.cs b/Source/AuthProxy/AccessTokens/IUserAccessTokens.cs new file mode 100644 index 00000000..13eeba9e --- /dev/null +++ b/Source/AuthProxy/AccessTokens/IUserAccessTokens.cs @@ -0,0 +1,21 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using C = Cratis.AuthProxy.Configuration; + +namespace Cratis.AuthProxy.AccessTokens; + +/// +/// Defines a system that obtains access tokens for a signed-in user, per audience. +/// +public interface IUserAccessTokens +{ + /// + /// Gets an access token for the user's session and the audience a service declares. + /// + /// The token session identifier carried by the user's session. + /// The audience the service declares. + /// The for the operation. + /// The token, or why none could be obtained. + Task GetFor(string sessionId, C.ServiceAccessToken accessToken, CancellationToken cancellationToken); +} diff --git a/Source/AuthProxy/AccessTokens/IUserTokenStore.cs b/Source/AuthProxy/AccessTokens/IUserTokenStore.cs new file mode 100644 index 00000000..2916a07f --- /dev/null +++ b/Source/AuthProxy/AccessTokens/IUserTokenStore.cs @@ -0,0 +1,67 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens; + +/// +/// Defines the server-side store of users' refresh tokens and the access tokens obtained with them. +/// +/// +/// Entries are keyed by an unguessable session identifier that only the encrypted session cookie carries, so the +/// tokens themselves never reach the browser. +/// +public interface IUserTokenStore +{ + /// + /// Stores a new session and returns its identifier. + /// + /// The session to store. + /// The for the operation. + /// The identifier to keep in the session cookie. + Task Create(UserTokenSession session, CancellationToken cancellationToken); + + /// + /// Gets a session. + /// + /// The session identifier. + /// The for the operation. + /// The session, or when it is unknown or has expired. + Task Get(string sessionId, CancellationToken cancellationToken); + + /// + /// Replaces a session, for example after the provider rotated the refresh token. + /// + /// The session identifier. + /// The session. + /// The for the operation. + /// A representing the asynchronous operation. + Task Update(string sessionId, UserTokenSession session, CancellationToken cancellationToken); + + /// + /// Removes a session and every access token obtained for it. + /// + /// The session identifier. + /// The for the operation. + /// A representing the asynchronous operation. + Task Remove(string sessionId, CancellationToken cancellationToken); + + /// + /// Gets a cached access token for a session and audience. + /// + /// The session identifier. + /// The audience key. + /// The for the operation. + /// The cached token, or when there is none. + Task GetAccessToken(string sessionId, string audience, CancellationToken cancellationToken); + + /// + /// Caches an access token for a session and audience until it is due for renewal. + /// + /// The session identifier. + /// The audience key. + /// The token. + /// When the token must no longer be handed out. + /// The for the operation. + /// A representing the asynchronous operation. + Task SetAccessToken(string sessionId, string audience, CachedUserAccessToken token, DateTimeOffset renewAt, CancellationToken cancellationToken); +} diff --git a/Source/AuthProxy/AccessTokens/UserAccessTokenFailure.cs b/Source/AuthProxy/AccessTokens/UserAccessTokenFailure.cs new file mode 100644 index 00000000..fe7cc955 --- /dev/null +++ b/Source/AuthProxy/AccessTokens/UserAccessTokenFailure.cs @@ -0,0 +1,36 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens; + +/// +/// Defines why no access token could be obtained for the signed-in user. +/// +public enum UserAccessTokenFailure +{ + /// + /// A token was obtained. + /// + None = 0, + + /// + /// The session has no refresh token held for it: the provider issued none (no offline_access), + /// the session began before token forwarding was configured, or another AuthProxy instance holds it. + /// + NoRefreshToken = 1, + + /// + /// The user signed in with a provider other than the one the service names. + /// + WrongProvider = 2, + + /// + /// The provider refused the refresh token; the user has to sign in again. + /// + RefreshTokenRejected = 3, + + /// + /// The provider could not be reached or answered with something other than a bearer token. + /// + ProviderUnavailable = 4, +} diff --git a/Source/AuthProxy/AccessTokens/UserAccessTokenResult.cs b/Source/AuthProxy/AccessTokens/UserAccessTokenResult.cs new file mode 100644 index 00000000..5f525322 --- /dev/null +++ b/Source/AuthProxy/AccessTokens/UserAccessTokenResult.cs @@ -0,0 +1,31 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens; + +/// +/// Represents the outcome of obtaining an access token for the signed-in user. +/// +/// The access token, when one was obtained. +/// Why no token was obtained, or . +public sealed record UserAccessTokenResult(string? Token, UserAccessTokenFailure Failure) +{ + /// + /// Gets a value indicating whether a token was obtained. + /// + public bool Succeeded => Failure == UserAccessTokenFailure.None && Token is not null; + + /// + /// Creates a successful result. + /// + /// The access token. + /// The result. + public static UserAccessTokenResult Success(string token) => new(token, UserAccessTokenFailure.None); + + /// + /// Creates a failed result. + /// + /// Why no token was obtained. + /// The result. + public static UserAccessTokenResult Failed(UserAccessTokenFailure failure) => new(null, failure); +} diff --git a/Source/AuthProxy/AccessTokens/UserAccessTokens.cs b/Source/AuthProxy/AccessTokens/UserAccessTokens.cs new file mode 100644 index 00000000..c76d7311 --- /dev/null +++ b/Source/AuthProxy/AccessTokens/UserAccessTokens.cs @@ -0,0 +1,236 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using Cratis.AuthProxy.Authentication; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Options; +using C = Cratis.AuthProxy.Configuration; + +namespace Cratis.AuthProxy.AccessTokens; + +/// +/// Represents an implementation of that redeems the session's refresh token at the +/// identity provider's token endpoint (RFC 6749 section 6) for each audience, and caches the result until shortly +/// before it expires. +/// +/// +/// AuthProxy authenticates the refresh the way it authenticates the sign-in: with the provider's client secret, or +/// with the client assertion of its certificate or federated credential. A provider that rotates refresh tokens gets +/// the new one stored. Refreshes for one session are serialized within this instance, so concurrent requests do +/// not race to redeem the same refresh token. +/// +/// The holding refresh and access tokens. +/// The OIDC handler options, for each provider's client and metadata. +/// The authentication configuration, for each provider's client credential. +/// The for providers authenticated by assertion. +/// The for the token endpoint. +/// The . +/// The for diagnostics. +public sealed class UserAccessTokens( + IUserTokenStore store, + IOptionsMonitor oidcOptions, + IOptionsMonitor authentication, + IOidcClientAssertions clientAssertions, + IHttpClientFactory httpClientFactory, + TimeProvider timeProvider, + ILogger logger) : IUserAccessTokens +{ + /// + /// The name of the HTTP client used for the token endpoint. + /// + public const string HttpClientName = "Cratis.AuthProxy.UserAccessTokens"; + + /// + /// How long before its expiry a cached access token is renewed. + /// + public static readonly TimeSpan RenewalMargin = TimeSpan.FromMinutes(1); + + /// + /// The lifetime assumed for an access token whose response states none. + /// + public static readonly TimeSpan DefaultLifetime = TimeSpan.FromMinutes(5); + + readonly SemaphoreSlim[] _refreshLocks = [.. Enumerable.Range(0, 64).Select(_ => new SemaphoreSlim(1, 1))]; + + /// + public async Task GetFor(string sessionId, C.ServiceAccessToken accessToken, CancellationToken cancellationToken) + { + var session = await store.Get(sessionId, cancellationToken); + if (session is null) + { + return UserAccessTokenResult.Failed(UserAccessTokenFailure.NoRefreshToken); + } + + if (!string.IsNullOrWhiteSpace(accessToken.Provider) + && !string.Equals(OidcProviderScheme.FromName(accessToken.Provider), session.Scheme, StringComparison.Ordinal)) + { + return UserAccessTokenResult.Failed(UserAccessTokenFailure.WrongProvider); + } + + var audience = AudienceKey(session.Scheme, accessToken); + if (await UsableCachedToken(sessionId, audience, cancellationToken) is { } cached) + { + return UserAccessTokenResult.Success(cached); + } + + var refreshLock = _refreshLocks[(uint)StringComparer.Ordinal.GetHashCode(sessionId) % (uint)_refreshLocks.Length]; + await refreshLock.WaitAsync(cancellationToken); + try + { + // Another request for this session may have refreshed while this one waited. + if (await UsableCachedToken(sessionId, audience, cancellationToken) is { } refreshed) + { + return UserAccessTokenResult.Success(refreshed); + } + + session = await store.Get(sessionId, cancellationToken); + return session is null + ? UserAccessTokenResult.Failed(UserAccessTokenFailure.NoRefreshToken) + : await Refresh(sessionId, session, audience, accessToken, cancellationToken); + } + finally + { + refreshLock.Release(); + } + } + + static string AudienceKey(string scheme, C.ServiceAccessToken accessToken) + { + var scopes = string.Join(' ', accessToken.Scopes.Select(_ => _.Trim()).Where(_ => _.Length > 0).Order(StringComparer.Ordinal)); + var material = $"{scheme}\n{scopes}\n{accessToken.Resource.Trim()}"; + return WebEncoders.Base64UrlEncode(SHA256.HashData(Encoding.UTF8.GetBytes(material))); + } + + static string? StringProperty(JsonElement root, string name) => + root.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; + + static TimeSpan Lifetime(JsonElement root) => + root.TryGetProperty("expires_in", out var value) && value.ValueKind switch + { + JsonValueKind.Number => value.TryGetInt64(out var seconds) && seconds > 0 ? TimeSpan.FromSeconds(seconds) : (TimeSpan?)null, + JsonValueKind.String => long.TryParse(value.GetString(), NumberStyles.None, CultureInfo.InvariantCulture, out var seconds) && seconds > 0 ? TimeSpan.FromSeconds(seconds) : null, + _ => null + } is { } lifetime + ? lifetime + : DefaultLifetime; + + async Task UsableCachedToken(string sessionId, string audience, CancellationToken cancellationToken) => + await store.GetAccessToken(sessionId, audience, cancellationToken) is { } cached + && cached.ExpiresAt - RenewalMargin > timeProvider.GetUtcNow() + ? cached.Value + : null; + + async Task Refresh( + string sessionId, + UserTokenSession session, + string audience, + C.ServiceAccessToken accessToken, + CancellationToken cancellationToken) + { + var provider = authentication.CurrentValue.OidcProviders + .FirstOrDefault(_ => string.Equals(OidcProviderScheme.FromName(_.Name), session.Scheme, StringComparison.Ordinal)); + if (provider is null) + { + logger.ProviderNoLongerConfigured(session.Scheme); + return UserAccessTokenResult.Failed(UserAccessTokenFailure.ProviderUnavailable); + } + + try + { + var options = oidcOptions.Get(session.Scheme); + var tokenEndpoint = await OidcClientAuthentication.TokenEndpointOf(options, cancellationToken); + using var request = new HttpRequestMessage(HttpMethod.Post, tokenEndpoint) + { + Content = new FormUrlEncodedContent(await Form(session, provider, options, accessToken, tokenEndpoint, cancellationToken)) + }; + request.Headers.Accept.ParseAdd("application/json"); + + using var response = await httpClientFactory.CreateClient(HttpClientName).SendAsync(request, cancellationToken); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); + var root = document.RootElement; + + if (!response.IsSuccessStatusCode) + { + var error = root.ValueKind == JsonValueKind.Object ? StringProperty(root, "error") : null; + logger.RefreshRefused(session.Scheme, (int)response.StatusCode, error ?? "(none)"); + if (string.Equals(error, "invalid_grant", StringComparison.Ordinal)) + { + await store.Remove(sessionId, cancellationToken); + return UserAccessTokenResult.Failed(UserAccessTokenFailure.RefreshTokenRejected); + } + + return UserAccessTokenResult.Failed(UserAccessTokenFailure.ProviderUnavailable); + } + + if (root.ValueKind != JsonValueKind.Object + || StringProperty(root, "access_token") is not { Length: > 0 } token + || !string.Equals(StringProperty(root, "token_type"), "Bearer", StringComparison.OrdinalIgnoreCase)) + { + logger.RefreshAnsweredWithoutBearerToken(session.Scheme); + return UserAccessTokenResult.Failed(UserAccessTokenFailure.ProviderUnavailable); + } + + if (StringProperty(root, "refresh_token") is { Length: > 0 } rotated && rotated != session.RefreshToken) + { + await store.Update(sessionId, session with { RefreshToken = rotated }, cancellationToken); + } + + var now = timeProvider.GetUtcNow(); + var lifetime = Lifetime(root); + var renewAt = now + lifetime - (lifetime > RenewalMargin * 2 ? RenewalMargin : lifetime / 2); + await store.SetAccessToken(sessionId, audience, new CachedUserAccessToken(token, now + lifetime), renewAt, cancellationToken); + + return UserAccessTokenResult.Success(token); + } + catch (Exception exception) when ( + exception is HttpRequestException or JsonException or OidcClientCredentialUnavailable or InvalidOperationException or IOException + || (exception is TaskCanceledException && !cancellationToken.IsCancellationRequested)) + { + logger.RefreshFailed(session.Scheme, exception); + return UserAccessTokenResult.Failed(UserAccessTokenFailure.ProviderUnavailable); + } + } + + async Task> Form( + UserTokenSession session, + C.OidcProvider provider, + OpenIdConnectOptions options, + C.ServiceAccessToken accessToken, + string tokenEndpoint, + CancellationToken cancellationToken) + { + var form = new Dictionary(StringComparer.Ordinal) + { + ["grant_type"] = "refresh_token", + ["refresh_token"] = session.RefreshToken, + ["client_id"] = options.ClientId!, + }; + + var scopes = string.Join(' ', accessToken.Scopes.Select(_ => _.Trim()).Where(_ => _.Length > 0)); + if (scopes.Length > 0) + { + form["scope"] = scopes; + } + + if (!string.IsNullOrWhiteSpace(accessToken.Resource)) + { + form["resource"] = accessToken.Resource.Trim(); + } + + if (provider.UsesClientAssertion) + { + form["client_assertion_type"] = OidcClientAuthentication.JwtBearerAssertionType; + form["client_assertion"] = await clientAssertions.Create(session.Scheme, provider, tokenEndpoint, cancellationToken); + } + else if (!string.IsNullOrEmpty(options.ClientSecret)) + { + form["client_secret"] = options.ClientSecret; + } + + return form; + } +} diff --git a/Source/AuthProxy/AccessTokens/UserAccessTokensLogging.cs b/Source/AuthProxy/AccessTokens/UserAccessTokensLogging.cs new file mode 100644 index 00000000..62d1b185 --- /dev/null +++ b/Source/AuthProxy/AccessTokens/UserAccessTokensLogging.cs @@ -0,0 +1,19 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens; + +internal static partial class UserAccessTokensLogging +{ + [LoggerMessage(LogLevel.Warning, "The OIDC provider {Scheme} a session signed in with is no longer configured; no access token can be obtained for it")] + internal static partial void ProviderNoLongerConfigured(this ILogger logger, string scheme); + + [LoggerMessage(LogLevel.Warning, "OIDC provider {Scheme} refused to refresh an access token with status {StatusCode} and error {Error}")] + internal static partial void RefreshRefused(this ILogger logger, string scheme, int statusCode, string error); + + [LoggerMessage(LogLevel.Warning, "OIDC provider {Scheme} answered a token refresh without a bearer access token")] + internal static partial void RefreshAnsweredWithoutBearerToken(this ILogger logger, string scheme); + + [LoggerMessage(LogLevel.Warning, "Refreshing an access token at OIDC provider {Scheme} failed")] + internal static partial void RefreshFailed(this ILogger logger, string scheme, Exception exception); +} diff --git a/Source/AuthProxy/AccessTokens/UserTokenSession.cs b/Source/AuthProxy/AccessTokens/UserTokenSession.cs new file mode 100644 index 00000000..a40cc936 --- /dev/null +++ b/Source/AuthProxy/AccessTokens/UserTokenSession.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.AccessTokens; + +/// +/// Represents what AuthProxy keeps server-side for one signed-in session so it can obtain access tokens for the user. +/// +/// The authentication scheme of the OIDC provider the user signed in with. +/// The refresh token the provider issued for the session. +public sealed record UserTokenSession(string Scheme, string RefreshToken); diff --git a/Source/AuthProxy/AccessTokens/UserTokenSessions.cs b/Source/AuthProxy/AccessTokens/UserTokenSessions.cs new file mode 100644 index 00000000..c415a46f --- /dev/null +++ b/Source/AuthProxy/AccessTokens/UserTokenSessions.cs @@ -0,0 +1,106 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.Links; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.Extensions.Options; +using C = Cratis.AuthProxy.Configuration; + +namespace Cratis.AuthProxy.AccessTokens; + +/// +/// Ties the server-side token session to the AuthProxy session cookie: created when a sign-in redeems its +/// authorization code, found again on each authenticated request, and removed when the session ends. +/// +/// +/// The session cookie carries only an unguessable identifier, inside its encrypted ticket. The refresh token stays +/// in the , and the cookie carries no tokens at all. +/// +public static class UserTokenSessions +{ + /// + /// The authentication-properties item that carries the token session identifier in the session cookie. + /// + public const string PropertiesKey = "Cratis.AuthProxy.TokenSession"; + + /// + /// The key holding the token session identifier of the current request's session. + /// + internal const string HttpContextItemKey = "Cratis.AuthProxy.TokenSession"; + + /// + /// Gets whether any service forwards user access tokens, which is the only case in which refresh tokens are kept. + /// + /// The configuration. + /// when at least one service declares an access token. + public static bool IsForwardingConfigured(C.AuthProxy config) => config.Services.Values.Any(_ => _.AccessToken is not null); + + /// + /// Gets the token session identifier of the current request's session, when it has one. + /// + /// The current . + /// The identifier, or . + public static string? Of(HttpContext context) => context.Items[HttpContextItemKey] as string; + + /// + /// Keeps the refresh token of a completed code redemption server-side and records its identifier on the session. + /// + /// The token-response context of the OIDC handler. + /// A representing the asynchronous operation. + internal static async Task Capture(TokenResponseReceivedContext context) + { + var services = context.HttpContext.RequestServices; + var config = services.GetRequiredService>().CurrentValue; + if (!IsForwardingConfigured(config) || context.Properties is null) + { + return; + } + + // A link callback authenticates a second identity without signing it in, so it starts no session. + if (context.Properties.Items.TryGetValue(LinkMiddleware.LinkModePropertyKey, out var linkMode) && linkMode == "true") + { + return; + } + + var refreshToken = context.TokenEndpointResponse.RefreshToken; + if (string.IsNullOrEmpty(refreshToken)) + { + services.GetRequiredService() + .CreateLogger(typeof(UserTokenSessions)) + .NoRefreshTokenIssued(context.Scheme.Name); + return; + } + + var store = services.GetRequiredService(); + context.Properties.Items[PropertiesKey] = await store.Create(new(context.Scheme.Name, refreshToken), context.HttpContext.RequestAborted); + } + + /// + /// Makes the token session of an authenticated session cookie available to the rest of the request. + /// + /// The cookie validation context. + internal static void Remember(CookieValidatePrincipalContext context) + { + if (context.Properties.Items.TryGetValue(PropertiesKey, out var sessionId) && !string.IsNullOrEmpty(sessionId)) + { + context.HttpContext.Items[HttpContextItemKey] = sessionId; + } + } + + /// + /// Removes the token session of a session that is being signed out. + /// + /// The cookie sign-out context. + /// A representing the asynchronous operation. + internal static async Task Forget(CookieSigningOutContext context) + { + if (Of(context.HttpContext) is not { } sessionId) + { + return; + } + + await context.HttpContext.RequestServices.GetRequiredService().Remove(sessionId, CancellationToken.None); + context.HttpContext.Items.Remove(HttpContextItemKey); + } +} diff --git a/Source/AuthProxy/AccessTokens/UserTokenSessionsLogging.cs b/Source/AuthProxy/AccessTokens/UserTokenSessionsLogging.cs new file mode 100644 index 00000000..426fe937 --- /dev/null +++ b/Source/AuthProxy/AccessTokens/UserTokenSessionsLogging.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.AccessTokens; + +internal static partial class UserTokenSessionsLogging +{ + [LoggerMessage(LogLevel.Warning, "OIDC provider {Scheme} issued no refresh token at sign-in, so services that forward user access tokens will refuse this session. Request the offline_access scope from the provider")] + internal static partial void NoRefreshTokenIssued(this ILogger logger, string scheme); +} diff --git a/Source/AuthProxy/AccessTokens/UserTokenStore.cs b/Source/AuthProxy/AccessTokens/UserTokenStore.cs new file mode 100644 index 00000000..41bfe403 --- /dev/null +++ b/Source/AuthProxy/AccessTokens/UserTokenStore.cs @@ -0,0 +1,141 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Options; +using C = Cratis.AuthProxy.Configuration; + +namespace Cratis.AuthProxy.AccessTokens; + +/// +/// Represents an implementation of on , with every value +/// encrypted by ASP.NET Core Data Protection. +/// +/// +/// The default cache is in the process's memory, so tokens live and die with the AuthProxy instance that obtained +/// them. Every audience a session obtained a token for is recorded on the session, so removing the session removes +/// them all. +/// +/// The holding the entries. +/// The encrypting them. +/// The session configuration, which bounds how long an entry is kept. +/// The . +public sealed class UserTokenStore( + IDistributedCache cache, + IDataProtectionProvider dataProtection, + IOptionsMonitor session, + TimeProvider timeProvider) : IUserTokenStore +{ + const string KeyPrefix = "Cratis.AuthProxy.UserTokens:"; + + readonly IDataProtector _protector = dataProtection.CreateProtector("Cratis.AuthProxy.UserTokens.v1"); + + /// + public async Task Create(UserTokenSession session, CancellationToken cancellationToken) + { + var sessionId = WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(32)); + await Write(SessionKey(sessionId), new StoredSession(session.Scheme, session.RefreshToken, []), SessionEntryOptions(), cancellationToken); + return sessionId; + } + + /// + public async Task Get(string sessionId, CancellationToken cancellationToken) => + await Read(SessionKey(sessionId), cancellationToken) is { } stored + ? new UserTokenSession(stored.Scheme, stored.RefreshToken) + : null; + + /// + public async Task Update(string sessionId, UserTokenSession session, CancellationToken cancellationToken) + { + var audiences = (await Read(SessionKey(sessionId), cancellationToken))?.Audiences ?? []; + await Write(SessionKey(sessionId), new StoredSession(session.Scheme, session.RefreshToken, audiences), SessionEntryOptions(), cancellationToken); + } + + /// + public async Task Remove(string sessionId, CancellationToken cancellationToken) + { + var stored = await Read(SessionKey(sessionId), cancellationToken); + foreach (var audience in stored?.Audiences ?? []) + { + await cache.RemoveAsync(AccessTokenKey(sessionId, audience), cancellationToken); + } + + await cache.RemoveAsync(SessionKey(sessionId), cancellationToken); + } + + /// + public Task GetAccessToken(string sessionId, string audience, CancellationToken cancellationToken) => + Read(AccessTokenKey(sessionId, audience), cancellationToken); + + /// + public async Task SetAccessToken(string sessionId, string audience, CachedUserAccessToken token, DateTimeOffset renewAt, CancellationToken cancellationToken) + { + var stored = await Read(SessionKey(sessionId), cancellationToken); + if (stored is null) + { + // The session ended while the token was being obtained; keep nothing for it. + return; + } + + if (!stored.Audiences.Contains(audience, StringComparer.Ordinal)) + { + await Write(SessionKey(sessionId), stored with { Audiences = [.. stored.Audiences, audience] }, SessionEntryOptions(), cancellationToken); + } + + await Write( + AccessTokenKey(sessionId, audience), + token, + new DistributedCacheEntryOptions { AbsoluteExpiration = renewAt }, + cancellationToken); + } + + static string SessionKey(string sessionId) => $"{KeyPrefix}{Hash(sessionId)}"; + + static string AccessTokenKey(string sessionId, string audience) => $"{KeyPrefix}{Hash(sessionId)}:{audience}"; + + /// + /// Derives the cache key from the identifier rather than using it, so a cache that can be listed does not hand + /// out the value the cookie proves possession with. + /// + /// The identifier. + /// The derived key. + static string Hash(string value) => WebEncoders.Base64UrlEncode(SHA256.HashData(Encoding.UTF8.GetBytes(value))); + + DistributedCacheEntryOptions SessionEntryOptions() + { + var current = session.CurrentValue.Session; + var lifetime = current.Lifetime > TimeSpan.Zero ? current.Lifetime : C.Session.DefaultLifetime; + return current.SlidingExpiration + ? new DistributedCacheEntryOptions { SlidingExpiration = lifetime } + : new DistributedCacheEntryOptions { AbsoluteExpiration = timeProvider.GetUtcNow().Add(lifetime) }; + } + + async Task Write(string key, T value, DistributedCacheEntryOptions options, CancellationToken cancellationToken) => + await cache.SetAsync(key, _protector.Protect(JsonSerializer.SerializeToUtf8Bytes(value)), options, cancellationToken); + + async Task Read(string key, CancellationToken cancellationToken) + where T : class + { + var protectedValue = await cache.GetAsync(key, cancellationToken); + if (protectedValue is null) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(_protector.Unprotect(protectedValue)); + } + catch (CryptographicException) + { + // Written under a key ring this instance does not have; treat it as absent. + return null; + } + } + + sealed record StoredSession(string Scheme, string RefreshToken, string[] Audiences); +} diff --git a/Source/AuthProxy/Authentication/AuthenticationServiceCollectionExtensions.cs b/Source/AuthProxy/Authentication/AuthenticationServiceCollectionExtensions.cs index 53e5c493..390d1445 100644 --- a/Source/AuthProxy/Authentication/AuthenticationServiceCollectionExtensions.cs +++ b/Source/AuthProxy/Authentication/AuthenticationServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using System.Net.Http.Headers; using System.Security.Claims; +using Cratis.AuthProxy.AccessTokens; using Cratis.AuthProxy.Invites; using Cratis.AuthProxy.Links; using Cratis.AuthProxy.SignIns; @@ -32,6 +33,12 @@ public static class AuthenticationServiceCollectionExtensions /// public const string AuthenticationSchemeStateKey = "Cratis.AuthProxy.AuthenticationScheme"; + /// + /// The key recording which authentication scheme the default scheme selected for + /// the request: the session cookie, a client-credentials token, or a JWT bearer token. + /// + public const string SelectedSchemeItemKey = "Cratis.AuthProxy.SelectedAuthenticationScheme"; + const string ValidatedIssuerStateKey = "Cratis.AuthProxy.ValidatedIssuer"; /// @@ -101,6 +108,13 @@ public static WebApplicationBuilder AddIngressAuthentication(this WebApplication } static string ResolveAuthenticationScheme(HttpContext context, bool hasJwtBearer) + { + var scheme = SelectAuthenticationScheme(context, hasJwtBearer); + context.Items[SelectedSchemeItemKey] = scheme; + return scheme; + } + + static string SelectAuthenticationScheme(HttpContext context, bool hasJwtBearer) { var authorization = context.Request.Headers.Authorization.ToString(); if (authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) @@ -143,6 +157,7 @@ static void ConfigureCookieOptions(CookieAuthenticationOptions options, C.Sessio var existingValidatePrincipal = options.Events.OnValidatePrincipal; options.Events.OnValidatePrincipal = async context => { + UserTokenSessions.Remember(context); await existingValidatePrincipal(context); if (context.Principal is not null) { @@ -150,6 +165,13 @@ static void ConfigureCookieOptions(CookieAuthenticationOptions options, C.Sessio } }; + var existingSigningOut = options.Events.OnSigningOut; + options.Events.OnSigningOut = async context => + { + await UserTokenSessions.Forget(context); + await existingSigningOut(context); + }; + // Redirect unauthenticated users to the provider selection page (multiple providers) // or directly to the single provider login endpoint. // @@ -287,6 +309,7 @@ static void RegisterOidcProviders(AuthenticationBuilder authBuilder, IList capturedProvider.UsesClientAssertion ? OidcClientAuthentication.Apply(context.HttpContext, scheme, capturedProvider, context.Options, context.TokenEndpointRequest!) : Task.CompletedTask, diff --git a/Source/AuthProxy/Configuration/Service.cs b/Source/AuthProxy/Configuration/Service.cs index 37b79981..4a3af1ac 100644 --- a/Source/AuthProxy/Configuration/Service.cs +++ b/Source/AuthProxy/Configuration/Service.cs @@ -161,6 +161,17 @@ public class Service /// public bool ParticipatesInIdentityResolution => Backend is not null && (ResolveIdentityDetails ?? true); + /// + /// Gets or sets the access token AuthProxy obtains for the signed-in user and forwards to this service's + /// backend as Authorization: Bearer. When absent, no user token is forwarded. + /// + /// + /// Only requests authenticated by the AuthProxy session get a token, and only on the routes to + /// . When no token can be obtained, the request is refused with + /// 401 rather than forwarded without one. + /// + public ServiceAccessToken? AccessToken { 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. diff --git a/Source/AuthProxy/Configuration/ServiceAccessToken.cs b/Source/AuthProxy/Configuration/ServiceAccessToken.cs new file mode 100644 index 00000000..8d99475d --- /dev/null +++ b/Source/AuthProxy/Configuration/ServiceAccessToken.cs @@ -0,0 +1,35 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Configuration; + +/// +/// Represents the access token AuthProxy obtains for the signed-in user and forwards to a service's backend. +/// +/// +/// AuthProxy acts as a backend for frontend: it redeems the refresh token it holds server-side for the user's +/// session at the identity provider's token endpoint, asking for (and +/// when set), and forwards the resulting access token as Authorization: Bearer. The refresh token never +/// leaves AuthProxy, and the ID token is never forwarded. +/// +public class ServiceAccessToken +{ + /// + /// Gets or sets the scopes to request for the backend's audience, for example + /// api://reporting/access_as_user for Microsoft Entra ID. + /// + public IList Scopes { get; set; } = []; + + /// + /// Gets or sets an optional resource indicator (RFC 8707) to request the token for, for identity providers that + /// select the audience with the resource parameter rather than scopes. + /// + public string Resource { get; set; } = string.Empty; + + /// + /// Gets or sets the name of the OIDC provider the token must come from. When empty, the token comes from the + /// provider the user signed in with. When set, a user signed in with another provider gets no token, and the + /// request is refused. + /// + public string Provider { get; set; } = string.Empty; +} diff --git a/Source/AuthProxy/ReverseProxy/MicroserviceReverseProxyConfigProvider.cs b/Source/AuthProxy/ReverseProxy/MicroserviceReverseProxyConfigProvider.cs index ee74a61d..dc45a5be 100644 --- a/Source/AuthProxy/ReverseProxy/MicroserviceReverseProxyConfigProvider.cs +++ b/Source/AuthProxy/ReverseProxy/MicroserviceReverseProxyConfigProvider.cs @@ -37,6 +37,26 @@ public class MicroserviceReverseProxyConfigProvider : IProxyConfigProvider, IDis /// internal const string AnonymousAuthorizationPolicy = "anonymous"; + /// + /// The cluster metadata key naming the service a cluster belongs to. + /// + internal const string ServiceMetadataKey = "Cratis.AuthProxy.Service"; + + /// + /// The cluster metadata key naming which endpoint of the service a cluster is. + /// + internal const string EndpointMetadataKey = "Cratis.AuthProxy.Endpoint"; + + /// + /// The value of a service's backend cluster. + /// + internal const string BackendEndpoint = "Backend"; + + /// + /// The value of a service's frontend cluster. + /// + internal const string FrontendEndpoint = "Frontend"; + /// /// The path prefix served by a service's backend rather than its frontend. /// @@ -386,6 +406,7 @@ static List BuildClusters(C.AuthProxy config) { ["destination1"] = new() { Address = ms.Backend.BaseUrl } }, + Metadata = ClusterMetadata(key, BackendEndpoint), }); } @@ -398,6 +419,7 @@ static List BuildClusters(C.AuthProxy config) { ["destination1"] = new() { Address = ms.Frontend.BaseUrl } }, + Metadata = ClusterMetadata(key, FrontendEndpoint), }); } } @@ -405,6 +427,12 @@ static List BuildClusters(C.AuthProxy config) return clusters; } + static Dictionary ClusterMetadata(string key, string endpoint) => new() + { + [ServiceMetadataKey] = key, + [EndpointMetadataKey] = endpoint, + }; + static string BackendClusterId(string key) => $"{key}-backend-cluster"; static string FrontendClusterId(string key) => $"{key}-frontend-cluster"; diff --git a/Source/AuthProxy/ReverseProxy/ReverseProxyExtensions.cs b/Source/AuthProxy/ReverseProxy/ReverseProxyExtensions.cs index 6fa40949..69dafb3b 100644 --- a/Source/AuthProxy/ReverseProxy/ReverseProxyExtensions.cs +++ b/Source/AuthProxy/ReverseProxy/ReverseProxyExtensions.cs @@ -1,8 +1,12 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using Cratis.AuthProxy.AccessTokens; using Cratis.AuthProxy.Identity; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; using Yarp.ReverseProxy.Configuration; +using C = Cratis.AuthProxy.Configuration; namespace Cratis.AuthProxy.ReverseProxy; @@ -20,6 +24,12 @@ public static class ReverseProxyExtensions public static WebApplicationBuilder SetupReverseProxy(this WebApplicationBuilder builder) { builder.Services.AddSingleton(); + builder.Services.AddDistributedMemoryCache(); + builder.Services.TryAddSingleton(TimeProvider.System); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton, AccessTokenConfigurationValidator>(); + builder.Services.AddHttpClient(UserAccessTokens.HttpClientName, client => client.Timeout = TimeSpan.FromSeconds(10)); builder.Services.AddSingleton( sp => sp.GetRequiredService()); @@ -44,7 +54,15 @@ public static WebApplicationBuilder SetupReverseProxy(this WebApplicationBuilder /// The same for chaining. public static WebApplication UseReverseProxy(this WebApplication app) { - app.MapReverseProxy(); + app.MapReverseProxy(proxy => + { + proxy.UseMiddleware(); + + // The stages MapReverseProxy() runs when it is given no pipeline of its own. + proxy.UseSessionAffinity(); + proxy.UseLoadBalancing(); + proxy.UsePassiveHealthChecks(); + }); return app; } } From a09800dd227241df3cac4e45f3ba06bdf998b7d7 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 1 Oct 2026 16:40:30 +0200 Subject: [PATCH 03/13] Fix OIDC assertion signing and credential reloads --- Documentation/configuration/authentication.md | 32 ++++-- .../when_signing_with_an_ecdsa_certificate.cs | 51 +++++++++ ..._requests_reload_an_expired_certificate.cs | 83 ++++++++++++++ ...entity_credentials_for_sovereign_clouds.cs | 57 ++++++++++ ...eplacement_certificate_is_still_expired.cs | 59 ++++++++++ .../CertificateClientAssertion.cs | 47 ++++++-- .../Authentication/OidcClientAssertions.cs | 103 ++++++++++++------ .../OidcClientCredentialDescription.cs | 14 ++- 8 files changed, 392 insertions(+), 54 deletions(-) create mode 100644 Source/AuthProxy.Specs/Authentication/for_CertificateClientAssertion/when_signing_with_an_ecdsa_certificate.cs create mode 100644 Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_concurrent_requests_reload_an_expired_certificate.cs create mode 100644 Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_loading_managed_identity_credentials_for_sovereign_clouds.cs create mode 100644 Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_the_replacement_certificate_is_still_expired.cs diff --git a/Documentation/configuration/authentication.md b/Documentation/configuration/authentication.md index e6d70b70..675268d5 100644 --- a/Documentation/configuration/authentication.md +++ b/Documentation/configuration/authentication.md @@ -129,8 +129,8 @@ Microsoft Entra ID confidential clients. Set `ClientCredential` on the provider `client_assertion` ([RFC 7523](https://www.rfc-editor.org/rfc/rfc7523)) instead. Leave `ClientSecret` empty: AuthProxy refuses to start when both are configured. -AuthProxy presents the credential every time it calls the provider's token endpoint, including pushed -authorization requests when the provider supports them. The credential loaders come from +AuthProxy presents the credential during authorization-code redemption at the provider's token endpoint and +in pushed authorization requests at the provider's PAR endpoint when the provider supports them. The credential loaders come from [Microsoft.Identity.Web](https://github.com/AzureAD/microsoft-identity-web/wiki/Certificates), so certificate stores, Key Vault, workload identity and managed identity behave as they do in any other Microsoft.Identity.Web application. @@ -144,15 +144,22 @@ Microsoft.Identity.Web application. | `FederatedTokenFile` | A platform-issued federated token read from a file is the assertion (Kubernetes workload identity). | `TokenFilePath`, or the `AZURE_FEDERATED_TOKEN_FILE` environment variable. | | `ManagedIdentity` | An Azure managed identity token for the token-exchange audience is the assertion. | None. `ManagedIdentityClientId` selects a user-assigned identity. | -A certificate assertion is a short-lived JWT signed with `RS256` (RSA keys) or `ES256` (ECDSA keys). Its issuer -and subject are `ClientId`, its audience is the provider's token endpoint, and its header carries the -certificate thumbprint (`x5t`). Upload the certificate's public part to the app registration. AuthProxy -loads a certificate once and loads it again after it expires, so put the renewed certificate in the same -file, store or vault entry before the old one expires, or restart AuthProxy to pick it up straight away. +A certificate assertion is a short-lived JWT signed with `RS256` (RSA keys), or `ES256`, `ES384` or `ES512` +(ECDSA P-256, P-384 or P-521 keys respectively). Its issuer and subject are `ClientId`, its audience is the +provider's token endpoint, and its header carries the certificate thumbprint (`x5t`). Upload the +certificate's public part to the app registration. AuthProxy loads a certificate once and loads it again +after it expires. For `CertificateFile` or `KeyVaultCertificate`, put the renewed certificate in the same +file or vault entry before the old one expires, or restart AuthProxy to pick it up straight away. If the +replacement is still expired, sign-in fails and AuthProxy retries loading at most once per minute. +For `CertificateStore`, renewal changes the thumbprint: update `CertificateThumbprint` to the new +certificate's thumbprint and restart AuthProxy. `KeyVaultCertificate` authenticates to Key Vault with the default Azure credential chain. Set `ManagedIdentityClientId` (or `AZURE_CLIENT_ID`) to use a user-assigned managed identity. The identity needs -permission to read the certificate's secret, because the private key is stored there. +both certificate-get and secret-get permissions (for example, the Key Vault Certificate User and Key Vault +Secrets User roles), because the loader reads the certificate and the secret containing its private key. +The certificate must have an exportable private key; a non-exportable Key Vault certificate cannot sign +client assertions in AuthProxy. `FederatedTokenFile` and `ManagedIdentity` need a federated identity credential on the app registration that trusts the platform issuer: the cluster's OIDC issuer and service account for workload identity, or the @@ -213,9 +220,12 @@ equivalent resolved from `Authority`). Set `TokenExchangeAudience` to override i With environment variables, the same settings are `Cratis__AuthProxy__Authentication__OidcProviders__0__ClientCredential__Source=ManagedIdentity` and so on. -If the credential cannot be loaded or produces no assertion, the sign-in fails and is handled as a -[failed sign-in](failed-sign-ins.md), and AuthProxy logs an error that names the provider and credential -source. OAuth 2.0 providers (below) still authenticate with `ClientSecret` only. +If the credential cannot be loaded or produces no assertion during authorization-code redemption, the +sign-in is handled as a [failed sign-in](failed-sign-ins.md). AuthProxy logs credential-loading and +assertion-provider errors with the provider and credential source. A credential failure during a pushed +authorization request happens while starting the sign-in challenge, outside the callback's failed-sign-in +handling, and returns an HTTP 500 response instead. OAuth 2.0 providers (below) still authenticate with +`ClientSecret` only. ### Canonical federated identity diff --git a/Source/AuthProxy.Specs/Authentication/for_CertificateClientAssertion/when_signing_with_an_ecdsa_certificate.cs b/Source/AuthProxy.Specs/Authentication/for_CertificateClientAssertion/when_signing_with_an_ecdsa_certificate.cs new file mode 100644 index 00000000..87d9fbba --- /dev/null +++ b/Source/AuthProxy.Specs/Authentication/for_CertificateClientAssertion/when_signing_with_an_ecdsa_certificate.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.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +namespace Cratis.AuthProxy.Authentication.for_CertificateClientAssertion; + +public class when_signing_with_an_ecdsa_certificate : Specification +{ + readonly List _validations = []; + readonly List _algorithms = []; + readonly List _thumbprints = []; + readonly List _expectedThumbprints = []; + + async Task Because() + { + foreach (var curve in new[] { ECCurve.NamedCurves.nistP256, ECCurve.NamedCurves.nistP384, ECCurve.NamedCurves.nistP521 }) + { + using var key = ECDsa.Create(curve); + var request = new CertificateRequest("CN=authproxy-client", key, HashAlgorithmName.SHA256); + using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(30)); + using var publicKey = certificate.GetECDsaPublicKey(); + + // Repeated signing also verifies that disposing one signing-key handle does not poison a cached provider. + for (var index = 0; index < 2; index++) + { + var serialized = CertificateClientAssertion.Create(certificate, "client-id", "https://login.example.com/token", DateTimeOffset.UtcNow); + var assertion = new JsonWebToken(serialized); + _algorithms.Add(assertion.Alg); + _thumbprints.Add(assertion.X5t); + _expectedThumbprints.Add(Base64UrlEncoder.Encode(certificate.GetCertHash())); + _validations.Add(await new JsonWebTokenHandler().ValidateTokenAsync(serialized, new TokenValidationParameters + { + ValidIssuer = "client-id", + ValidAudience = "https://login.example.com/token", + IssuerSigningKey = new ECDsaSecurityKey(publicKey) + { + CryptoProviderFactory = new CryptoProviderFactory { CacheSignatureProviders = false } + } + })); + } + } + } + + [Fact] void should_carry_signatures_the_certificates_verify() => _validations.TrueForAll(_ => _.IsValid).ShouldBeTrue(); + [Fact] void should_select_the_algorithm_for_each_curve() => _algorithms.ShouldEqual(new[] { "ES256", "ES256", "ES384", "ES384", "ES512", "ES512" }); + [Fact] void should_preserve_the_certificate_thumbprints() => _thumbprints.ShouldEqual(_expectedThumbprints); +} diff --git a/Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_concurrent_requests_reload_an_expired_certificate.cs b/Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_concurrent_requests_reload_an_expired_certificate.cs new file mode 100644 index 00000000..af9d5493 --- /dev/null +++ b/Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_concurrent_requests_reload_an_expired_certificate.cs @@ -0,0 +1,83 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Cratis.AuthProxy.Authentication.given; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Identity.Abstractions; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +namespace Cratis.AuthProxy.Authentication.for_OidcClientAssertions; + +public class when_concurrent_requests_reload_an_expired_certificate : Specification +{ + readonly TaskCompletionSource _reloading = new(TaskCreationOptions.RunContinuationsAsynchronously); + readonly TaskCompletionSource _allowReload = new(TaskCreationOptions.RunContinuationsAsynchronously); + X509Certificate2 _expired; + X509Certificate2 _rotated; + ICredentialsLoader _loader; + OidcClientAssertions _assertions; + C.OidcProvider _provider; + string[] _tokens; + bool _secondRequestWaited; + + void Establish() + { + _expired = ClientCertificates.Rsa(DateTimeOffset.UtcNow.AddDays(-30), DateTimeOffset.UtcNow.AddDays(-1)); + _rotated = ClientCertificates.Rsa(); + var loads = 0; + _loader = Substitute.For(); + _loader.LoadCredentialsIfNeededAsync(Arg.Any(), Arg.Any()) + .Returns(async call => + { + var description = call.Arg(); + var load = Interlocked.Increment(ref loads); + if (load == 1) + { + description.Certificate = _expired; + return; + } + + if (load == 2) + { + _reloading.SetResult(); + await _allowReload.Task; + } + + description.Certificate = _rotated; + }); + _loader.When(_ => _.ResetCredentials(Arg.Any>())) + .Do(call => call.Arg>().Single().Certificate = null); + _provider = new() + { + Name = "Workforce", + ClientId = "client-id", + Authority = "https://login.example.com/tenant", + ClientCredential = new() { Source = C.OidcClientCredentialSource.CertificateFile } + }; + _assertions = new(_loader, TimeProvider.System, NullLogger.Instance); + } + + async Task Because() + { + var first = _assertions.Create("workforce", _provider, "https://login.example.com/token", CancellationToken.None); + await _reloading.Task.WaitAsync(TimeSpan.FromSeconds(5)); + var second = _assertions.Create("workforce", _provider, "https://login.example.com/token", CancellationToken.None); + _secondRequestWaited = !second.IsCompleted; + _allowReload.SetResult(); + _tokens = await Task.WhenAll(first, second).WaitAsync(TimeSpan.FromSeconds(5)); + } + + void Destroy() + { + _expired.Dispose(); + _rotated.Dispose(); + } + + [Fact] void should_wait_for_the_in_progress_reload() => _secondRequestWaited.ShouldBeTrue(); + [Fact] void should_sign_both_requests_with_the_replacement() => _tokens.Select(_ => new JsonWebToken(_).X5t).ShouldEqual([Base64UrlEncoder.Encode(_rotated.GetCertHash()), Base64UrlEncoder.Encode(_rotated.GetCertHash())]); + [Fact] void should_dispose_the_replaced_certificate() => Catch.Exception(() => _expired.GetCertHash()).ShouldBeOfExactType(); + [Fact] void should_reset_the_credential_only_once() => _loader.Received(1).ResetCredentials(Arg.Any>()); +} diff --git a/Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_loading_managed_identity_credentials_for_sovereign_clouds.cs b/Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_loading_managed_identity_credentials_for_sovereign_clouds.cs new file mode 100644 index 00000000..88784e4c --- /dev/null +++ b/Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_loading_managed_identity_credentials_for_sovereign_clouds.cs @@ -0,0 +1,57 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Identity.Abstractions; + +namespace Cratis.AuthProxy.Authentication.for_OidcClientAssertions; + +public class when_loading_managed_identity_credentials_for_sovereign_clouds : Specification +{ + readonly List _audiences = []; + + async Task Because() + { + foreach (var (authority, configuredAudience) in new[] + { + ("https://login.microsoftonline.us/tenant/v2.0", ""), + ("https://login.chinacloudapi.cn/tenant/v2.0", ""), + ("https://login.microsoftonline.com/tenant/v2.0", ""), + ("https://login.microsoftonline.us/tenant/v2.0", "api://custom-exchange") + }) + { + var loader = Substitute.For(); + loader.LoadCredentialsIfNeededAsync(Arg.Any(), Arg.Any()) + .Returns(call => + { + // The audience is already resolved before the loader's eager token acquisition, and stays + // resolved on later loads/refreshes even when the loader supplies no assertion request options. + _audiences.Add(call.Arg().TokenExchangeUrl); + return Task.CompletedTask; + }); + var provider = new C.OidcProvider + { + Name = "Workforce", + Authority = authority, + ClientId = "client-id", + ClientCredential = new() + { + Source = C.OidcClientCredentialSource.ManagedIdentity, + TokenExchangeAudience = configuredAudience + } + }; + var assertions = new OidcClientAssertions(loader, TimeProvider.System, NullLogger.Instance); + await Catch.Exception(() => assertions.Create("workforce", provider, "https://login.example.com/token", CancellationToken.None)); + await Catch.Exception(() => assertions.Create("workforce", provider, "https://login.example.com/token", CancellationToken.None)); + } + } + + [Fact] + void should_resolve_the_audience_before_initial_and_subsequent_loads() => _audiences.ShouldEqual(new[] + { + "api://AzureADTokenExchangeUSGov", "api://AzureADTokenExchangeUSGov", + "api://AzureADTokenExchangeChina", "api://AzureADTokenExchangeChina", + "api://AzureADTokenExchange", "api://AzureADTokenExchange", + "api://custom-exchange", "api://custom-exchange" + }); +} diff --git a/Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_the_replacement_certificate_is_still_expired.cs b/Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_the_replacement_certificate_is_still_expired.cs new file mode 100644 index 00000000..b4962d13 --- /dev/null +++ b/Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_the_replacement_certificate_is_still_expired.cs @@ -0,0 +1,59 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Security.Cryptography.X509Certificates; +using Cratis.AuthProxy.Authentication.given; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Identity.Abstractions; + +namespace Cratis.AuthProxy.Authentication.for_OidcClientAssertions; + +public class when_the_replacement_certificate_is_still_expired : Specification +{ + X509Certificate2 _expired; + X509Certificate2 _replacement; + ICredentialsLoader _loader; + OidcClientAssertions _assertions; + C.OidcProvider _provider; + Exception[] _failures; + + void Establish() + { + _expired = ClientCertificates.Rsa(DateTimeOffset.UtcNow.AddDays(-30), DateTimeOffset.UtcNow.AddDays(-1)); + _replacement = ClientCertificates.Rsa(DateTimeOffset.UtcNow.AddDays(-30), DateTimeOffset.UtcNow.AddDays(-1)); + var loads = new Queue([_expired, _replacement]); + _loader = Substitute.For(); + _loader.LoadCredentialsIfNeededAsync(Arg.Any(), Arg.Any()) + .Returns(call => + { + var description = call.Arg(); + description.Certificate ??= loads.Dequeue(); + return Task.CompletedTask; + }); + _loader.When(_ => _.ResetCredentials(Arg.Any>())) + .Do(call => call.Arg>().Single().Certificate = null); + _provider = new() + { + Name = "Workforce", + ClientId = "client-id", + Authority = "https://login.example.com/tenant", + ClientCredential = new() { Source = C.OidcClientCredentialSource.CertificateFile } + }; + _assertions = new(_loader, TimeProvider.System, NullLogger.Instance); + } + + async Task Because() => _failures = + [ + await Catch.Exception(() => _assertions.Create("workforce", _provider, "https://login.example.com/token", CancellationToken.None)), + await Catch.Exception(() => _assertions.Create("workforce", _provider, "https://login.example.com/token", CancellationToken.None)) + ]; + + void Destroy() + { + _expired.Dispose(); + _replacement.Dispose(); + } + + [Fact] void should_fail_instead_of_signing_with_an_expired_certificate() => _failures.All(_ => _ is OidcClientCredentialUnavailable).ShouldBeTrue(); + [Fact] void should_not_reload_on_every_request() => _loader.Received(1).ResetCredentials(Arg.Any>()); +} diff --git a/Source/AuthProxy/Authentication/CertificateClientAssertion.cs b/Source/AuthProxy/Authentication/CertificateClientAssertion.cs index 744bfd80..74d24beb 100644 --- a/Source/AuthProxy/Authentication/CertificateClientAssertion.cs +++ b/Source/AuthProxy/Authentication/CertificateClientAssertion.cs @@ -1,8 +1,9 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using System.IdentityModel.Tokens.Jwt; +using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; -using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; namespace Cratis.AuthProxy.Authentication; @@ -14,7 +15,6 @@ namespace Cratis.AuthProxy.Authentication; static class CertificateClientAssertion { const string RsaKeyAlgorithm = "1.2.840.113549.1.1.1"; - const string EcKeyAlgorithm = "1.2.840.10045.2.1"; /// /// The lifetime of each assertion. It is presented once, immediately, so it only needs to survive clock skew. @@ -32,7 +32,8 @@ static class CertificateClientAssertion /// The certificate has no usable private key. internal static string Create(X509Certificate2 certificate, string clientId, string audience, DateTimeOffset now) { - var signingCredentials = SigningCredentialsFor(certificate); + using var ecKey = certificate.HasPrivateKey ? certificate.GetECDsaPrivateKey() : null; + var signingCredentials = SigningCredentialsFor(certificate, ecKey); var descriptor = new SecurityTokenDescriptor { Issuer = clientId, @@ -47,15 +48,41 @@ internal static string Create(X509Certificate2 certificate, string clientId, str }, SigningCredentials = signingCredentials }; + var handler = new JwtSecurityTokenHandler { SetDefaultTimesOnTokenCreation = false }; + var token = handler.CreateJwtSecurityToken(descriptor); + if (ecKey is not null) + { + token.Header[JwtHeaderParameterNames.X5t] = Base64UrlEncoder.Encode(certificate.GetCertHash()); + } - return new JsonWebTokenHandler { SetDefaultTimesOnTokenCreation = false }.CreateToken(descriptor); + return handler.WriteToken(token); } - static X509SigningCredentials SigningCredentialsFor(X509Certificate2 certificate) => (certificate.HasPrivateKey, certificate.PublicKey.Oid.Value) switch + static SigningCredentials SigningCredentialsFor(X509Certificate2 certificate, ECDsa? ecKey) { - (true, RsaKeyAlgorithm) => new X509SigningCredentials(certificate, SecurityAlgorithms.RsaSha256), - (true, EcKeyAlgorithm) => new X509SigningCredentials(certificate, SecurityAlgorithms.EcdsaSha256), - _ => throw new OidcClientCredentialUnavailable( - $"The client certificate '{certificate.Subject}' has no RSA or ECDSA private key to sign a client assertion with.") - }; + if (ecKey is not null) + { + var algorithm = ecKey.KeySize switch + { + 256 => SecurityAlgorithms.EcdsaSha256, + 384 => SecurityAlgorithms.EcdsaSha384, + 521 => SecurityAlgorithms.EcdsaSha512, + _ => throw new OidcClientCredentialUnavailable($"The client certificate '{certificate.Subject}' has an unsupported ECDSA key size.") + }; + var key = new ECDsaSecurityKey(ecKey) + { + KeyId = certificate.Thumbprint, + + // The private-key handle belongs to this call, not to the signature-provider cache. + CryptoProviderFactory = new CryptoProviderFactory { CacheSignatureProviders = false } + }; + + return new SigningCredentials(key, algorithm); + } + + return certificate.HasPrivateKey && certificate.PublicKey.Oid.Value == RsaKeyAlgorithm + ? new X509SigningCredentials(certificate, SecurityAlgorithms.RsaSha256) + : throw new OidcClientCredentialUnavailable( + $"The client certificate '{certificate.Subject}' has no RSA or ECDSA private key to sign a client assertion with."); + } } diff --git a/Source/AuthProxy/Authentication/OidcClientAssertions.cs b/Source/AuthProxy/Authentication/OidcClientAssertions.cs index ad50b96d..aba1177a 100644 --- a/Source/AuthProxy/Authentication/OidcClientAssertions.cs +++ b/Source/AuthProxy/Authentication/OidcClientAssertions.cs @@ -26,7 +26,7 @@ public sealed class OidcClientAssertions( TimeProvider timeProvider, ILogger logger) : IOidcClientAssertions { - readonly ConcurrentDictionary _descriptions = new(StringComparer.Ordinal); + readonly ConcurrentDictionary _credentials = new(StringComparer.Ordinal); /// public async Task Create(string scheme, C.OidcProvider provider, string audience, CancellationToken cancellationToken) @@ -36,46 +36,78 @@ public async Task Create(string scheme, C.OidcProvider provider, string throw new OidcClientCredentialUnavailable($"The OIDC provider '{provider.Name}' is not configured with a client-assertion credential."); } - var description = _descriptions.GetOrAdd(scheme, static (_, configured) => OidcClientCredentialDescription.From(configured), credential); - await Load(description, provider); - - var now = timeProvider.GetUtcNow(); - if (description.Certificate is not null && description.Certificate.NotAfter.ToUniversalTime() <= now.UtcDateTime) + var state = _credentials.GetOrAdd( + scheme, + static (_, configured) => new CredentialState(OidcClientCredentialDescription.From(configured.ClientCredential!, configured.Authority)), + provider); + await state.Semaphore.WaitAsync(cancellationToken); + try { - // A rotated certificate replaces the expired one in its file, store or vault; load it again rather than - // signing with a certificate the provider will refuse. - logger.ClientCertificateExpired(provider.Name); - loader.ResetCredentials([description]); + var description = state.Description; await Load(description, provider); - } - if (description.Certificate is { } certificate) - { - return CertificateClientAssertion.Create(certificate, provider.ClientId, audience, now); - } + var now = timeProvider.GetUtcNow(); + var certificate = description.Certificate; + if (certificate is not null && certificate.NotAfter.ToUniversalTime() <= now.UtcDateTime && now >= state.NextCertificateReload) + { + // Serialize reload and signing so resetting/disposal cannot invalidate another request's key. + // An unchanged expired file or thumbprint must not cause a load and warning on every sign-in. + state.NextCertificateReload = now.AddMinutes(1); + logger.ClientCertificateExpired(provider.Name); + loader.ResetCredentials([description]); + try + { + await Load(description, provider); + } + finally + { + if (!ReferenceEquals(certificate, description.Certificate)) + { + certificate.Dispose(); + } + } - if (description.CachedValue is ClientAssertionProviderBase assertionProvider) - { - try + certificate = description.Certificate; + } + + if (certificate is not null) { - return await assertionProvider.GetSignedAssertionAsync(new AssertionRequestOptions + if (certificate.NotAfter.ToUniversalTime() <= now.UtcDateTime) { - ClientID = provider.ClientId, - TokenEndpoint = audience, - CancellationToken = cancellationToken - }); + throw new OidcClientCredentialUnavailable($"The client certificate of OIDC provider '{provider.Name}' has expired."); + } + + return CertificateClientAssertion.Create(certificate, provider.ClientId, audience, now); } - catch (Exception exception) when (exception is not OperationCanceledException) + + if (description.CachedValue is ClientAssertionProviderBase assertionProvider) { - logger.ClientCredentialUnavailable(provider.Name, credential.Source.ToString(), exception); - throw new OidcClientCredentialUnavailable( - $"The {credential.Source} client credential of OIDC provider '{provider.Name}' produced no client assertion.", - exception); + try + { + return await assertionProvider.GetSignedAssertionAsync(new AssertionRequestOptions + { + ClientID = provider.ClientId, + Authority = provider.Authority, + TokenEndpoint = audience, + CancellationToken = cancellationToken + }); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + logger.ClientCredentialUnavailable(provider.Name, credential.Source.ToString(), exception); + throw new OidcClientCredentialUnavailable( + $"The {credential.Source} client credential of OIDC provider '{provider.Name}' produced no client assertion.", + exception); + } } - } - throw new OidcClientCredentialUnavailable( - $"The {credential.Source} client credential of OIDC provider '{provider.Name}' could not be loaded."); + throw new OidcClientCredentialUnavailable( + $"The {credential.Source} client credential of OIDC provider '{provider.Name}' could not be loaded."); + } + finally + { + state.Semaphore.Release(); + } } async Task Load(CredentialDescription description, C.OidcProvider provider) @@ -92,4 +124,13 @@ async Task Load(CredentialDescription description, C.OidcProvider provider) exception); } } + + sealed class CredentialState(CredentialDescription description) + { + internal CredentialDescription Description { get; } = description; + + internal SemaphoreSlim Semaphore { get; } = new(1, 1); + + internal DateTimeOffset NextCertificateReload { get; set; } + } } diff --git a/Source/AuthProxy/Authentication/OidcClientCredentialDescription.cs b/Source/AuthProxy/Authentication/OidcClientCredentialDescription.cs index 8af28d29..37c45270 100644 --- a/Source/AuthProxy/Authentication/OidcClientCredentialDescription.cs +++ b/Source/AuthProxy/Authentication/OidcClientCredentialDescription.cs @@ -17,9 +17,10 @@ static class OidcClientCredentialDescription /// Creates the credential description for a client-assertion credential. /// /// The configured credential. Its source must use a client assertion. + /// The provider authority used to resolve the managed-identity token-exchange audience. /// The the credential loader loads. /// The source does not use a client assertion. - internal static CredentialDescription From(C.OidcClientCredential credential) => credential.Source switch + internal static CredentialDescription From(C.OidcClientCredential credential, string authority) => credential.Source switch { C.OidcClientCredentialSource.CertificateFile => new() { @@ -51,10 +52,19 @@ static class OidcClientCredentialDescription { SourceType = CredentialSource.SignedAssertionFromManagedIdentity, ManagedIdentityClientId = NullIfEmpty(credential.ManagedIdentityClientId), - TokenExchangeUrl = NullIfEmpty(credential.TokenExchangeAudience) + TokenExchangeUrl = NullIfEmpty(credential.TokenExchangeAudience) ?? TokenExchangeAudienceOf(authority) }, _ => throw new OidcClientCredentialUnavailable($"The client credential source '{credential.Source}' does not use a client assertion.") }; + static string TokenExchangeAudienceOf(string authority) => Uri.TryCreate(authority, UriKind.Absolute, out var uri) + ? uri.Host.ToLowerInvariant() switch + { + "login.microsoftonline.us" => "api://AzureADTokenExchangeUSGov", + "login.chinacloudapi.cn" => "api://AzureADTokenExchangeChina", + _ => "api://AzureADTokenExchange" + } + : "api://AzureADTokenExchange"; + static string? NullIfEmpty(string value) => string.IsNullOrWhiteSpace(value) ? null : value; } From 06b8a3558957c337a7846469821d37ab832e0ab1 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 1 Oct 2026 16:55:45 +0200 Subject: [PATCH 04/13] Protect forwarded-token sessions through refresh and sign-in --- Documentation/configuration/services.md | 23 +++-- .../when_a_short_lived_token_is_cached.cs | 30 ++++++ ...logout_occurs_during_a_rotating_refresh.cs | 37 ++++++++ .../when_one_audience_requires_consent.cs | 24 +++++ ..._the_browser_cancels_a_rotating_refresh.cs | 38 ++++++++ ..._the_provider_rejects_the_refresh_token.cs | 2 +- .../given/a_sign_in_redeeming_its_code.cs | 3 + .../when_a_sign_in_redeems_its_code.cs | 4 +- ..._a_sign_in_replaces_an_existing_session.cs | 30 ++++++ ...n_a_validated_sign_in_issues_its_cookie.cs | 26 ++++++ .../when_a_rotation_arrives_after_logout.cs | 19 ++++ .../when_a_session_is_removed.cs | 2 +- .../when_a_sliding_session_is_accessed.cs | 26 ++++++ .../when_an_absolute_session_is_updated.cs | 27 ++++++ ...cess_token_arrives_for_an_ended_session.cs | 2 +- ...logout_races_with_an_access_token_write.cs | 35 +++++++ .../given/RecordingDistributedCache.cs | 81 +++++++++++++---- .../AccessTokens/given/TokenEndpoint.cs | 7 +- .../AccessTokens/given/a_user_token_store.cs | 6 +- .../AccessTokens/CachedUserAccessToken.cs | 3 +- .../AccessTokens/UserAccessTokens.cs | 71 ++++++++++----- .../AccessTokens/UserTokenSessions.cs | 49 ++++++++-- .../AuthProxy/AccessTokens/UserTokenStore.cs | 91 ++++++++++++++----- ...thenticationServiceCollectionExtensions.cs | 7 ++ 24 files changed, 552 insertions(+), 91 deletions(-) create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_a_short_lived_token_is_cached.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_logout_occurs_during_a_rotating_refresh.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_one_audience_requires_consent.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_browser_cancels_a_rotating_refresh.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_sign_in_replaces_an_existing_session.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_validated_sign_in_issues_its_cookie.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_a_rotation_arrives_after_logout.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_a_sliding_session_is_accessed.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_an_absolute_session_is_updated.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_logout_races_with_an_access_token_write.cs diff --git a/Documentation/configuration/services.md b/Documentation/configuration/services.md index 95ee7c7c..3ad5efcf 100644 --- a/Documentation/configuration/services.md +++ b/Documentation/configuration/services.md @@ -453,17 +453,22 @@ provider must be configured. AuthProxy refuses to start otherwise. ### How the token is obtained -- At sign-in, AuthProxy keeps the refresh token the OIDC provider issues **server-side**. The session cookie - carries only an unguessable reference to it, inside its encrypted ticket. The refresh token, the access - tokens and the ID token never reach the browser. +- After an OIDC sign-in passes validation, AuthProxy keeps the refresh token the provider issues + **server-side** when issuing the session cookie. The cookie carries only an unguessable reference to it, + inside its encrypted ticket. In this OIDC flow, the refresh token, access tokens and ID token never reach + the browser. Failed sign-ins and identity-link callbacks create no stored token session. - For each request to the backend, AuthProxy uses that refresh token at the provider's token endpoint (`grant_type=refresh_token`) to get a token for the service's scopes. The token is cached per session and audience, and renewed shortly before it expires. AuthProxy authenticates to the token endpoint with the provider's `ClientSecret` or [client credential](authentication.md#client-credentials-certificates-and-federated-credentials), - and stores a rotated refresh token when the provider issues one. + and stores a rotated refresh token when the provider issues one. Once a refresh starts, it finishes under + a ten-second operation timeout independently of browser cancellation, so navigation does not discard + a received rotation. - Request `offline_access` (or your provider's equivalent) in the provider's `Scopes`. Without a refresh token AuthProxy cannot get access tokens, and logs a warning at each such sign-in. -- Signing out removes the refresh token and every access token kept for the session. +- Signing out removes the refresh token and every access token kept for the session, even when a refresh + is in flight. Signing in again replaces the previous token session. Rotation and new audiences do not + extend an absolute session's original retention deadline. ### What is forwarded, and when it is refused @@ -474,9 +479,11 @@ provider must be configured. AuthProxy refuses to start otherwise. forwarded as before. - When no token can be obtained, the request is refused with `401` instead of being forwarded without one. This happens when the session has no refresh token, the provider rejects the refresh token, the provider - cannot be reached, or the user signed in with another provider than `Provider`. A rejected refresh token - is discarded. The frontend should treat the `401` as a signal to sign in again through - `/.cratis/login/{scheme}`. + cannot be reached, or the user signed in with another provider than `Provider`. An `invalid_grant` error + refuses that audience without discarding the session or other audiences: it can mean missing consent + or a resource-specific policy rather than an expired refresh token. The frontend should treat the `401` + as a signal to sign in again through `/.cratis/login/{scheme}`; a missing consent or policy requirement + may also need to be addressed at the provider. ### Running more than one instance diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_a_short_lived_token_is_cached.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_a_short_lived_token_is_cached.cs new file mode 100644 index 00000000..180004ec --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_a_short_lived_token_is_cached.cs @@ -0,0 +1,30 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.AccessTokens.given; + +namespace Cratis.AuthProxy.AccessTokens.for_UserAccessTokens; + +public class when_a_short_lived_token_is_cached : given.user_access_tokens +{ + UserAccessTokenResult _reused; + UserAccessTokenResult _renewed; + int _callsBeforeRenewal; + + async Task Because() + { + _endpoint.Answer = () => TokenEndpoint.Bearer("short-token", 60); + await Get(); + _time.Advance(TimeSpan.FromSeconds(29)); + _reused = await Get(); + _callsBeforeRenewal = _endpoint.Received.Count; + _time.Advance(TimeSpan.FromSeconds(1)); + _endpoint.Answer = () => TokenEndpoint.Bearer("renewed-token", 60); + _renewed = await Get(); + } + + [Fact] void should_reuse_the_token_before_its_half_life() => _reused.Token.ShouldEqual("short-token"); + [Fact] void should_not_refresh_on_each_request() => _callsBeforeRenewal.ShouldEqual(1); + [Fact] void should_renew_at_the_half_life() => _renewed.Token.ShouldEqual("renewed-token"); + [Fact] void should_refresh_only_twice() => _endpoint.Received.Count.ShouldEqual(2); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_logout_occurs_during_a_rotating_refresh.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_logout_occurs_during_a_rotating_refresh.cs new file mode 100644 index 00000000..2b26bd32 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_logout_occurs_during_a_rotating_refresh.cs @@ -0,0 +1,37 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.AccessTokens.given; + +namespace Cratis.AuthProxy.AccessTokens.for_UserAccessTokens; + +public class when_logout_occurs_during_a_rotating_refresh : given.user_access_tokens +{ + UserAccessTokenResult _result; + UserAccessTokenResult _replayed; + UserTokenSession? _session; + + async Task Because() + { + var issued = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var deliver = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _endpoint.AnswerAsync = async token => + { + issued.SetResult(); + await deliver.Task.WaitAsync(TimeSpan.FromSeconds(5), TimeProvider.System, token); + return TokenEndpoint.Bearer("access-token", 3600, "rotated-refresh-token"); + }; + var request = Get(); + await issued.Task.WaitAsync(TimeSpan.FromSeconds(5), TimeProvider.System); + await _store.Remove(_sessionId, CancellationToken.None); + deliver.SetResult(); + _result = await request.WaitAsync(TimeSpan.FromSeconds(5), TimeProvider.System); + _session = await _store.Get(_sessionId, CancellationToken.None); + _replayed = await Get(); + } + + [Fact] void should_not_resurrect_the_session() => _session.ShouldBeNull(); + [Fact] void should_not_forward_the_in_flight_token() => _result.Failure.ShouldEqual(UserAccessTokenFailure.NoRefreshToken); + [Fact] void should_refuse_a_replayed_cookie_session() => _replayed.Failure.ShouldEqual(UserAccessTokenFailure.NoRefreshToken); + [Fact] void should_not_cache_the_access_token() => _cache.Written.Count.ShouldEqual(1); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_one_audience_requires_consent.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_one_audience_requires_consent.cs new file mode 100644 index 00000000..bb24ec34 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_one_audience_requires_consent.cs @@ -0,0 +1,24 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.AccessTokens.given; + +namespace Cratis.AuthProxy.AccessTokens.for_UserAccessTokens; + +public class when_one_audience_requires_consent : given.user_access_tokens +{ + UserAccessTokenResult _refused; + UserAccessTokenResult _other; + + async Task Because() + { + _endpoint.Answer = () => TokenEndpoint.Error("invalid_grant"); + _refused = await Get(); + _endpoint.Answer = () => TokenEndpoint.Bearer("other-audience-token", 3600); + _other = await _tokens.GetFor(_sessionId, new() { Scopes = ["api://other/access_as_user"] }, CancellationToken.None); + } + + [Fact] void should_refuse_only_the_affected_audience() => _refused.Succeeded.ShouldBeFalse(); + [Fact] void should_obtain_a_token_for_the_other_audience() => _other.Token.ShouldEqual("other-audience-token"); + [Fact] void should_redeem_the_original_refresh_token_for_the_other_audience() => _endpoint.Received[1]["refresh_token"].ShouldEqual("refresh-token"); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_browser_cancels_a_rotating_refresh.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_browser_cancels_a_rotating_refresh.cs new file mode 100644 index 00000000..edef0bcc --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_browser_cancels_a_rotating_refresh.cs @@ -0,0 +1,38 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.AccessTokens.given; + +namespace Cratis.AuthProxy.AccessTokens.for_UserAccessTokens; + +public class when_the_browser_cancels_a_rotating_refresh : given.user_access_tokens +{ + Exception? _error; + UserTokenSession? _session; + UserAccessTokenResult _next; + + async Task Because() + { + var issued = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var deliver = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _endpoint.AnswerAsync = async token => + { + issued.SetResult(); + await deliver.Task.WaitAsync(TimeSpan.FromSeconds(5), TimeProvider.System, token); + return TokenEndpoint.Bearer("rotated-access-token", 3600, "rotated-refresh-token"); + }; + using var browser = new CancellationTokenSource(); + var request = _tokens.GetFor(_sessionId, _accessToken, browser.Token); + await issued.Task.WaitAsync(TimeSpan.FromSeconds(5), TimeProvider.System); + await browser.CancelAsync(); + _error = await Catch.Exception(async () => await request); + deliver.SetResult(); + _next = await Get().WaitAsync(TimeSpan.FromSeconds(5), TimeProvider.System); + _session = await _store.Get(_sessionId, CancellationToken.None); + } + + [Fact] void should_let_the_browser_stop_waiting() => (_error is OperationCanceledException).ShouldBeTrue(); + [Fact] void should_persist_the_rotated_refresh_token() => _session!.RefreshToken.ShouldEqual("rotated-refresh-token"); + [Fact] void should_cache_the_completed_access_token() => _next.Token.ShouldEqual("rotated-access-token"); + [Fact] void should_not_redeem_the_obsolete_refresh_token_again() => _endpoint.Received.Count.ShouldEqual(1); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_provider_rejects_the_refresh_token.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_provider_rejects_the_refresh_token.cs index a445923c..1b016ab4 100644 --- a/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_provider_rejects_the_refresh_token.cs +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_provider_rejects_the_refresh_token.cs @@ -18,5 +18,5 @@ async Task Because() } [Fact] void should_fail() => _result.Failure.ShouldEqual(UserAccessTokenFailure.RefreshTokenRejected); - [Fact] void should_forget_the_dead_refresh_token() => _session.ShouldBeNull(); + [Fact] void should_keep_the_session_for_other_audiences() => _session.ShouldNotBeNull(); } diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/given/a_sign_in_redeeming_its_code.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/given/a_sign_in_redeeming_its_code.cs index a1a0873f..75a0f045 100644 --- a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/given/a_sign_in_redeeming_its_code.cs +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/given/a_sign_in_redeeming_its_code.cs @@ -16,6 +16,7 @@ public class a_sign_in_redeeming_its_code : Specification protected C.AuthProxy _config; protected IUserTokenStore _store; protected AuthenticationProperties _properties; + protected IAuthenticationService _authentication; protected TokenResponseReceivedContext _context; void Establish() @@ -33,10 +34,12 @@ void Establish() _store = Substitute.For(); _store.Create(Arg.Any(), Arg.Any()).Returns("session-id"); + _authentication = Substitute.For(); var services = new ServiceCollection() .AddLogging() .AddSingleton(monitor) .AddSingleton(_store) + .AddSingleton(_authentication) .BuildServiceProvider(); _properties = new AuthenticationProperties(); diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_sign_in_redeems_its_code.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_sign_in_redeems_its_code.cs index ea444e4b..3e3564a7 100644 --- a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_sign_in_redeems_its_code.cs +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_sign_in_redeems_its_code.cs @@ -9,7 +9,7 @@ public class when_a_sign_in_redeems_its_code : given.a_sign_in_redeeming_its_cod { Task Because() => UserTokenSessions.Capture(_context); - [Fact] void should_keep_the_refresh_token_server_side() => _store.Received(1).Create(new UserTokenSession("workforce", "refresh-token"), Arg.Any()); - [Fact] void should_put_only_the_session_identifier_on_the_session() => _properties.Items[UserTokenSessions.PropertiesKey].ShouldEqual("session-id"); + [Fact] void should_not_store_tokens_before_validation_and_ticket_handlers_succeed() => _store.DidNotReceive().Create(Arg.Any(), Arg.Any()); + [Fact] void should_not_create_a_cookie_token_session_yet() => _properties.Items.ContainsKey(UserTokenSessions.PropertiesKey).ShouldBeFalse(); [Fact] void should_put_no_tokens_on_the_session() => _properties.GetTokens().ShouldBeEmpty(); } diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_sign_in_replaces_an_existing_session.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_sign_in_replaces_an_existing_session.cs new file mode 100644 index 00000000..c07c2ac1 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_sign_in_replaces_an_existing_session.cs @@ -0,0 +1,30 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; + +namespace Cratis.AuthProxy.AccessTokens.for_UserTokenSessions; + +public class when_a_sign_in_replaces_an_existing_session : given.a_sign_in_redeeming_its_code +{ + async Task Because() + { + _context.HttpContext.Request.Headers.Cookie = ".AuthProxy=old-cookie"; + var previousProperties = new AuthenticationProperties(); + previousProperties.Items[UserTokenSessions.PropertiesKey] = "previous-session"; + _authentication.AuthenticateAsync(_context.HttpContext, "Cookies").Returns(AuthenticateResult.Success( + new AuthenticationTicket(new ClaimsPrincipal(), previousProperties, "Cookies"))); + await UserTokenSessions.Capture(_context); + await UserTokenSessions.Complete(new CookieSigningInContext( + _context.HttpContext, + new AuthenticationScheme("Cookies", null, typeof(CookieAuthenticationHandler)), + new CookieAuthenticationOptions { Cookie = new CookieBuilder { Name = ".AuthProxy" } }, + new ClaimsPrincipal(), + _properties, + new CookieOptions())); + } + + [Fact] void should_remove_the_previous_token_session() => _store.Received(1).Remove("previous-session", CancellationToken.None); + [Fact] void should_keep_only_the_new_identifier() => _properties.Items[UserTokenSessions.PropertiesKey].ShouldEqual("session-id"); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_validated_sign_in_issues_its_cookie.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_validated_sign_in_issues_its_cookie.cs new file mode 100644 index 00000000..ba76da0f --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_validated_sign_in_issues_its_cookie.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 Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; + +namespace Cratis.AuthProxy.AccessTokens.for_UserTokenSessions; + +public class when_a_validated_sign_in_issues_its_cookie : given.a_sign_in_redeeming_its_code +{ + async Task Because() + { + await UserTokenSessions.Capture(_context); + await UserTokenSessions.Complete(new CookieSigningInContext( + _context.HttpContext, + new AuthenticationScheme("Cookies", null, typeof(CookieAuthenticationHandler)), + new CookieAuthenticationOptions(), + new ClaimsPrincipal(), + _properties, + new CookieOptions())); + } + + [Fact] void should_store_the_refresh_token_after_validation() => _store.Received(1).Create(new UserTokenSession("workforce", "refresh-token"), Arg.Any()); + [Fact] void should_put_only_the_session_identifier_on_the_cookie() => _properties.Items[UserTokenSessions.PropertiesKey].ShouldEqual("session-id"); + [Fact] void should_put_no_tokens_on_the_cookie() => _properties.GetTokens().ShouldBeEmpty(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_a_rotation_arrives_after_logout.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_a_rotation_arrives_after_logout.cs new file mode 100644 index 00000000..aa638977 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_a_rotation_arrives_after_logout.cs @@ -0,0 +1,19 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_UserTokenStore; + +public class when_a_rotation_arrives_after_logout : given.a_user_token_store +{ + UserTokenSession? _session; + + async Task Because() + { + var sessionId = await _store.Create(new("workforce", "refresh-token"), CancellationToken.None); + await _store.Remove(sessionId, CancellationToken.None); + await _store.Update(sessionId, new("workforce", "rotated-token"), CancellationToken.None); + _session = await _store.Get(sessionId, CancellationToken.None); + } + + [Fact] void should_not_recreate_the_session() => _session.ShouldBeNull(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_a_session_is_removed.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_a_session_is_removed.cs index ea359de0..a3fc6bfc 100644 --- a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_a_session_is_removed.cs +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_a_session_is_removed.cs @@ -12,7 +12,7 @@ public class when_a_session_is_removed : given.a_user_token_store async Task Establish() { _sessionId = await _store.Create(new("workforce", "refresh-token"), CancellationToken.None); - await _store.SetAccessToken(_sessionId, "audience", new("access-token", _time.GetUtcNow().AddHours(1)), _time.GetUtcNow().AddMinutes(59), CancellationToken.None); + await _store.SetAccessToken(_sessionId, "audience", new("access-token", _time.GetUtcNow().AddHours(1), _time.GetUtcNow().AddMinutes(59)), _time.GetUtcNow().AddMinutes(59), CancellationToken.None); } async Task Because() diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_a_sliding_session_is_accessed.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_a_sliding_session_is_accessed.cs new file mode 100644 index 00000000..ec5c0d63 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_a_sliding_session_is_accessed.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. + +namespace Cratis.AuthProxy.AccessTokens.for_UserTokenStore; + +public class when_a_sliding_session_is_accessed : given.a_user_token_store +{ + UserTokenSession? _active; + UserTokenSession? _expired; + + void Establish() => _config.Session.SlidingExpiration = true; + + async Task Because() + { + var sessionId = await _store.Create(new("workforce", "refresh-token"), CancellationToken.None); + _time.Advance(TimeSpan.FromHours(11)); + await _store.Get(sessionId, CancellationToken.None); + _time.Advance(TimeSpan.FromHours(11)); + _active = await _store.Get(sessionId, CancellationToken.None); + _time.Advance(TimeSpan.FromHours(12)); + _expired = await _store.Get(sessionId, CancellationToken.None); + } + + [Fact] void should_extend_the_deadline_when_accessed() => _active.ShouldNotBeNull(); + [Fact] void should_expire_after_an_idle_lifetime() => _expired.ShouldBeNull(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_an_absolute_session_is_updated.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_an_absolute_session_is_updated.cs new file mode 100644 index 00000000..7c99cd56 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_an_absolute_session_is_updated.cs @@ -0,0 +1,27 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_UserTokenStore; + +public class when_an_absolute_session_is_updated : given.a_user_token_store +{ + UserTokenSession? _before; + UserTokenSession? _after; + CachedUserAccessToken? _accessToken; + + async Task Because() + { + var sessionId = await _store.Create(new("workforce", "refresh-token"), CancellationToken.None); + _time.Advance(TimeSpan.FromHours(11)); + await _store.Update(sessionId, new("workforce", "rotated-token"), CancellationToken.None); + await _store.SetAccessToken(sessionId, "audience", new("access-token", _time.GetUtcNow().AddHours(2), _time.GetUtcNow().AddHours(1.5)), _time.GetUtcNow().AddHours(1.5), CancellationToken.None); + _before = await _store.Get(sessionId, CancellationToken.None); + _time.Advance(TimeSpan.FromHours(1)); + _after = await _store.Get(sessionId, CancellationToken.None); + _accessToken = await _store.GetAccessToken(sessionId, "audience", CancellationToken.None); + } + + [Fact] void should_keep_the_rotation_until_the_original_deadline() => _before!.RefreshToken.ShouldEqual("rotated-token"); + [Fact] void should_not_extend_the_absolute_session_deadline() => _after.ShouldBeNull(); + [Fact] void should_not_retain_an_access_token_past_the_session_deadline() => _accessToken.ShouldBeNull(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_an_access_token_arrives_for_an_ended_session.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_an_access_token_arrives_for_an_ended_session.cs index d155ce98..a539439a 100644 --- a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_an_access_token_arrives_for_an_ended_session.cs +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_an_access_token_arrives_for_an_ended_session.cs @@ -9,7 +9,7 @@ public class when_an_access_token_arrives_for_an_ended_session : given.a_user_to async Task Because() { - await _store.SetAccessToken("ended-session", "audience", new("access-token", _time.GetUtcNow().AddHours(1)), _time.GetUtcNow().AddMinutes(59), CancellationToken.None); + await _store.SetAccessToken("ended-session", "audience", new("access-token", _time.GetUtcNow().AddHours(1), _time.GetUtcNow().AddMinutes(59)), _time.GetUtcNow().AddMinutes(59), CancellationToken.None); _accessToken = await _store.GetAccessToken("ended-session", "audience", CancellationToken.None); } diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_logout_races_with_an_access_token_write.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_logout_races_with_an_access_token_write.cs new file mode 100644 index 00000000..72bfa1ee --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_logout_races_with_an_access_token_write.cs @@ -0,0 +1,35 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_UserTokenStore; + +public class when_logout_races_with_an_access_token_write : given.a_user_token_store +{ + UserTokenSession? _session; + byte[]? _remainingToken; + + async Task Because() + { + var sessionId = await _store.Create(new("workforce", "refresh-token"), CancellationToken.None); + var writing = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var finish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _cache.BeforeSet = async key => + { + if (key.EndsWith(":audience", StringComparison.Ordinal)) + { + writing.SetResult(); + await finish.Task.WaitAsync(TimeSpan.FromSeconds(5), TimeProvider.System); + } + }; + var write = _store.SetAccessToken(sessionId, "audience", new("access-token", _time.GetUtcNow().AddHours(1), _time.GetUtcNow().AddMinutes(59)), _time.GetUtcNow().AddMinutes(59), CancellationToken.None); + await writing.Task.WaitAsync(TimeSpan.FromSeconds(5), TimeProvider.System); + var logout = _store.Remove(sessionId, CancellationToken.None); + finish.SetResult(); + await Task.WhenAll(write, logout).WaitAsync(TimeSpan.FromSeconds(5), TimeProvider.System); + _session = await _store.Get(sessionId, CancellationToken.None); + _remainingToken = await _cache.GetAsync(_cache.Written.Keys.Single(_ => _.EndsWith(":audience", StringComparison.Ordinal))); + } + + [Fact] void should_remove_the_session() => _session.ShouldBeNull(); + [Fact] void should_not_leave_a_token_written_after_removal() => _remainingToken.ShouldBeNull(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/given/RecordingDistributedCache.cs b/Source/AuthProxy.Specs/AccessTokens/given/RecordingDistributedCache.cs index bf198624..fa6dec40 100644 --- a/Source/AuthProxy.Specs/AccessTokens/given/RecordingDistributedCache.cs +++ b/Source/AuthProxy.Specs/AccessTokens/given/RecordingDistributedCache.cs @@ -7,47 +7,96 @@ namespace Cratis.AuthProxy.AccessTokens.given; /// -/// An in-memory that also records every value written, so a spec can inspect what -/// would have reached a shared cache. +/// An in-memory cache whose absolute and sliding expiration follow the spec's clock, recording every write. /// -public class RecordingDistributedCache : IDistributedCache +/// The clock shared with the token store. +public class RecordingDistributedCache(TimeProvider timeProvider) : IDistributedCache { - readonly MemoryDistributedCache _inner = new(Options.Create(new MemoryDistributedCacheOptions())); + readonly ConcurrentDictionary _entries = new(StringComparer.Ordinal); /// /// Gets every value written, by key. /// public ConcurrentDictionary Written { get; } = new(StringComparer.Ordinal); - /// - public byte[]? Get(string key) => _inner.Get(key); + /// + /// Gets or sets a write barrier for concurrency specs. + /// + public Func? BeforeSet { get; set; } /// - public Task GetAsync(string key, CancellationToken token = default) => _inner.GetAsync(key, token); + public byte[]? Get(string key) => ReadEntry(key); /// - public void Refresh(string key) => _inner.Refresh(key); + public Task GetAsync(string key, CancellationToken token = default) + { + token.ThrowIfCancellationRequested(); + return Task.FromResult(ReadEntry(key)); + } /// - public Task RefreshAsync(string key, CancellationToken token = default) => _inner.RefreshAsync(key, token); + public void Refresh(string key) => ReadEntry(key); /// - public void Remove(string key) => _inner.Remove(key); + public Task RefreshAsync(string key, CancellationToken token = default) + { + token.ThrowIfCancellationRequested(); + ReadEntry(key); + return Task.CompletedTask; + } /// - public Task RemoveAsync(string key, CancellationToken token = default) => _inner.RemoveAsync(key, token); + public void Remove(string key) => _entries.TryRemove(key, out _); /// - public void Set(string key, byte[] value, DistributedCacheEntryOptions options) + public Task RemoveAsync(string key, CancellationToken token = default) { - Written[key] = value; - _inner.Set(key, value, options); + token.ThrowIfCancellationRequested(); + _entries.TryRemove(key, out _); + return Task.CompletedTask; } /// - public Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default) + public void Set(string key, byte[] value, DistributedCacheEntryOptions options) => WriteEntry(key, value, options); + + /// + public async Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default) { + token.ThrowIfCancellationRequested(); + if (BeforeSet is { } beforeSet) + { + await beforeSet(key); + } + + WriteEntry(key, value, options); + } + + byte[]? ReadEntry(string key) + { + if (!_entries.TryGetValue(key, out var entry)) + { + return null; + } + + var now = timeProvider.GetUtcNow(); + if ((entry.AbsoluteExpiration is { } absolute && now >= absolute) + || (entry.SlidingExpiration is { } sliding && now >= entry.LastAccess + sliding)) + { + _entries.TryRemove(key, out _); + return null; + } + + _entries[key] = entry with { LastAccess = now }; + return entry.Value; + } + + void WriteEntry(string key, byte[] value, DistributedCacheEntryOptions options) + { + var now = timeProvider.GetUtcNow(); + var absolute = options.AbsoluteExpirationRelativeToNow is { } relative ? now + relative : options.AbsoluteExpiration; Written[key] = value; - return _inner.SetAsync(key, value, options, token); + _entries[key] = new(value, absolute, options.SlidingExpiration, now); } + + sealed record Entry(byte[] Value, DateTimeOffset? AbsoluteExpiration, TimeSpan? SlidingExpiration, DateTimeOffset LastAccess); } diff --git a/Source/AuthProxy.Specs/AccessTokens/given/TokenEndpoint.cs b/Source/AuthProxy.Specs/AccessTokens/given/TokenEndpoint.cs index f8aa3034..91f53341 100644 --- a/Source/AuthProxy.Specs/AccessTokens/given/TokenEndpoint.cs +++ b/Source/AuthProxy.Specs/AccessTokens/given/TokenEndpoint.cs @@ -21,6 +21,11 @@ public class TokenEndpoint : HttpMessageHandler /// public Func Answer { get; set; } = () => Bearer("access-token", 3600); + /// + /// Gets or sets an asynchronous response for concurrency specs. + /// + public Func>? AnswerAsync { get; set; } + /// /// Builds a successful bearer token answer. /// @@ -55,6 +60,6 @@ protected override async Task SendAsync(HttpRequestMessage { var form = await request.Content!.ReadAsStringAsync(cancellationToken); Received.Add(Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(form).ToDictionary(_ => _.Key, _ => _.Value.ToString(), StringComparer.Ordinal)); - return Answer(); + return AnswerAsync is { } answer ? await answer(cancellationToken) : Answer(); } } diff --git a/Source/AuthProxy.Specs/AccessTokens/given/a_user_token_store.cs b/Source/AuthProxy.Specs/AccessTokens/given/a_user_token_store.cs index 9226a5e7..75898615 100644 --- a/Source/AuthProxy.Specs/AccessTokens/given/a_user_token_store.cs +++ b/Source/AuthProxy.Specs/AccessTokens/given/a_user_token_store.cs @@ -13,13 +13,15 @@ public class a_user_token_store : Specification protected RecordingDistributedCache _cache; protected ManualTime _time; protected UserTokenStore _store; + protected C.AuthProxy _config; void Establish() { - _cache = new RecordingDistributedCache(); _time = new ManualTime(new DateTimeOffset(2026, 10, 1, 12, 0, 0, TimeSpan.Zero)); + _cache = new RecordingDistributedCache(_time); + _config = new C.AuthProxy(); var config = Substitute.For>(); - config.CurrentValue.Returns(new C.AuthProxy()); + config.CurrentValue.Returns(_ => _config); _store = new UserTokenStore(_cache, new EphemeralDataProtectionProvider(), config, _time); } } diff --git a/Source/AuthProxy/AccessTokens/CachedUserAccessToken.cs b/Source/AuthProxy/AccessTokens/CachedUserAccessToken.cs index 7a2cf64d..a33d01a4 100644 --- a/Source/AuthProxy/AccessTokens/CachedUserAccessToken.cs +++ b/Source/AuthProxy/AccessTokens/CachedUserAccessToken.cs @@ -8,4 +8,5 @@ namespace Cratis.AuthProxy.AccessTokens; /// /// The access token. /// When the provider said the token expires. -public sealed record CachedUserAccessToken(string Value, DateTimeOffset ExpiresAt); +/// When the token must be renewed. +public sealed record CachedUserAccessToken(string Value, DateTimeOffset ExpiresAt, DateTimeOffset RenewAt); diff --git a/Source/AuthProxy/AccessTokens/UserAccessTokens.cs b/Source/AuthProxy/AccessTokens/UserAccessTokens.cs index c76d7311..7a9014c9 100644 --- a/Source/AuthProxy/AccessTokens/UserAccessTokens.cs +++ b/Source/AuthProxy/AccessTokens/UserAccessTokens.cs @@ -77,25 +77,8 @@ public async Task GetFor(string sessionId, C.ServiceAcces return UserAccessTokenResult.Success(cached); } - var refreshLock = _refreshLocks[(uint)StringComparer.Ordinal.GetHashCode(sessionId) % (uint)_refreshLocks.Length]; - await refreshLock.WaitAsync(cancellationToken); - try - { - // Another request for this session may have refreshed while this one waited. - if (await UsableCachedToken(sessionId, audience, cancellationToken) is { } refreshed) - { - return UserAccessTokenResult.Success(refreshed); - } - - session = await store.Get(sessionId, cancellationToken); - return session is null - ? UserAccessTokenResult.Failed(UserAccessTokenFailure.NoRefreshToken) - : await Refresh(sessionId, session, audience, accessToken, cancellationToken); - } - finally - { - refreshLock.Release(); - } + // The caller may stop waiting, but a started redemption must finish and persist any rotation. + return await RefreshUnderLock(sessionId, audience, accessToken).WaitAsync(cancellationToken); } static string AudienceKey(string scheme, C.ServiceAccessToken accessToken) @@ -118,9 +101,44 @@ static TimeSpan Lifetime(JsonElement root) => ? lifetime : DefaultLifetime; + async Task RefreshUnderLock(string sessionId, string audience, C.ServiceAccessToken accessToken) + { + using var operation = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var cancellationToken = operation.Token; + var refreshLock = _refreshLocks[(uint)StringComparer.Ordinal.GetHashCode(sessionId) % (uint)_refreshLocks.Length]; + var acquired = false; + try + { + await refreshLock.WaitAsync(cancellationToken); + acquired = true; + + // Another request for this session may have refreshed while this one waited. + if (await UsableCachedToken(sessionId, audience, cancellationToken) is { } refreshed) + { + return UserAccessTokenResult.Success(refreshed); + } + + var session = await store.Get(sessionId, cancellationToken); + return session is null + ? UserAccessTokenResult.Failed(UserAccessTokenFailure.NoRefreshToken) + : await Refresh(sessionId, session, audience, accessToken, cancellationToken); + } + catch (OperationCanceledException) when (operation.IsCancellationRequested) + { + return UserAccessTokenResult.Failed(UserAccessTokenFailure.ProviderUnavailable); + } + finally + { + if (acquired) + { + refreshLock.Release(); + } + } + } + async Task UsableCachedToken(string sessionId, string audience, CancellationToken cancellationToken) => await store.GetAccessToken(sessionId, audience, cancellationToken) is { } cached - && cached.ExpiresAt - RenewalMargin > timeProvider.GetUtcNow() + && cached.RenewAt > timeProvider.GetUtcNow() ? cached.Value : null; @@ -159,7 +177,7 @@ async Task Refresh( logger.RefreshRefused(session.Scheme, (int)response.StatusCode, error ?? "(none)"); if (string.Equals(error, "invalid_grant", StringComparison.Ordinal)) { - await store.Remove(sessionId, cancellationToken); + // invalid_grant may mean missing consent or resource-specific policy, not a dead session. return UserAccessTokenResult.Failed(UserAccessTokenFailure.RefreshTokenRejected); } @@ -176,19 +194,22 @@ async Task Refresh( if (StringProperty(root, "refresh_token") is { Length: > 0 } rotated && rotated != session.RefreshToken) { - await store.Update(sessionId, session with { RefreshToken = rotated }, cancellationToken); + await store.Update(sessionId, session with { RefreshToken = rotated }, CancellationToken.None); } var now = timeProvider.GetUtcNow(); var lifetime = Lifetime(root); var renewAt = now + lifetime - (lifetime > RenewalMargin * 2 ? RenewalMargin : lifetime / 2); - await store.SetAccessToken(sessionId, audience, new CachedUserAccessToken(token, now + lifetime), renewAt, cancellationToken); + await store.SetAccessToken(sessionId, audience, new CachedUserAccessToken(token, now + lifetime, renewAt), renewAt, CancellationToken.None); - return UserAccessTokenResult.Success(token); + // A logout during redemption must also prevent this request from forwarding the new token. + return await store.Get(sessionId, CancellationToken.None) is null + ? UserAccessTokenResult.Failed(UserAccessTokenFailure.NoRefreshToken) + : UserAccessTokenResult.Success(token); } catch (Exception exception) when ( exception is HttpRequestException or JsonException or OidcClientCredentialUnavailable or InvalidOperationException or IOException - || (exception is TaskCanceledException && !cancellationToken.IsCancellationRequested)) + || exception is OperationCanceledException) { logger.RefreshFailed(session.Scheme, exception); return UserAccessTokenResult.Failed(UserAccessTokenFailure.ProviderUnavailable); diff --git a/Source/AuthProxy/AccessTokens/UserTokenSessions.cs b/Source/AuthProxy/AccessTokens/UserTokenSessions.cs index c415a46f..4e0d8910 100644 --- a/Source/AuthProxy/AccessTokens/UserTokenSessions.cs +++ b/Source/AuthProxy/AccessTokens/UserTokenSessions.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using Cratis.AuthProxy.Links; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.Extensions.Options; @@ -11,7 +12,7 @@ namespace Cratis.AuthProxy.AccessTokens; /// /// Ties the server-side token session to the AuthProxy session cookie: created when a sign-in redeems its -/// authorization code, found again on each authenticated request, and removed when the session ends. +/// authorization code and issues its cookie, found again on each authenticated request, and removed when the session ends. /// /// /// The session cookie carries only an unguessable identifier, inside its encrypted ticket. The refresh token stays @@ -29,6 +30,8 @@ public static class UserTokenSessions /// internal const string HttpContextItemKey = "Cratis.AuthProxy.TokenSession"; + const string PendingSessionKey = "Cratis.AuthProxy.PendingTokenSession"; + /// /// Gets whether any service forwards user access tokens, which is the only case in which refresh tokens are kept. /// @@ -44,23 +47,23 @@ public static class UserTokenSessions public static string? Of(HttpContext context) => context.Items[HttpContextItemKey] as string; /// - /// Keeps the refresh token of a completed code redemption server-side and records its identifier on the session. + /// Holds the refresh token on the callback request until sign-in succeeds. /// /// The token-response context of the OIDC handler. /// A representing the asynchronous operation. - internal static async Task Capture(TokenResponseReceivedContext context) + internal static Task Capture(TokenResponseReceivedContext context) { var services = context.HttpContext.RequestServices; var config = services.GetRequiredService>().CurrentValue; if (!IsForwardingConfigured(config) || context.Properties is null) { - return; + return Task.CompletedTask; } // A link callback authenticates a second identity without signing it in, so it starts no session. if (context.Properties.Items.TryGetValue(LinkMiddleware.LinkModePropertyKey, out var linkMode) && linkMode == "true") { - return; + return Task.CompletedTask; } var refreshToken = context.TokenEndpointResponse.RefreshToken; @@ -69,11 +72,41 @@ internal static async Task Capture(TokenResponseReceivedContext context) services.GetRequiredService() .CreateLogger(typeof(UserTokenSessions)) .NoRefreshTokenIssued(context.Scheme.Name); - return; + return Task.CompletedTask; + } + + context.HttpContext.Items[PendingSessionKey] = new UserTokenSession(context.Scheme.Name, refreshToken); + return Task.CompletedTask; + } + + /// + /// Persists the pending token session only when a validated sign-in issues its cookie, replacing any old session. + /// + /// The cookie sign-in context. + /// A representing the asynchronous operation. + internal static async Task Complete(CookieSigningInContext context) + { + var previousSession = Of(context.HttpContext); + if (previousSession is null + && context.Options.Cookie.Name is { } cookieName + && context.HttpContext.Request.Cookies.ContainsKey(cookieName)) + { + var previousTicket = await context.HttpContext.AuthenticateAsync(context.Scheme.Name); + previousTicket.Properties?.Items.TryGetValue(PropertiesKey, out previousSession); } - var store = services.GetRequiredService(); - context.Properties.Items[PropertiesKey] = await store.Create(new(context.Scheme.Name, refreshToken), context.HttpContext.RequestAborted); + if (previousSession is not null) + { + await context.HttpContext.RequestServices.GetRequiredService().Remove(previousSession, CancellationToken.None); + context.HttpContext.Items.Remove(HttpContextItemKey); + } + + context.Properties.Items.Remove(PropertiesKey); + if (context.HttpContext.Items.Remove(PendingSessionKey, out var pending) && pending is UserTokenSession session) + { + var store = context.HttpContext.RequestServices.GetRequiredService(); + context.Properties.Items[PropertiesKey] = await store.Create(session, CancellationToken.None); + } } /// diff --git a/Source/AuthProxy/AccessTokens/UserTokenStore.cs b/Source/AuthProxy/AccessTokens/UserTokenStore.cs index 41bfe403..a8b2dba3 100644 --- a/Source/AuthProxy/AccessTokens/UserTokenStore.cs +++ b/Source/AuthProxy/AccessTokens/UserTokenStore.cs @@ -32,13 +32,15 @@ public sealed class UserTokenStore( { const string KeyPrefix = "Cratis.AuthProxy.UserTokens:"; + readonly SemaphoreSlim[] _mutationLocks = [.. Enumerable.Range(0, 64).Select(_ => new SemaphoreSlim(1, 1))]; readonly IDataProtector _protector = dataProtection.CreateProtector("Cratis.AuthProxy.UserTokens.v1"); /// public async Task Create(UserTokenSession session, CancellationToken cancellationToken) { var sessionId = WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(32)); - await Write(SessionKey(sessionId), new StoredSession(session.Scheme, session.RefreshToken, []), SessionEntryOptions(), cancellationToken); + var options = SessionEntryOptions(); + await Write(SessionKey(sessionId), new StoredSession(session.Scheme, session.RefreshToken, [], options.AbsoluteExpiration), options, cancellationToken); return sessionId; } @@ -51,46 +53,78 @@ await Read(SessionKey(sessionId), cancellationToken) is { } store /// public async Task Update(string sessionId, UserTokenSession session, CancellationToken cancellationToken) { - var audiences = (await Read(SessionKey(sessionId), cancellationToken))?.Audiences ?? []; - await Write(SessionKey(sessionId), new StoredSession(session.Scheme, session.RefreshToken, audiences), SessionEntryOptions(), cancellationToken); + var mutationLock = MutationLock(sessionId); + await mutationLock.WaitAsync(cancellationToken); + try + { + if (await Read(SessionKey(sessionId), cancellationToken) is { } stored) + { + await Write(SessionKey(sessionId), stored with { Scheme = session.Scheme, RefreshToken = session.RefreshToken }, SessionEntryOptions(stored.ExpiresAt), cancellationToken); + } + } + finally + { + mutationLock.Release(); + } } /// public async Task Remove(string sessionId, CancellationToken cancellationToken) { - var stored = await Read(SessionKey(sessionId), cancellationToken); - foreach (var audience in stored?.Audiences ?? []) + var mutationLock = MutationLock(sessionId); + await mutationLock.WaitAsync(cancellationToken); + try { - await cache.RemoveAsync(AccessTokenKey(sessionId, audience), cancellationToken); - } + var stored = await Read(SessionKey(sessionId), cancellationToken); + foreach (var audience in stored?.Audiences ?? []) + { + await cache.RemoveAsync(AccessTokenKey(sessionId, audience), cancellationToken); + } - await cache.RemoveAsync(SessionKey(sessionId), cancellationToken); + await cache.RemoveAsync(SessionKey(sessionId), cancellationToken); + } + finally + { + mutationLock.Release(); + } } /// - public Task GetAccessToken(string sessionId, string audience, CancellationToken cancellationToken) => - Read(AccessTokenKey(sessionId, audience), cancellationToken); + public async Task GetAccessToken(string sessionId, string audience, CancellationToken cancellationToken) => + await Get(sessionId, cancellationToken) is not null + ? await Read(AccessTokenKey(sessionId, audience), cancellationToken) + : null; /// public async Task SetAccessToken(string sessionId, string audience, CachedUserAccessToken token, DateTimeOffset renewAt, CancellationToken cancellationToken) { - var stored = await Read(SessionKey(sessionId), cancellationToken); - if (stored is null) + var mutationLock = MutationLock(sessionId); + await mutationLock.WaitAsync(cancellationToken); + try { - // The session ended while the token was being obtained; keep nothing for it. - return; + var stored = await Read(SessionKey(sessionId), cancellationToken); + if (stored is null) + { + // The session ended while the token was being obtained; keep nothing for it. + return; + } + + if (!stored.Audiences.Contains(audience, StringComparer.Ordinal)) + { + await Write(SessionKey(sessionId), stored with { Audiences = [.. stored.Audiences, audience] }, SessionEntryOptions(stored.ExpiresAt), cancellationToken); + } + + var expiresAt = stored.ExpiresAt is { } sessionExpiry && sessionExpiry < renewAt ? sessionExpiry : renewAt; + await Write( + AccessTokenKey(sessionId, audience), + token, + new DistributedCacheEntryOptions { AbsoluteExpiration = expiresAt }, + cancellationToken); } - - if (!stored.Audiences.Contains(audience, StringComparer.Ordinal)) + finally { - await Write(SessionKey(sessionId), stored with { Audiences = [.. stored.Audiences, audience] }, SessionEntryOptions(), cancellationToken); + mutationLock.Release(); } - - await Write( - AccessTokenKey(sessionId, audience), - token, - new DistributedCacheEntryOptions { AbsoluteExpiration = renewAt }, - cancellationToken); } static string SessionKey(string sessionId) => $"{KeyPrefix}{Hash(sessionId)}"; @@ -105,8 +139,15 @@ await Write( /// The derived key. static string Hash(string value) => WebEncoders.Base64UrlEncode(SHA256.HashData(Encoding.UTF8.GetBytes(value))); - DistributedCacheEntryOptions SessionEntryOptions() + SemaphoreSlim MutationLock(string sessionId) => _mutationLocks[(uint)StringComparer.Ordinal.GetHashCode(sessionId) % (uint)_mutationLocks.Length]; + + DistributedCacheEntryOptions SessionEntryOptions(DateTimeOffset? expiresAt = null) { + if (expiresAt is not null) + { + return new DistributedCacheEntryOptions { AbsoluteExpiration = expiresAt }; + } + var current = session.CurrentValue.Session; var lifetime = current.Lifetime > TimeSpan.Zero ? current.Lifetime : C.Session.DefaultLifetime; return current.SlidingExpiration @@ -137,5 +178,5 @@ async Task Write(string key, T value, DistributedCacheEntryOptions options, C } } - sealed record StoredSession(string Scheme, string RefreshToken, string[] Audiences); + sealed record StoredSession(string Scheme, string RefreshToken, string[] Audiences, DateTimeOffset? ExpiresAt); } diff --git a/Source/AuthProxy/Authentication/AuthenticationServiceCollectionExtensions.cs b/Source/AuthProxy/Authentication/AuthenticationServiceCollectionExtensions.cs index 390d1445..200f9328 100644 --- a/Source/AuthProxy/Authentication/AuthenticationServiceCollectionExtensions.cs +++ b/Source/AuthProxy/Authentication/AuthenticationServiceCollectionExtensions.cs @@ -165,6 +165,13 @@ static void ConfigureCookieOptions(CookieAuthenticationOptions options, C.Sessio } }; + var existingSigningIn = options.Events.OnSigningIn; + options.Events.OnSigningIn = async context => + { + await existingSigningIn(context); + await UserTokenSessions.Complete(context); + }; + var existingSigningOut = options.Events.OnSigningOut; options.Events.OnSigningOut = async context => { From 4e36334f8cd50854ae23f5d6a59321f73ce7f563 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 1 Oct 2026 18:18:27 +0200 Subject: [PATCH 05/13] Resolve managed identity audiences from MSAL cloud metadata --- ...d_identity_credentials_for_sovereign_clouds.cs | 12 ++++++++++++ .../OidcClientCredentialDescription.cs | 15 +++++++-------- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_loading_managed_identity_credentials_for_sovereign_clouds.cs b/Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_loading_managed_identity_credentials_for_sovereign_clouds.cs index 88784e4c..3444ae62 100644 --- a/Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_loading_managed_identity_credentials_for_sovereign_clouds.cs +++ b/Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_loading_managed_identity_credentials_for_sovereign_clouds.cs @@ -16,7 +16,13 @@ async Task Because() { ("https://login.microsoftonline.us/tenant/v2.0", ""), ("https://login.chinacloudapi.cn/tenant/v2.0", ""), + ("https://login.partner.microsoftonline.cn/tenant/v2.0", ""), + ("https://login.usgovcloudapi.net/tenant/v2.0", ""), + ("https://login.sovcloud-identity.fr/tenant/v2.0", ""), + ("https://login.sovcloud-identity.de/tenant/v2.0", ""), + ("https://login.sovcloud-identity.sg/tenant/v2.0", ""), ("https://login.microsoftonline.com/tenant/v2.0", ""), + ("https://login.example.com/tenant/v2.0", ""), ("https://login.microsoftonline.us/tenant/v2.0", "api://custom-exchange") }) { @@ -51,6 +57,12 @@ void should_resolve_the_audience_before_initial_and_subsequent_loads() => _audie { "api://AzureADTokenExchangeUSGov", "api://AzureADTokenExchangeUSGov", "api://AzureADTokenExchangeChina", "api://AzureADTokenExchangeChina", + "api://AzureADTokenExchangeChina", "api://AzureADTokenExchangeChina", + "api://AzureADTokenExchangeUSGov", "api://AzureADTokenExchangeUSGov", + "api://AzureADTokenExchangeFrance", "api://AzureADTokenExchangeFrance", + "api://AzureADTokenExchangeGermany", "api://AzureADTokenExchangeGermany", + "api://AzureADTokenExchangeGovSG", "api://AzureADTokenExchangeGovSG", + "api://AzureADTokenExchange", "api://AzureADTokenExchange", "api://AzureADTokenExchange", "api://AzureADTokenExchange", "api://custom-exchange", "api://custom-exchange" }); diff --git a/Source/AuthProxy/Authentication/OidcClientCredentialDescription.cs b/Source/AuthProxy/Authentication/OidcClientCredentialDescription.cs index 37c45270..0717e790 100644 --- a/Source/AuthProxy/Authentication/OidcClientCredentialDescription.cs +++ b/Source/AuthProxy/Authentication/OidcClientCredentialDescription.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Client.Instance.Discovery; using C = Cratis.AuthProxy.Configuration; namespace Cratis.AuthProxy.Authentication; @@ -57,14 +58,12 @@ static class OidcClientCredentialDescription _ => throw new OidcClientCredentialUnavailable($"The client credential source '{credential.Source}' does not use a client assertion.") }; - static string TokenExchangeAudienceOf(string authority) => Uri.TryCreate(authority, UriKind.Absolute, out var uri) - ? uri.Host.ToLowerInvariant() switch - { - "login.microsoftonline.us" => "api://AzureADTokenExchangeUSGov", - "login.chinacloudapi.cn" => "api://AzureADTokenExchangeChina", - _ => "api://AzureADTokenExchange" - } - : "api://AzureADTokenExchange"; + static string TokenExchangeAudienceOf(string authority) => + Uri.TryCreate(authority, UriKind.Absolute, out var uri) && + KnownCloudMetadata.Default.GetByAuthorityHost(uri.Host) is { } metadata && + metadata.TryGetValue(Microsoft.Identity.Client.Instance.Discovery.CloudMetadataKeyNames.FederatedCredentialAudience, out var audience) + ? audience + : "api://AzureADTokenExchange"; static string? NullIfEmpty(string value) => string.IsNullOrWhiteSpace(value) ? null : value; } From 928f6766d806e6dae9639688fcf53fde9b6599ef Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 1 Oct 2026 18:18:27 +0200 Subject: [PATCH 06/13] Throttle unavailable certificate reloads after expiry --- ...pired_certificate_reload_is_unavailable.cs | 84 +++++++++++++++++++ .../Authentication/OidcClientAssertions.cs | 16 +++- 2 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_the_expired_certificate_reload_is_unavailable.cs diff --git a/Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_the_expired_certificate_reload_is_unavailable.cs b/Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_the_expired_certificate_reload_is_unavailable.cs new file mode 100644 index 00000000..bd5b0186 --- /dev/null +++ b/Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_the_expired_certificate_reload_is_unavailable.cs @@ -0,0 +1,84 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.Authentication.given; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Identity.Abstractions; + +namespace Cratis.AuthProxy.Authentication.for_OidcClientAssertions; + +public class when_the_expired_certificate_reload_is_unavailable : Specification +{ + readonly List _failures = []; + readonly List _loadCounts = []; + readonly List _recoveredAssertions = []; + + async Task Because() + { + foreach (var reloadThrows in new[] { false, true }) + { + var clock = new AdjustableTimeProvider(DateTimeOffset.UtcNow); + using var expired = ClientCertificates.Rsa(clock.Now.AddDays(-30), clock.Now.AddDays(-1)); + using var replacement = ClientCertificates.Rsa(clock.Now.AddDays(-1), clock.Now.AddDays(30)); + var loads = 0; + var loader = Substitute.For(); + loader.LoadCredentialsIfNeededAsync(Arg.Any(), Arg.Any()) + .Returns(call => + { + loads++; + var description = call.Arg(); + if (loads == 1 || loads == 4) + { + description.Certificate = loads == 1 ? expired : replacement; + description.CachedValue = description.Certificate; + return Task.CompletedTask; + } + + return reloadThrows + ? Task.FromException(new OidcClientCredentialUnavailable("The certificate store is unavailable.")) + : Task.CompletedTask; + }); + loader.When(_ => _.ResetCredentials(Arg.Any>())) + .Do(call => + { + var description = call.Arg>().Single(); + description.Certificate = null; + description.CachedValue = null; + }); + var provider = new C.OidcProvider + { + Name = "Workforce", + ClientId = "client-id", + Authority = "https://login.example.com/tenant", + ClientCredential = new() { Source = C.OidcClientCredentialSource.CertificateStore } + }; + var assertions = new OidcClientAssertions(loader, clock, NullLogger.Instance); + + _failures.Add(await Catch.Exception(() => assertions.Create("workforce", provider, provider.Authority, CancellationToken.None))); + _loadCounts.Add(loads); + clock.Now = clock.Now.AddSeconds(59); + _failures.Add(await Catch.Exception(() => assertions.Create("workforce", provider, provider.Authority, CancellationToken.None))); + _loadCounts.Add(loads); + clock.Now = clock.Now.AddSeconds(1); + _failures.Add(await Catch.Exception(() => assertions.Create("workforce", provider, provider.Authority, CancellationToken.None))); + _loadCounts.Add(loads); + clock.Now = clock.Now.AddSeconds(59); + _failures.Add(await Catch.Exception(() => assertions.Create("workforce", provider, provider.Authority, CancellationToken.None))); + _loadCounts.Add(loads); + clock.Now = clock.Now.AddSeconds(1); + _recoveredAssertions.Add(await assertions.Create("workforce", provider, provider.Authority, CancellationToken.None)); + _loadCounts.Add(loads); + } + } + + [Fact] void should_fail_closed_for_null_and_failed_reloads() => _failures.TrueForAll(_ => _ is OidcClientCredentialUnavailable).ShouldBeTrue(); + [Fact] void should_load_only_once_per_minute_after_the_expiry_reset() => _loadCounts.ShouldEqual(new[] { 2, 2, 3, 3, 4, 2, 2, 3, 3, 4 }); + [Fact] void should_resume_signing_when_a_later_reload_succeeds() => _recoveredAssertions.TrueForAll(_ => !string.IsNullOrWhiteSpace(_)).ShouldBeTrue(); + + sealed class AdjustableTimeProvider(DateTimeOffset now) : TimeProvider + { + internal DateTimeOffset Now { get; set; } = now; + + public override DateTimeOffset GetUtcNow() => Now; + } +} diff --git a/Source/AuthProxy/Authentication/OidcClientAssertions.cs b/Source/AuthProxy/Authentication/OidcClientAssertions.cs index aba1177a..f8ac4200 100644 --- a/Source/AuthProxy/Authentication/OidcClientAssertions.cs +++ b/Source/AuthProxy/Authentication/OidcClientAssertions.cs @@ -44,9 +44,21 @@ public async Task Create(string scheme, C.OidcProvider provider, string try { var description = state.Description; - await Load(description, provider); - var now = timeProvider.GetUtcNow(); + if (description.Certificate is null && state.NextCertificateReload != default) + { + // An expiry-triggered reload can return no certificate or fail. Throttle those retries too. + if (now < state.NextCertificateReload) + { + throw new OidcClientCredentialUnavailable( + $"The {credential.Source} client credential of OIDC provider '{provider.Name}' could not be loaded."); + } + + state.NextCertificateReload = now.AddMinutes(1); + } + + await Load(description, provider); + now = timeProvider.GetUtcNow(); var certificate = description.Certificate; if (certificate is not null && certificate.NotAfter.ToUniversalTime() <= now.UtcDateTime && now >= state.NextCertificateReload) { From d236c49ec13f9153b975134436c03cb68ec981f9 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 1 Oct 2026 18:36:07 +0200 Subject: [PATCH 07/13] Cache refused audience refreshes and retain active sliding token sessions --- Documentation/configuration/services.md | 7 ++- ...hen_a_refresh_rejection_backoff_expires.cs | 24 +++++++++ ...nt_requests_receive_a_refresh_rejection.cs | 31 +++++++++++ ..._the_provider_rejects_the_refresh_token.cs | 4 ++ .../given/a_cookie_token_session.cs | 49 +++++++++++++++++ ...ing_cookie_is_active_without_forwarding.cs | 24 +++++++++ ...ute_cookie_is_active_without_forwarding.cs | 27 ++++++++++ .../when_a_session_is_removed.cs | 5 ++ .../AuthProxy/AccessTokens/IUserTokenStore.cs | 19 +++++++ .../AccessTokens/UserAccessTokens.cs | 26 +++++++--- .../AccessTokens/UserTokenSessions.cs | 13 +++++ .../AuthProxy/AccessTokens/UserTokenStore.cs | 52 +++++++++++++------ ...thenticationServiceCollectionExtensions.cs | 2 + 13 files changed, 256 insertions(+), 27 deletions(-) create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_a_refresh_rejection_backoff_expires.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_concurrent_requests_receive_a_refresh_rejection.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/given/a_cookie_token_session.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_sliding_cookie_is_active_without_forwarding.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_an_absolute_cookie_is_active_without_forwarding.cs diff --git a/Documentation/configuration/services.md b/Documentation/configuration/services.md index 3ad5efcf..644e51f5 100644 --- a/Documentation/configuration/services.md +++ b/Documentation/configuration/services.md @@ -468,7 +468,8 @@ provider must be configured. AuthProxy refuses to start otherwise. token AuthProxy cannot get access tokens, and logs a warning at each such sign-in. - Signing out removes the refresh token and every access token kept for the session, even when a refresh is in flight. Signing in again replaces the previous token session. Rotation and new audiences do not - extend an absolute session's original retention deadline. + extend an absolute session's original retention deadline. When `Session.SlidingExpiration` is enabled, + authenticated cookie activity renews token retention even on frontend or non-forwarding routes. ### What is forwarded, and when it is refused @@ -481,7 +482,9 @@ provider must be configured. AuthProxy refuses to start otherwise. This happens when the session has no refresh token, the provider rejects the refresh token, the provider cannot be reached, or the user signed in with another provider than `Provider`. An `invalid_grant` error refuses that audience without discarding the session or other audiences: it can mean missing consent - or a resource-specific policy rather than an expired refresh token. The frontend should treat the `401` + or a resource-specific policy rather than an expired refresh token. Rejections are cached for 30 seconds + per session and audience to avoid repeatedly redeeming the same refused refresh token. Signing in again + clears the previous session's rejections. The frontend should treat the `401` as a signal to sign in again through `/.cratis/login/{scheme}`; a missing consent or policy requirement may also need to be addressed at the provider. diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_a_refresh_rejection_backoff_expires.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_a_refresh_rejection_backoff_expires.cs new file mode 100644 index 00000000..12023569 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_a_refresh_rejection_backoff_expires.cs @@ -0,0 +1,24 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.AccessTokens.given; + +namespace Cratis.AuthProxy.AccessTokens.for_UserAccessTokens; + +public class when_a_refresh_rejection_backoff_expires : given.user_access_tokens +{ + UserAccessTokenResult _result; + + async Task Establish() + { + _endpoint.Answer = () => TokenEndpoint.Error("invalid_grant"); + await Get(); + _time.Advance(TimeSpan.FromSeconds(30)); + _endpoint.Answer = () => TokenEndpoint.Bearer("renewed-token", 3600); + } + + async Task Because() => _result = await Get(); + + [Fact] void should_retry_the_provider() => _endpoint.Received.Count.ShouldEqual(2); + [Fact] void should_forward_the_new_token() => _result.Token.ShouldEqual("renewed-token"); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_concurrent_requests_receive_a_refresh_rejection.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_concurrent_requests_receive_a_refresh_rejection.cs new file mode 100644 index 00000000..a355e1b0 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_concurrent_requests_receive_a_refresh_rejection.cs @@ -0,0 +1,31 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.AccessTokens.given; + +namespace Cratis.AuthProxy.AccessTokens.for_UserAccessTokens; + +public class when_concurrent_requests_receive_a_refresh_rejection : given.user_access_tokens +{ + UserAccessTokenResult[] _results; + + async Task Because() + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _endpoint.AnswerAsync = async cancellationToken => + { + entered.SetResult(); + await release.Task.WaitAsync(cancellationToken); + return TokenEndpoint.Error("invalid_grant"); + }; + var first = Get(); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5), TimeProvider.System); + var second = Get(); + release.SetResult(); + _results = await Task.WhenAll(first, second).WaitAsync(TimeSpan.FromSeconds(5), TimeProvider.System); + } + + [Fact] void should_redeem_only_once() => _endpoint.Received.Count.ShouldEqual(1); + [Fact] void should_refuse_both_requests() => _results.All(_ => _.Failure == UserAccessTokenFailure.RefreshTokenRejected).ShouldBeTrue(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_provider_rejects_the_refresh_token.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_provider_rejects_the_refresh_token.cs index 1b016ab4..4758f9ce 100644 --- a/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_provider_rejects_the_refresh_token.cs +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_provider_rejects_the_refresh_token.cs @@ -8,15 +8,19 @@ namespace Cratis.AuthProxy.AccessTokens.for_UserAccessTokens; public class when_the_provider_rejects_the_refresh_token : given.user_access_tokens { UserAccessTokenResult _result; + UserAccessTokenResult _second; UserTokenSession? _session; async Task Because() { _endpoint.Answer = () => TokenEndpoint.Error("invalid_grant"); _result = await Get(); + _second = await Get(); _session = await _store.Get(_sessionId, CancellationToken.None); } [Fact] void should_fail() => _result.Failure.ShouldEqual(UserAccessTokenFailure.RefreshTokenRejected); + [Fact] void should_reject_the_next_request_without_redeeming_again() => _second.Failure.ShouldEqual(UserAccessTokenFailure.RefreshTokenRejected); + [Fact] void should_call_the_provider_only_once() => _endpoint.Received.Count.ShouldEqual(1); [Fact] void should_keep_the_session_for_other_audiences() => _session.ShouldNotBeNull(); } diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/given/a_cookie_token_session.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/given/a_cookie_token_session.cs new file mode 100644 index 00000000..06ba4799 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/given/a_cookie_token_session.cs @@ -0,0 +1,49 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.Authentication; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Cratis.AuthProxy.AccessTokens.for_UserTokenSessions.given; + +public class a_cookie_token_session : for_UserAccessTokens.given.user_access_tokens +{ + protected CookieAuthenticationOptions _cookie; + ServiceProvider _services; + + async Task Establish() + { + _config.Session.Lifetime = TimeSpan.FromMinutes(1); + _config.Session.SlidingExpiration = true; + _sessionId = await _store.Create(new(Scheme, "refresh-token"), CancellationToken.None); + var builder = WebApplication.CreateBuilder(); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + [$"{C.Session.SectionKey}:Lifetime"] = "00:01:00", + [$"{C.Session.SectionKey}:SlidingExpiration"] = "true", + }); + builder.AddIngressAuthentication(); + builder.Services.AddSingleton(_store); + _services = builder.Services.BuildServiceProvider(); + _cookie = _services.GetRequiredService>().Get("Cookies"); + } + + protected async Task ValidateCookie() + { + var properties = new AuthenticationProperties(); + properties.Items[UserTokenSessions.PropertiesKey] = _sessionId; + var context = new DefaultHttpContext { RequestServices = _services }; + context.Request.Path = "/frontend"; + await _cookie.Events.ValidatePrincipal(new CookieValidatePrincipalContext( + context, + new AuthenticationScheme("Cookies", null, typeof(CookieAuthenticationHandler)), + _cookie, + new AuthenticationTicket(new ClaimsPrincipal(new ClaimsIdentity("Cookies")), properties, "Cookies"))); + } + + void Destroy() => _services.Dispose(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_sliding_cookie_is_active_without_forwarding.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_sliding_cookie_is_active_without_forwarding.cs new file mode 100644 index 00000000..91a8433f --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_sliding_cookie_is_active_without_forwarding.cs @@ -0,0 +1,24 @@ +// 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.AccessTokens.for_UserTokenSessions; + +public class when_a_sliding_cookie_is_active_without_forwarding : given.a_cookie_token_session +{ + UserAccessTokenResult _result; + + async Task Because() + { + for (var request = 0; request < 3; request++) + { + _time.Advance(TimeSpan.FromSeconds(40)); + await ValidateCookie(); + } + + _time.Advance(TimeSpan.FromSeconds(40)); + _result = await Get(); + } + + [Fact] void should_still_forward_the_users_token_after_the_original_lifetime() => _result.Token.ShouldEqual("access-token"); + [Fact] void should_redeem_only_for_the_forwarding_request() => _endpoint.Received.Count.ShouldEqual(1); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_an_absolute_cookie_is_active_without_forwarding.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_an_absolute_cookie_is_active_without_forwarding.cs new file mode 100644 index 00000000..ddc8c92c --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_an_absolute_cookie_is_active_without_forwarding.cs @@ -0,0 +1,27 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_UserTokenSessions; + +public class when_an_absolute_cookie_is_active_without_forwarding : given.a_cookie_token_session +{ + UserAccessTokenResult _result; + + async Task Establish() + { + _cookie.SlidingExpiration = false; + _config.Session.SlidingExpiration = false; + _sessionId = await _store.Create(new(Scheme, "refresh-token"), CancellationToken.None); + } + + async Task Because() + { + _time.Advance(TimeSpan.FromSeconds(40)); + await ValidateCookie(); + _time.Advance(TimeSpan.FromSeconds(40)); + _result = await Get(); + } + + [Fact] void should_not_extend_the_absolute_token_session() => _result.Failure.ShouldEqual(UserAccessTokenFailure.NoRefreshToken); + [Fact] void should_not_call_the_provider() => _endpoint.Received.ShouldBeEmpty(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_a_session_is_removed.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_a_session_is_removed.cs index a3fc6bfc..b7206263 100644 --- a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_a_session_is_removed.cs +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_a_session_is_removed.cs @@ -8,10 +8,12 @@ public class when_a_session_is_removed : given.a_user_token_store string _sessionId; UserTokenSession? _session; CachedUserAccessToken? _accessToken; + bool _rejected; async Task Establish() { _sessionId = await _store.Create(new("workforce", "refresh-token"), CancellationToken.None); + await _store.SetRefreshRejected(_sessionId, "refused-audience", _time.GetUtcNow().AddSeconds(30), CancellationToken.None); await _store.SetAccessToken(_sessionId, "audience", new("access-token", _time.GetUtcNow().AddHours(1), _time.GetUtcNow().AddMinutes(59)), _time.GetUtcNow().AddMinutes(59), CancellationToken.None); } @@ -19,9 +21,12 @@ async Task Because() { await _store.Remove(_sessionId, CancellationToken.None); _session = await _store.Get(_sessionId, CancellationToken.None); + _rejected = await _store.IsRefreshRejected(_sessionId, "refused-audience", CancellationToken.None); _accessToken = await _store.GetAccessToken(_sessionId, "audience", CancellationToken.None); } + [Fact] void should_forget_the_refresh_rejections() => _rejected.ShouldBeFalse(); + [Fact] void should_remove_every_cached_entry() => _cache.Written.Keys.All(_ => _cache.Get(_) is null).ShouldBeTrue(); [Fact] void should_forget_the_refresh_token() => _session.ShouldBeNull(); [Fact] void should_forget_the_access_tokens_obtained_for_it() => _accessToken.ShouldBeNull(); } diff --git a/Source/AuthProxy/AccessTokens/IUserTokenStore.cs b/Source/AuthProxy/AccessTokens/IUserTokenStore.cs index 2916a07f..87636468 100644 --- a/Source/AuthProxy/AccessTokens/IUserTokenStore.cs +++ b/Source/AuthProxy/AccessTokens/IUserTokenStore.cs @@ -64,4 +64,23 @@ public interface IUserTokenStore /// The for the operation. /// A representing the asynchronous operation. Task SetAccessToken(string sessionId, string audience, CachedUserAccessToken token, DateTimeOffset renewAt, CancellationToken cancellationToken); + + /// + /// Gets whether a refresh for this session and audience was recently rejected. + /// + /// The session identifier. + /// The audience key. + /// The for the operation. + /// Whether the audience must wait before retrying its refresh. + Task IsRefreshRejected(string sessionId, string audience, CancellationToken cancellationToken); + + /// + /// Records a refused refresh for a session and audience until it may be retried. + /// + /// The session identifier. + /// The audience key. + /// When a refresh may be retried. + /// The for the operation. + /// A representing the asynchronous operation. + Task SetRefreshRejected(string sessionId, string audience, DateTimeOffset retryAt, CancellationToken cancellationToken); } diff --git a/Source/AuthProxy/AccessTokens/UserAccessTokens.cs b/Source/AuthProxy/AccessTokens/UserAccessTokens.cs index 7a9014c9..409dd903 100644 --- a/Source/AuthProxy/AccessTokens/UserAccessTokens.cs +++ b/Source/AuthProxy/AccessTokens/UserAccessTokens.cs @@ -54,6 +54,8 @@ public sealed class UserAccessTokens( /// public static readonly TimeSpan DefaultLifetime = TimeSpan.FromMinutes(5); + static readonly TimeSpan _refreshRejectionBackoff = TimeSpan.FromSeconds(30); + readonly SemaphoreSlim[] _refreshLocks = [.. Enumerable.Range(0, 64).Select(_ => new SemaphoreSlim(1, 1))]; /// @@ -72,9 +74,9 @@ public async Task GetFor(string sessionId, C.ServiceAcces } var audience = AudienceKey(session.Scheme, accessToken); - if (await UsableCachedToken(sessionId, audience, cancellationToken) is { } cached) + if (await CachedResult(sessionId, audience, cancellationToken) is { } cached) { - return UserAccessTokenResult.Success(cached); + return cached; } // The caller may stop waiting, but a started redemption must finish and persist any rotation. @@ -113,9 +115,9 @@ async Task RefreshUnderLock(string sessionId, string audi acquired = true; // Another request for this session may have refreshed while this one waited. - if (await UsableCachedToken(sessionId, audience, cancellationToken) is { } refreshed) + if (await CachedResult(sessionId, audience, cancellationToken) is { } refreshed) { - return UserAccessTokenResult.Success(refreshed); + return refreshed; } var session = await store.Get(sessionId, cancellationToken); @@ -136,11 +138,18 @@ async Task RefreshUnderLock(string sessionId, string audi } } - async Task UsableCachedToken(string sessionId, string audience, CancellationToken cancellationToken) => - await store.GetAccessToken(sessionId, audience, cancellationToken) is { } cached - && cached.RenewAt > timeProvider.GetUtcNow() - ? cached.Value + async Task CachedResult(string sessionId, string audience, CancellationToken cancellationToken) + { + if (await store.GetAccessToken(sessionId, audience, cancellationToken) is { } cached + && cached.RenewAt > timeProvider.GetUtcNow()) + { + return UserAccessTokenResult.Success(cached.Value); + } + + return await store.IsRefreshRejected(sessionId, audience, cancellationToken) + ? UserAccessTokenResult.Failed(UserAccessTokenFailure.RefreshTokenRejected) : null; + } async Task Refresh( string sessionId, @@ -178,6 +187,7 @@ async Task Refresh( if (string.Equals(error, "invalid_grant", StringComparison.Ordinal)) { // invalid_grant may mean missing consent or resource-specific policy, not a dead session. + await store.SetRefreshRejected(sessionId, audience, timeProvider.GetUtcNow() + _refreshRejectionBackoff, CancellationToken.None); return UserAccessTokenResult.Failed(UserAccessTokenFailure.RefreshTokenRejected); } diff --git a/Source/AuthProxy/AccessTokens/UserTokenSessions.cs b/Source/AuthProxy/AccessTokens/UserTokenSessions.cs index 4e0d8910..e561cc2f 100644 --- a/Source/AuthProxy/AccessTokens/UserTokenSessions.cs +++ b/Source/AuthProxy/AccessTokens/UserTokenSessions.cs @@ -121,6 +121,19 @@ internal static void Remember(CookieValidatePrincipalContext context) } } + /// + /// Keeps sliding token retention aligned with a successfully validated cookie, even on non-forwarding routes. + /// + /// The cookie validation context. + /// A representing the asynchronous operation. + internal static async Task RenewRetention(CookieValidatePrincipalContext context) + { + if (context.Principal is not null && context.Options.SlidingExpiration && Of(context.HttpContext) is { } sessionId) + { + await context.HttpContext.RequestServices.GetRequiredService().Get(sessionId, context.HttpContext.RequestAborted); + } + } + /// /// Removes the token session of a session that is being signed out. /// diff --git a/Source/AuthProxy/AccessTokens/UserTokenStore.cs b/Source/AuthProxy/AccessTokens/UserTokenStore.cs index a8b2dba3..38f8f9c2 100644 --- a/Source/AuthProxy/AccessTokens/UserTokenStore.cs +++ b/Source/AuthProxy/AccessTokens/UserTokenStore.cs @@ -79,6 +79,7 @@ public async Task Remove(string sessionId, CancellationToken cancellationToken) foreach (var audience in stored?.Audiences ?? []) { await cache.RemoveAsync(AccessTokenKey(sessionId, audience), cancellationToken); + await cache.RemoveAsync(RefreshRejectionKey(sessionId, audience), cancellationToken); } await cache.RemoveAsync(SessionKey(sessionId), cancellationToken); @@ -96,7 +97,34 @@ await Get(sessionId, cancellationToken) is not null : null; /// - public async Task SetAccessToken(string sessionId, string audience, CachedUserAccessToken token, DateTimeOffset renewAt, CancellationToken cancellationToken) + public Task SetAccessToken(string sessionId, string audience, CachedUserAccessToken token, DateTimeOffset renewAt, CancellationToken cancellationToken) => + SetAudienceEntry(sessionId, audience, AccessTokenKey(sessionId, audience), token, renewAt, cancellationToken); + + /// + public async Task IsRefreshRejected(string sessionId, string audience, CancellationToken cancellationToken) => + await Get(sessionId, cancellationToken) is not null + && await Read(RefreshRejectionKey(sessionId, audience), cancellationToken) is { } rejection + && rejection.RetryAt > timeProvider.GetUtcNow(); + + /// + public Task SetRefreshRejected(string sessionId, string audience, DateTimeOffset retryAt, CancellationToken cancellationToken) => + SetAudienceEntry(sessionId, audience, RefreshRejectionKey(sessionId, audience), new RefreshRejection(retryAt), retryAt, cancellationToken); + + static string SessionKey(string sessionId) => $"{KeyPrefix}{Hash(sessionId)}"; + + static string AccessTokenKey(string sessionId, string audience) => $"{KeyPrefix}{Hash(sessionId)}:{audience}"; + + static string RefreshRejectionKey(string sessionId, string audience) => $"{AccessTokenKey(sessionId, audience)}:rejected"; + + /// + /// Derives the cache key from the identifier rather than using it, so a cache that can be listed does not hand + /// out the value the cookie proves possession with. + /// + /// The identifier. + /// The derived key. + static string Hash(string value) => WebEncoders.Base64UrlEncode(SHA256.HashData(Encoding.UTF8.GetBytes(value))); + + async Task SetAudienceEntry(string sessionId, string audience, string key, T value, DateTimeOffset expiresAt, CancellationToken cancellationToken) { var mutationLock = MutationLock(sessionId); await mutationLock.WaitAsync(cancellationToken); @@ -114,11 +142,11 @@ public async Task SetAccessToken(string sessionId, string audience, CachedUserAc await Write(SessionKey(sessionId), stored with { Audiences = [.. stored.Audiences, audience] }, SessionEntryOptions(stored.ExpiresAt), cancellationToken); } - var expiresAt = stored.ExpiresAt is { } sessionExpiry && sessionExpiry < renewAt ? sessionExpiry : renewAt; + var expiry = stored.ExpiresAt is { } sessionExpiry && sessionExpiry < expiresAt ? sessionExpiry : expiresAt; await Write( - AccessTokenKey(sessionId, audience), - token, - new DistributedCacheEntryOptions { AbsoluteExpiration = expiresAt }, + key, + value, + new DistributedCacheEntryOptions { AbsoluteExpiration = expiry }, cancellationToken); } finally @@ -127,18 +155,6 @@ await Write( } } - static string SessionKey(string sessionId) => $"{KeyPrefix}{Hash(sessionId)}"; - - static string AccessTokenKey(string sessionId, string audience) => $"{KeyPrefix}{Hash(sessionId)}:{audience}"; - - /// - /// Derives the cache key from the identifier rather than using it, so a cache that can be listed does not hand - /// out the value the cookie proves possession with. - /// - /// The identifier. - /// The derived key. - static string Hash(string value) => WebEncoders.Base64UrlEncode(SHA256.HashData(Encoding.UTF8.GetBytes(value))); - SemaphoreSlim MutationLock(string sessionId) => _mutationLocks[(uint)StringComparer.Ordinal.GetHashCode(sessionId) % (uint)_mutationLocks.Length]; DistributedCacheEntryOptions SessionEntryOptions(DateTimeOffset? expiresAt = null) @@ -179,4 +195,6 @@ async Task Write(string key, T value, DistributedCacheEntryOptions options, C } sealed record StoredSession(string Scheme, string RefreshToken, string[] Audiences, DateTimeOffset? ExpiresAt); + + sealed record RefreshRejection(DateTimeOffset RetryAt); } diff --git a/Source/AuthProxy/Authentication/AuthenticationServiceCollectionExtensions.cs b/Source/AuthProxy/Authentication/AuthenticationServiceCollectionExtensions.cs index 200f9328..a2eba1ff 100644 --- a/Source/AuthProxy/Authentication/AuthenticationServiceCollectionExtensions.cs +++ b/Source/AuthProxy/Authentication/AuthenticationServiceCollectionExtensions.cs @@ -163,6 +163,8 @@ static void ConfigureCookieOptions(CookieAuthenticationOptions options, C.Sessio { await ValidateCanonicalSession(context); } + + await UserTokenSessions.RenewRetention(context); }; var existingSigningIn = options.Events.OnSigningIn; From bf7ca953b6cd98dee1c45356b328eba91a46e4f3 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 1 Oct 2026 20:48:37 +0200 Subject: [PATCH 08/13] Preserve whitespace-only OIDC certificate passwords --- ...ficate_file_password_is_only_whitespace.cs | 42 +++++++++++++++++++ .../OidcClientCredentialDescription.cs | 2 +- 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_the_certificate_file_password_is_only_whitespace.cs diff --git a/Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_the_certificate_file_password_is_only_whitespace.cs b/Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_the_certificate_file_password_is_only_whitespace.cs new file mode 100644 index 00000000..64bb3634 --- /dev/null +++ b/Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_the_certificate_file_password_is_only_whitespace.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. + +using System.Security.Cryptography.X509Certificates; +using Cratis.AuthProxy.Authentication.given; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +namespace Cratis.AuthProxy.Authentication.for_OidcClientAssertions; + +public class when_the_certificate_file_password_is_only_whitespace : given.oidc_client_assertions +{ + const string Password = " \t "; + + X509Certificate2 _certificate; + TokenValidationResult _validation; + + void Establish() + { + _certificate = ClientCertificates.Rsa(); + var path = Path.Combine(_directory, "client.pfx"); + File.WriteAllBytes(path, _certificate.Export(X509ContentType.Pfx, Password)); + _provider.ClientCredential!.Source = C.OidcClientCredentialSource.CertificateFile; + _provider.ClientCredential.CertificatePath = path; + _provider.ClientCredential.CertificatePassword = Password; + } + + async Task Because() + { + var assertion = await _assertions.Create(Scheme, _provider, TokenEndpoint, CancellationToken.None); + _validation = await new JsonWebTokenHandler().ValidateTokenAsync(assertion, new TokenValidationParameters + { + ValidIssuer = _provider.ClientId, + ValidAudience = TokenEndpoint, + IssuerSigningKey = new X509SecurityKey(_certificate) + }); + } + + void Destroy() => _certificate.Dispose(); + + [Fact] void should_sign_the_assertion_with_the_certificate_from_the_file() => _validation.IsValid.ShouldBeTrue(); +} diff --git a/Source/AuthProxy/Authentication/OidcClientCredentialDescription.cs b/Source/AuthProxy/Authentication/OidcClientCredentialDescription.cs index 0717e790..77761ba9 100644 --- a/Source/AuthProxy/Authentication/OidcClientCredentialDescription.cs +++ b/Source/AuthProxy/Authentication/OidcClientCredentialDescription.cs @@ -27,7 +27,7 @@ static class OidcClientCredentialDescription { SourceType = CredentialSource.Path, CertificateDiskPath = credential.CertificatePath, - CertificatePassword = NullIfEmpty(credential.CertificatePassword) + CertificatePassword = string.IsNullOrEmpty(credential.CertificatePassword) ? null : credential.CertificatePassword }, C.OidcClientCredentialSource.CertificateStore => new() { From 13d37fdde375313c0acbf92b3f1decbdb618f690 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 1 Oct 2026 21:04:00 +0200 Subject: [PATCH 09/13] Remove trailing blank line from access-token documentation --- Documentation/configuration/services.md | 1 - 1 file changed, 1 deletion(-) diff --git a/Documentation/configuration/services.md b/Documentation/configuration/services.md index dc01c0ba..ccd4dbd3 100644 --- a/Documentation/configuration/services.md +++ b/Documentation/configuration/services.md @@ -509,4 +509,3 @@ restart and are not shared between replicas. With several replicas, route each s (sticky sessions). Otherwise a request that lands on another replica is refused with `401` until the user signs in again. Sessions that began before `AccessToken` was configured hold no refresh token either, so their users sign in again once. - From b6c03df68e82f3fadd54eacee192e30dd83e3613 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 1 Oct 2026 23:47:44 +0200 Subject: [PATCH 10/13] Initialize authentication services in streaming specs --- .../for_ActivityTimeout/given/a_streaming_deployment.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeout/given/a_streaming_deployment.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeout/given/a_streaming_deployment.cs index 9eed9b1d..862c7b0b 100644 --- a/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeout/given/a_streaming_deployment.cs +++ b/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeout/given/a_streaming_deployment.cs @@ -3,6 +3,7 @@ using System.Net.WebSockets; using System.Text; +using Cratis.AuthProxy.Authentication; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting.Server; @@ -62,6 +63,7 @@ protected async Task StartWith(TimeSpan activityTimeout) }, }; }); + builder.AddIngressAuthentication(); builder.SetupReverseProxy(); _proxy = builder.Build(); From 4fec1a9b94d2e747a850e167317b83ef7eb4d773 Mon Sep 17 00:00:00 2001 From: woksin Date: Fri, 2 Oct 2026 00:58:43 +0200 Subject: [PATCH 11/13] Bind forwarded user tokens to versioned backend configuration --- ...ackend_changes_during_token_acquisition.cs | 111 ++++++++++++++++++ .../given/a_forwarding_middleware.cs | 14 +-- ...when_a_signed_in_user_calls_the_backend.cs | 1 + .../when_no_token_can_be_obtained.cs | 2 +- ...when_a_token_forwarding_binding_changes.cs | 59 ++++++++++ .../AccessTokenForwardingMiddleware.cs | 20 +--- .../MicroserviceReverseProxyConfigProvider.cs | 59 ++++++++-- 7 files changed, 230 insertions(+), 36 deletions(-) create mode 100644 Source/AuthProxy.Security.Specs/for_AccessTokenForwarding/when_the_backend_changes_during_token_acquisition.cs create mode 100644 Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_a_token_forwarding_binding_changes.cs diff --git a/Source/AuthProxy.Security.Specs/for_AccessTokenForwarding/when_the_backend_changes_during_token_acquisition.cs b/Source/AuthProxy.Security.Specs/for_AccessTokenForwarding/when_the_backend_changes_during_token_acquisition.cs new file mode 100644 index 00000000..21c67878 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_AccessTokenForwarding/when_the_backend_changes_during_token_acquisition.cs @@ -0,0 +1,111 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.AccessTokens; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Cratis.AuthProxy.Security.for_AccessTokenForwarding; + +/// +/// A token awaited for the old backend must never be forwarded to a newly configured origin. +/// +public class when_the_backend_changes_during_token_acquisition : IAsyncLifetime +{ + readonly ReloadingHarness _harness = new(); + HttpClient? _client; + HttpRequestMessage? _oldRequest; + Task? _inFlight; + ForwardedRequest? _oldBackendRequest; + ForwardedRequest? _newBackendRequest; + + public async Task InitializeAsync() + { + _client = _harness.CreateSecurityClient(); + _client.Timeout = TimeSpan.FromSeconds(20); + _oldRequest = AccessTokenForwardingHarness.FromSession("/api/old", "blocked-session"); + _inFlight = _client.SendAsync(_oldRequest); + await _harness.Tokens.Started.Task.WaitAsync(TimeSpan.FromSeconds(10)); + + var endpoints = _harness.Services.GetRequiredService(); + _ = endpoints.Endpoints; + var reloaded = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var changed = endpoints.GetChangeToken().RegisterChangeCallback(_ => reloaded.TrySetResult(), null); + var config = (IConfigurationRoot)_harness.Services.GetRequiredService(); + config[$"{C.AuthProxy.SectionKey}:Services:reporting:Backend:BaseUrl"] = _harness.Frontend.BaseUrl; + config[$"{C.AuthProxy.SectionKey}:Services:reporting:AccessToken:Scopes:0"] = "api://new-backend/access_as_user"; + config.Reload(); + await reloaded.Task.WaitAsync(TimeSpan.FromSeconds(10)); + + using var newRequest = AccessTokenForwardingHarness.FromSession("/api/new", "new-session"); + using var newResponse = await _client.SendAsync(newRequest); + newResponse.EnsureSuccessStatusCode(); + _harness.Tokens.Resume.TrySetResult(); + using var oldResponse = await _inFlight; + oldResponse.EnsureSuccessStatusCode(); + + _oldBackendRequest = _harness.Backend.LastRequestTo("/api/old"); + _newBackendRequest = _harness.Frontend.LastRequestTo("/api/new"); + } + + public async Task DisposeAsync() + { + _harness.Tokens.Resume.TrySetResult(); + try + { + if (_inFlight is not null) + { + using var response = await _inFlight; + } + } + finally + { + _oldRequest?.Dispose(); + _client?.Dispose(); + await _harness.DisposeAsync(); + } + } + + [Fact] + public void should_send_the_old_token_only_to_the_old_backend() => + Assert.Equal("Bearer token-for-api://reporting/access_as_user", _oldBackendRequest!.Value("Authorization")); + + [Fact] + public void should_send_the_new_audiences_token_to_the_new_backend() => + Assert.Equal("Bearer token-for-api://new-backend/access_as_user", _newBackendRequest!.Value("Authorization")); + + [Fact] + public void should_never_send_the_in_flight_request_to_the_new_backend() => + Assert.False(_harness.Frontend.ReceivedAnythingFor("/api/old")); + + sealed class ReloadingHarness : AccessTokenForwardingHarness + { + public BlockingTokens Tokens { get; } = new(); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + builder.ConfigureTestServices(services => services.AddSingleton(Tokens)); + } + } + + sealed class BlockingTokens : IUserAccessTokens + { + public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource Resume { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public async Task GetFor(string sessionId, C.ServiceAccessToken accessToken, CancellationToken cancellationToken) + { + if (sessionId == "blocked-session") + { + Started.TrySetResult(); + await Resume.Task.WaitAsync(cancellationToken); + } + + return UserAccessTokenResult.Success($"token-for-{accessToken.Scopes.Single()}"); + } + } +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/given/a_forwarding_middleware.cs b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/given/a_forwarding_middleware.cs index a939d52c..2912f7d4 100644 --- a/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/given/a_forwarding_middleware.cs +++ b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/given/a_forwarding_middleware.cs @@ -1,6 +1,7 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using System.Text.Json; using Cratis.AuthProxy.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.Extensions.Logging.Abstractions; @@ -27,17 +28,8 @@ public class a_forwarding_middleware : Specification void Establish() { _accessToken = new() { Scopes = ["api://reporting/access_as_user"] }; - var config = Substitute.For>(); - config.CurrentValue.Returns(new C.AuthProxy - { - Services = new Dictionary - { - ["Reporting"] = new() { Backend = new C.ServiceEndpoint { BaseUrl = "http://reporting/" }, AccessToken = _accessToken }, - }, - }); - _tokens = Substitute.For(); - _tokens.GetFor("session-id", _accessToken, Arg.Any()).Returns(UserAccessTokenResult.Success("user-access-token")); + _tokens.GetFor("session-id", Arg.Any(), Arg.Any()).Returns(UserAccessTokenResult.Success("user-access-token")); _context = new DefaultHttpContext { @@ -53,7 +45,6 @@ void Establish() _forwarded = true; return Task.CompletedTask; }, - config, NullLogger.Instance); } @@ -67,6 +58,7 @@ protected Task Invoke() { [ReverseProxy.MicroserviceReverseProxyConfigProvider.ServiceMetadataKey] = "reporting", [ReverseProxy.MicroserviceReverseProxyConfigProvider.EndpointMetadataKey] = _endpoint, + [ReverseProxy.MicroserviceReverseProxyConfigProvider.AccessTokenMetadataKey] = JsonSerializer.Serialize(_accessToken), }, }, new HttpMessageInvoker(new SocketsHttpHandler())); diff --git a/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_a_signed_in_user_calls_the_backend.cs b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_a_signed_in_user_calls_the_backend.cs index 266ee0ce..80eb3b39 100644 --- a/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_a_signed_in_user_calls_the_backend.cs +++ b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_a_signed_in_user_calls_the_backend.cs @@ -8,5 +8,6 @@ public class when_a_signed_in_user_calls_the_backend : given.a_forwarding_middle Task Because() => Invoke(); [Fact] void should_forward_the_request() => _forwarded.ShouldBeTrue(); + [Fact] void should_request_the_audience_from_the_selected_cluster() => _tokens.Received(1).GetFor("session-id", Arg.Is(_ => _.Scopes.SequenceEqual(_accessToken.Scopes)), Arg.Any()); [Fact] void should_replace_the_authorization_header_with_the_users_access_token() => _context.Request.Headers.Authorization.ToString().ShouldEqual("Bearer user-access-token"); } diff --git a/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_no_token_can_be_obtained.cs b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_no_token_can_be_obtained.cs index 6d5f746d..5f3134dd 100644 --- a/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_no_token_can_be_obtained.cs +++ b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_no_token_can_be_obtained.cs @@ -5,7 +5,7 @@ namespace Cratis.AuthProxy.AccessTokens.for_AccessTokenForwardingMiddleware; public class when_no_token_can_be_obtained : given.a_forwarding_middleware { - void Establish() => _tokens.GetFor("session-id", _accessToken, Arg.Any()).Returns(UserAccessTokenResult.Failed(UserAccessTokenFailure.RefreshTokenRejected)); + void Establish() => _tokens.GetFor("session-id", Arg.Any(), Arg.Any()).Returns(UserAccessTokenResult.Failed(UserAccessTokenFailure.RefreshTokenRejected)); Task Because() => Invoke(); diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_a_token_forwarding_binding_changes.cs b/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_a_token_forwarding_binding_changes.cs new file mode 100644 index 00000000..b052faab --- /dev/null +++ b/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_a_token_forwarding_binding_changes.cs @@ -0,0 +1,59 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text.Json; +using Yarp.ReverseProxy.Configuration; + +namespace Cratis.AuthProxy.ReverseProxy.for_MicroserviceReverseProxyConfigProvider; + +public class when_a_token_forwarding_binding_changes : Specification +{ + MicroserviceReverseProxyConfigProvider _provider; + Action _reload; + C.AuthProxy _config; + IProxyConfig _original; + IProxyConfig _newPolicy; + IProxyConfig _newAddress; + + void Establish() + { + _config = new C.AuthProxy + { + Services = new Dictionary + { + ["Reporting"] = new() + { + Backend = new C.ServiceEndpoint { BaseUrl = "https://old-backend/" }, + AccessToken = new C.ServiceAccessToken { Scopes = ["old-audience"], Resource = "old-resource", Provider = "old-provider" }, + }, + }, + }; + var monitor = Substitute.For>(); + monitor.CurrentValue.Returns(_config); + monitor.OnChange(Arg.Do>(listener => _reload = listener)); + _provider = new(monitor, Substitute.For>()); + _original = _provider.GetConfig(); + } + + void Because() + { + _config.Services["Reporting"].AccessToken!.Scopes[0] = "new-audience"; + _reload(_config, Options.DefaultName); + _newPolicy = _provider.GetConfig(); + _config.Services["Reporting"].Backend!.BaseUrl = "https://new-backend/"; + _reload(_config, Options.DefaultName); + _newAddress = _provider.GetConfig(); + } + + void Destroy() => _provider.Dispose(); + + C.ServiceAccessToken OriginalPolicy() => JsonSerializer.Deserialize(_original.Clusters.Single().Metadata![MicroserviceReverseProxyConfigProvider.AccessTokenMetadataKey])!; + + [Fact] void should_version_the_cluster_when_only_the_policy_changes() => (_original.Clusters.Single().ClusterId != _newPolicy.Clusters.Single().ClusterId).ShouldBeTrue(); + [Fact] void should_version_the_cluster_when_only_the_address_changes() => (_newPolicy.Clusters.Single().ClusterId != _newAddress.Clusters.Single().ClusterId).ShouldBeTrue(); + [Fact] void should_version_the_destination_when_the_binding_changes() => (_original.Clusters.Single().Destinations!.Single().Key != _newAddress.Clusters.Single().Destinations!.Single().Key).ShouldBeTrue(); + [Fact] void should_keep_the_original_scopes_immutable() => OriginalPolicy().Scopes.ShouldContainOnly("old-audience"); + [Fact] void should_keep_the_original_resource() => OriginalPolicy().Resource.ShouldEqual("old-resource"); + [Fact] void should_keep_the_original_provider() => OriginalPolicy().Provider.ShouldEqual("old-provider"); + [Fact] void should_bind_all_routes_to_the_selected_cluster() => _newAddress.Routes.All(_ => _.ClusterId == _newAddress.Clusters.Single().ClusterId).ShouldBeTrue(); +} diff --git a/Source/AuthProxy/AccessTokens/AccessTokenForwardingMiddleware.cs b/Source/AuthProxy/AccessTokens/AccessTokenForwardingMiddleware.cs index d7280839..7b1dacc7 100644 --- a/Source/AuthProxy/AccessTokens/AccessTokenForwardingMiddleware.cs +++ b/Source/AuthProxy/AccessTokens/AccessTokenForwardingMiddleware.cs @@ -3,7 +3,6 @@ using Cratis.AuthProxy.ReverseProxy; using Microsoft.AspNetCore.Authentication.Cookies; -using Microsoft.Extensions.Options; using Yarp.ReverseProxy.Model; using C = Cratis.AuthProxy.Configuration; @@ -21,11 +20,9 @@ namespace Cratis.AuthProxy.AccessTokens; /// the backend expects. /// /// The next middleware in the proxy pipeline. -/// The configuration. /// The for diagnostics. public class AccessTokenForwardingMiddleware( RequestDelegate next, - IOptionsMonitor config, ILogger logger) { /// @@ -39,7 +36,7 @@ public async Task InvokeAsync(HttpContext context, IUserAccessTokens tokens) var proxy = context.Features.Get(); if (proxy is null || proxy.Route.Config.AuthorizationPolicy == MicroserviceReverseProxyConfigProvider.AnonymousAuthorizationPolicy - || !TryGetAccessToken(proxy, config.CurrentValue, out var serviceName, out var accessToken) + || !TryGetAccessToken(proxy, out var serviceName, out var accessToken) || !IsSessionRequest(context)) { await next(context); @@ -61,7 +58,7 @@ public async Task InvokeAsync(HttpContext context, IUserAccessTokens tokens) await next(context); } - static bool TryGetAccessToken(IReverseProxyFeature proxy, C.AuthProxy config, out string serviceName, out C.ServiceAccessToken accessToken) + static bool TryGetAccessToken(IReverseProxyFeature proxy, out string serviceName, out C.ServiceAccessToken accessToken) { serviceName = string.Empty; accessToken = default!; @@ -70,19 +67,14 @@ static bool TryGetAccessToken(IReverseProxyFeature proxy, C.AuthProxy config, ou if (metadata is null || !metadata.TryGetValue(MicroserviceReverseProxyConfigProvider.ServiceMetadataKey, out var key) || !metadata.TryGetValue(MicroserviceReverseProxyConfigProvider.EndpointMetadataKey, out var endpoint) - || endpoint != MicroserviceReverseProxyConfigProvider.BackendEndpoint) + || endpoint != MicroserviceReverseProxyConfigProvider.BackendEndpoint + || !metadata.TryGetValue(MicroserviceReverseProxyConfigProvider.AccessTokenMetadataKey, out var policy)) { return false; } - var service = config.Services.FirstOrDefault(_ => string.Equals(_.Key, key, StringComparison.OrdinalIgnoreCase)); - if (service.Value?.AccessToken is null) - { - return false; - } - - serviceName = service.Key; - accessToken = service.Value.AccessToken; + serviceName = key; + accessToken = JsonSerializer.Deserialize(policy)!; return true; } diff --git a/Source/AuthProxy/ReverseProxy/MicroserviceReverseProxyConfigProvider.cs b/Source/AuthProxy/ReverseProxy/MicroserviceReverseProxyConfigProvider.cs index 7e03746f..183956ff 100644 --- a/Source/AuthProxy/ReverseProxy/MicroserviceReverseProxyConfigProvider.cs +++ b/Source/AuthProxy/ReverseProxy/MicroserviceReverseProxyConfigProvider.cs @@ -1,6 +1,8 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using System.Security.Cryptography; +using System.Text; using Microsoft.Extensions.Options; using Yarp.ReverseProxy.Configuration; using C = Cratis.AuthProxy.Configuration; @@ -50,6 +52,11 @@ public class MicroserviceReverseProxyConfigProvider : IProxyConfigProvider, IDis /// internal const string EndpointMetadataKey = "Cratis.AuthProxy.Endpoint"; + /// + /// The cluster metadata key holding the access token policy selected with its destination. + /// + internal const string AccessTokenMetadataKey = "Cratis.AuthProxy.AccessToken"; + /// /// The value of a service's backend cluster. /// @@ -95,9 +102,10 @@ public MicroserviceReverseProxyConfigProvider( ILogger logger) { _logger = logger; + var snapshot = config.CurrentValue; _inner = new InMemoryConfigProvider( - BuildRoutes(config.CurrentValue, logger), - BuildClusters(config.CurrentValue)); + BuildRoutes(snapshot, logger), + BuildClusters(snapshot)); _configurationChanged = config.OnChange(Rebuild); } @@ -181,7 +189,16 @@ static List BuildRoutes(C.AuthProxy config, ILogger logger) } } - return routes; + // Version token-forwarding clusters with their destination and policy. YARP must not reuse a + // destination state whose address can change while a request awaits a token for the old audience. + var backendVersions = services.ToDictionary( + _ => BackendClusterId(_.Key.ToLowerInvariant()), + _ => VersionedBackendClusterId(_.Key.ToLowerInvariant(), _.Value), + StringComparer.Ordinal); + + return routes.ConvertAll(route => backendVersions.TryGetValue(route.ClusterId!, out var version) + ? route with { ClusterId = version } + : route); } /// @@ -545,12 +562,12 @@ static List BuildClusters(C.AuthProxy config) { clusters.Add(ClusterFor(config, ms, ms.Backend) with { - ClusterId = BackendClusterId(key), + ClusterId = VersionedBackendClusterId(key, ms), Destinations = new Dictionary { - ["destination1"] = new() { Address = ms.Backend.BaseUrl } + [ms.AccessToken is null ? "destination1" : VersionedBackendClusterId(key, ms)] = new() { Address = ms.Backend.BaseUrl } }, - Metadata = ClusterMetadata(key, BackendEndpoint), + Metadata = ClusterMetadata(key, BackendEndpoint, ms.AccessToken), }); } @@ -571,11 +588,33 @@ static List BuildClusters(C.AuthProxy config) return clusters; } - static Dictionary ClusterMetadata(string key, string endpoint) => new() + static Dictionary ClusterMetadata(string key, string endpoint, C.ServiceAccessToken? accessToken = null) { - [ServiceMetadataKey] = key, - [EndpointMetadataKey] = endpoint, - }; + var metadata = new Dictionary + { + [ServiceMetadataKey] = key, + [EndpointMetadataKey] = endpoint, + }; + if (accessToken is not null) + { + metadata[AccessTokenMetadataKey] = JsonSerializer.Serialize(accessToken); + } + + return metadata; + } + + static string VersionedBackendClusterId(string key, C.Service service) + { + if (service.AccessToken is null) + { + return BackendClusterId(key); + } + + var binding = JsonSerializer.Serialize(new { Address = service.Backend?.BaseUrl, Policy = service.AccessToken }); + var version = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(binding))); + + return $"{BackendClusterId(key)}-{version}"; + } /// /// Creates the cluster skeleton for an endpoint, carrying the activity timeout that applies to it. From e081b20c1fcc38044d45c5114a018bcea7d6fbb9 Mon Sep 17 00:00:00 2001 From: woksin Date: Fri, 2 Oct 2026 02:45:54 +0200 Subject: [PATCH 12/13] Preserve routed service recognition for token-forwarding backends --- ...a_machine_token_targets_another_service.cs | 82 +++++++++++++++++++ .../when_the_service_requires_claims.cs | 58 +++++++++++++ .../given/AccessTokenForwardingHarness.cs | 1 + ...lected_proxy_service_cannot_be_resolved.cs | 50 +++++++++++ ...when_a_token_forwarding_binding_changes.cs | 8 +- .../ClientCredentialsServiceResolver.cs | 9 ++ .../MicroserviceReverseProxyConfigProvider.cs | 24 ++---- 7 files changed, 214 insertions(+), 18 deletions(-) create mode 100644 Source/AuthProxy.Security.Specs/for_AccessTokenForwarding/when_a_machine_token_targets_another_service.cs create mode 100644 Source/AuthProxy.Security.Specs/for_AccessTokenForwarding/when_the_service_requires_claims.cs create mode 100644 Source/AuthProxy.Specs/Authentication/for_ClientCredentialsServiceResolver/when_the_selected_proxy_service_cannot_be_resolved.cs diff --git a/Source/AuthProxy.Security.Specs/for_AccessTokenForwarding/when_a_machine_token_targets_another_service.cs b/Source/AuthProxy.Security.Specs/for_AccessTokenForwarding/when_a_machine_token_targets_another_service.cs new file mode 100644 index 00000000..d25e107c --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_AccessTokenForwarding/when_a_machine_token_targets_another_service.cs @@ -0,0 +1,82 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.Authentication; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Cratis.AuthProxy.Security.for_AccessTokenForwarding; + +/// +/// A real AuthProxy machine token must not authenticate another service's token-forwarding backend, even with +/// caller-supplied service selection or only one client-credentials candidate and no claim authorization gate. +/// +public class when_a_machine_token_targets_another_service +{ + [Theory] + [InlineData("/api/host", null, "/api")] + [InlineData("/reports/api/header", "machine", "/reports/api")] + [InlineData("/reports/api/query?service=machine", null, "/reports/api")] + public async Task should_reject_the_token_without_reaching_the_other_backend(string path, string? service, string routePrefix) + { + await using var harness = new MachineHarness(routePrefix); + using var client = harness.CreateSecurityClient(); + var token = harness.Services.GetRequiredService().CreateToken( + new ConfiguredClientCredentialsService("machine", routePrefix, new Uri($"{harness.Frontend.BaseUrl}/verify")), + "machine-client", + AccessTokenForwardingHarness.TenantId); + + // Prove the bearer token and the real authentication handler work for its own service first. + var controlPath = $"{routePrefix}/control"; + using var control = new HttpRequestMessage(HttpMethod.Get, controlPath); + control.Headers.TryAddWithoutValidation("Authorization", $"Bearer {token}"); + control.Headers.Host = "machine.example.test"; + control.Headers.TryAddWithoutValidation(Headers.ServiceId, "machine"); + using var controlResponse = await client.SendAsync(control); + Assert.Equal(HttpStatusCode.OK, controlResponse.StatusCode); + Assert.NotNull(harness.Frontend.LastRequestTo(controlPath)); + + using var request = new HttpRequestMessage(HttpMethod.Get, path); + request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {token}"); + request.Headers.Host = "reporting.example.test"; + if (service is not null) + { + request.Headers.TryAddWithoutValidation(Headers.ServiceId, service); + } + + using var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.False(harness.Backend.ReceivedAnythingFor(path.Split('?')[0])); + } + + sealed class MachineHarness(string routePrefix) : AccessTokenForwardingHarness + { + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + builder.ConfigureAppConfiguration((_, config) => config.AddInMemoryCollection(new Dictionary + { + [$"{C.AuthProxy.SectionKey}:Services:reporting:Hosts:0"] = "reporting.example.test", + [$"{C.AuthProxy.SectionKey}:Services:prefixed:PathPrefix"] = "/reports", + [$"{C.AuthProxy.SectionKey}:Services:prefixed:Hosts:0"] = "reporting.example.test", + [$"{C.AuthProxy.SectionKey}:Services:prefixed:Backend:BaseUrl"] = Backend.BaseUrl, + [$"{C.AuthProxy.SectionKey}:Services:prefixed:ResolveIdentityDetails"] = "false", + [$"{C.AuthProxy.SectionKey}:Services:prefixed:IdentityVerification"] = nameof(C.IdentityVerificationMode.BestEffort), + [$"{C.AuthProxy.SectionKey}:Services:prefixed:AccessToken:Scopes:0"] = "api://reporting/access_as_user", + [$"{C.AuthProxy.SectionKey}:Services:machine:Backend:BaseUrl"] = Frontend.BaseUrl, + [$"{C.AuthProxy.SectionKey}:Services:machine:Frontend:BaseUrl"] = Frontend.BaseUrl, + [$"{C.AuthProxy.SectionKey}:Services:machine:ResolveIdentityDetails"] = "false", + [$"{C.AuthProxy.SectionKey}:Services:machine:IdentityVerification"] = nameof(C.IdentityVerificationMode.BestEffort), + [$"{C.AuthProxy.SectionKey}:Services:machine:ClientCredentials:RoutePrefix"] = routePrefix, + })); + builder.ConfigureTestServices(services => services.PostConfigure(options => + { + options.DefaultScheme = ClientCredentialsDefaults.CompositeAuthenticationScheme; + options.DefaultChallengeScheme = ClientCredentialsDefaults.AuthenticationScheme; + })); + } + } +} diff --git a/Source/AuthProxy.Security.Specs/for_AccessTokenForwarding/when_the_service_requires_claims.cs b/Source/AuthProxy.Security.Specs/for_AccessTokenForwarding/when_the_service_requires_claims.cs new file mode 100644 index 00000000..1886363c --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_AccessTokenForwarding/when_the_service_requires_claims.cs @@ -0,0 +1,58 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; + +namespace Cratis.AuthProxy.Security.for_AccessTokenForwarding; + +/// +/// Token-forwarding routes must still resolve to the service whose claim requirements authorize the request. +/// +public class when_the_service_requires_claims : IAsyncLifetime +{ + readonly ClaimHarness _harness = new(); + HttpResponseMessage? _authorized; + HttpResponseMessage? _denied; + ForwardedRequest? _forwarded; + + public async Task InitializeAsync() + { + using var client = _harness.CreateSecurityClient(); + using var authorized = AccessTokenForwardingHarness.FromSession("/api/authorized", "claim-session"); + authorized.Headers.TryAddWithoutValidation(HeaderAuthenticationHandler.ClaimsHeader, "role=member;permission=reports"); + _authorized = await client.SendAsync(authorized); + _forwarded = _harness.Backend.LastRequestTo("/api/authorized"); + + using var denied = AccessTokenForwardingHarness.FromSession("/api/denied", "claim-session"); + denied.Headers.TryAddWithoutValidation(HeaderAuthenticationHandler.ClaimsHeader, "role=member"); + _denied = await client.SendAsync(denied); + } + + public async Task DisposeAsync() + { + _authorized?.Dispose(); + _denied?.Dispose(); + await _harness.DisposeAsync(); + } + + [Fact] public void should_authorize_the_caller_satisfying_root_and_service_requirements() => Assert.Equal(HttpStatusCode.OK, _authorized!.StatusCode); + [Fact] public void should_forward_the_authorized_callers_bearer_token() => Assert.Equal($"Bearer {AccessTokenForwardingHarness.TokenFor("claim-session")}", _forwarded!.Value("Authorization")); + [Fact] public void should_deny_a_caller_missing_the_service_claim() => Assert.Equal(HttpStatusCode.Forbidden, _denied!.StatusCode); + [Fact] public void should_not_forward_the_denied_request() => Assert.False(_harness.Backend.ReceivedAnythingFor("/api/denied")); + + sealed class ClaimHarness : AccessTokenForwardingHarness + { + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + builder.ConfigureAppConfiguration((_, config) => config.AddInMemoryCollection(new Dictionary + { + [$"{C.AuthProxy.SectionKey}:Authorization:RequiredClaims:0:Claim"] = "role", + [$"{C.AuthProxy.SectionKey}:Authorization:RequiredClaims:0:AnyOf:0"] = "member", + [$"{C.AuthProxy.SectionKey}:Services:reporting:Authorization:RequiredClaims:0:Claim"] = "permission", + [$"{C.AuthProxy.SectionKey}:Services:reporting:Authorization:RequiredClaims:0:AnyOf:0"] = "reports", + })); + } + } +} diff --git a/Source/AuthProxy.Security.Specs/given/AccessTokenForwardingHarness.cs b/Source/AuthProxy.Security.Specs/given/AccessTokenForwardingHarness.cs index 200a6891..71b1d822 100644 --- a/Source/AuthProxy.Security.Specs/given/AccessTokenForwardingHarness.cs +++ b/Source/AuthProxy.Security.Specs/given/AccessTokenForwardingHarness.cs @@ -120,6 +120,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) [$"{C.AuthProxy.SectionKey}:Services:reporting:Backend:BaseUrl"] = Backend.BaseUrl, [$"{C.AuthProxy.SectionKey}:Services:reporting:Frontend:BaseUrl"] = Frontend.BaseUrl, [$"{C.AuthProxy.SectionKey}:Services:reporting:ResolveIdentityDetails"] = "false", + [$"{C.AuthProxy.SectionKey}:Services:reporting:IdentityVerification"] = nameof(C.IdentityVerificationMode.BestEffort), [$"{C.AuthProxy.SectionKey}:Services:reporting:AccessToken:Scopes:0"] = "api://reporting/access_as_user", [$"{C.AuthProxy.SectionKey}:PagesPath"] = _pagesPath, diff --git a/Source/AuthProxy.Specs/Authentication/for_ClientCredentialsServiceResolver/when_the_selected_proxy_service_cannot_be_resolved.cs b/Source/AuthProxy.Specs/Authentication/for_ClientCredentialsServiceResolver/when_the_selected_proxy_service_cannot_be_resolved.cs new file mode 100644 index 00000000..9a21d35c --- /dev/null +++ b/Source/AuthProxy.Specs/Authentication/for_ClientCredentialsServiceResolver/when_the_selected_proxy_service_cannot_be_resolved.cs @@ -0,0 +1,50 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Yarp.ReverseProxy.Configuration; +using Yarp.ReverseProxy.Forwarder; +using Yarp.ReverseProxy.Model; + +namespace Cratis.AuthProxy.Authentication.for_ClientCredentialsServiceResolver; + +public class when_the_selected_proxy_service_cannot_be_resolved : Specification +{ + ClientCredentialsServiceResolver _resolver; + DefaultHttpContext _context; + + void Establish() + { + var config = new C.AuthProxy + { + Services = new Dictionary + { + ["machine"] = new() + { + Backend = new C.ServiceEndpoint { BaseUrl = "http://machine.test/" }, + ClientCredentials = new C.ServiceClientCredentials(), + }, + }, + }; + var monitor = Substitute.For>(); + monitor.CurrentValue.Returns(config); + _resolver = new(monitor, Substitute.For>()); + _context = new DefaultHttpContext(); + _context.Request.Path = "/api/orders"; + var route = new RouteModel( + new RouteConfig { RouteId = "removed-route", ClusterId = "removed-backend-cluster" }, + new ClusterState("removed-backend-cluster"), + HttpTransformer.Default); + _context.SetEndpoint(new Endpoint(null, new EndpointMetadataCollection(route), "proxied")); + } + + [Theory] + [InlineData(null, null)] + [InlineData("machine", null)] + [InlineData(null, "?service=machine")] + public void should_not_fall_back_to_a_client_credentials_candidate(string? header, string? query) + { + _context.Request.Headers[Headers.ServiceId] = header; + _context.Request.QueryString = new QueryString(query); + _resolver.TryResolveForRequest(_context.Request, out _).ShouldBeFalse(); + } +} diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_a_token_forwarding_binding_changes.cs b/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_a_token_forwarding_binding_changes.cs index b052faab..a260c0b7 100644 --- a/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_a_token_forwarding_binding_changes.cs +++ b/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_a_token_forwarding_binding_changes.cs @@ -49,9 +49,11 @@ void Because() C.ServiceAccessToken OriginalPolicy() => JsonSerializer.Deserialize(_original.Clusters.Single().Metadata![MicroserviceReverseProxyConfigProvider.AccessTokenMetadataKey])!; - [Fact] void should_version_the_cluster_when_only_the_policy_changes() => (_original.Clusters.Single().ClusterId != _newPolicy.Clusters.Single().ClusterId).ShouldBeTrue(); - [Fact] void should_version_the_cluster_when_only_the_address_changes() => (_newPolicy.Clusters.Single().ClusterId != _newAddress.Clusters.Single().ClusterId).ShouldBeTrue(); - [Fact] void should_version_the_destination_when_the_binding_changes() => (_original.Clusters.Single().Destinations!.Single().Key != _newAddress.Clusters.Single().Destinations!.Single().Key).ShouldBeTrue(); + [Fact] void should_keep_the_cluster_id_convention() => _original.Clusters.Single().ClusterId.ShouldEqual("reporting-backend-cluster"); + [Fact] void should_keep_the_cluster_id_when_only_the_policy_changes() => _newPolicy.Clusters.Single().ClusterId.ShouldEqual(_original.Clusters.Single().ClusterId); + [Fact] void should_keep_the_cluster_id_when_only_the_address_changes() => _newAddress.Clusters.Single().ClusterId.ShouldEqual(_newPolicy.Clusters.Single().ClusterId); + [Fact] void should_version_the_destination_when_only_the_policy_changes() => (_original.Clusters.Single().Destinations!.Single().Key != _newPolicy.Clusters.Single().Destinations!.Single().Key).ShouldBeTrue(); + [Fact] void should_version_the_destination_when_only_the_address_changes() => (_newPolicy.Clusters.Single().Destinations!.Single().Key != _newAddress.Clusters.Single().Destinations!.Single().Key).ShouldBeTrue(); [Fact] void should_keep_the_original_scopes_immutable() => OriginalPolicy().Scopes.ShouldContainOnly("old-audience"); [Fact] void should_keep_the_original_resource() => OriginalPolicy().Resource.ShouldEqual("old-resource"); [Fact] void should_keep_the_original_provider() => OriginalPolicy().Provider.ShouldEqual("old-provider"); diff --git a/Source/AuthProxy/Authentication/ClientCredentialsServiceResolver.cs b/Source/AuthProxy/Authentication/ClientCredentialsServiceResolver.cs index 738df797..2c36fed1 100644 --- a/Source/AuthProxy/Authentication/ClientCredentialsServiceResolver.cs +++ b/Source/AuthProxy/Authentication/ClientCredentialsServiceResolver.cs @@ -3,6 +3,7 @@ using Cratis.AuthProxy.ReverseProxy; using Microsoft.Extensions.Options; +using Yarp.ReverseProxy.Model; using C = Cratis.AuthProxy.Configuration; namespace Cratis.AuthProxy.Authentication; @@ -87,6 +88,14 @@ public bool TryResolveForRequest(HttpRequest request, out ConfiguredClientCreden var routed = ServiceRoutes.Resolve(request, config.CurrentValue); if (routed is null) { + if (request.HttpContext.GetEndpoint()?.Metadata.GetMetadata() is not null) + { + // A selected proxy endpoint is authoritative, even if its service is no longer configured. + // Never authenticate it as a caller-selected service or the sole client-credentials candidate. + service = default!; + return false; + } + return TryResolveCandidate(request, out service); } diff --git a/Source/AuthProxy/ReverseProxy/MicroserviceReverseProxyConfigProvider.cs b/Source/AuthProxy/ReverseProxy/MicroserviceReverseProxyConfigProvider.cs index 183956ff..ec65f7d0 100644 --- a/Source/AuthProxy/ReverseProxy/MicroserviceReverseProxyConfigProvider.cs +++ b/Source/AuthProxy/ReverseProxy/MicroserviceReverseProxyConfigProvider.cs @@ -189,16 +189,7 @@ static List BuildRoutes(C.AuthProxy config, ILogger logger) } } - // Version token-forwarding clusters with their destination and policy. YARP must not reuse a - // destination state whose address can change while a request awaits a token for the old audience. - var backendVersions = services.ToDictionary( - _ => BackendClusterId(_.Key.ToLowerInvariant()), - _ => VersionedBackendClusterId(_.Key.ToLowerInvariant(), _.Value), - StringComparer.Ordinal); - - return routes.ConvertAll(route => backendVersions.TryGetValue(route.ClusterId!, out var version) - ? route with { ClusterId = version } - : route); + return routes; } /// @@ -562,10 +553,13 @@ static List BuildClusters(C.AuthProxy config) { clusters.Add(ClusterFor(config, ms, ms.Backend) with { - ClusterId = VersionedBackendClusterId(key, ms), + ClusterId = BackendClusterId(key), + + // A request retains its selected cluster config and available destinations. Give a changed + // binding a new destination state so YARP cannot mutate the address while it awaits a token. Destinations = new Dictionary { - [ms.AccessToken is null ? "destination1" : VersionedBackendClusterId(key, ms)] = new() { Address = ms.Backend.BaseUrl } + [BackendDestinationId(key, ms)] = new() { Address = ms.Backend.BaseUrl } }, Metadata = ClusterMetadata(key, BackendEndpoint, ms.AccessToken), }); @@ -603,17 +597,17 @@ static Dictionary ClusterMetadata(string key, string endpoint, C return metadata; } - static string VersionedBackendClusterId(string key, C.Service service) + static string BackendDestinationId(string key, C.Service service) { if (service.AccessToken is null) { - return BackendClusterId(key); + return "destination1"; } var binding = JsonSerializer.Serialize(new { Address = service.Backend?.BaseUrl, Policy = service.AccessToken }); var version = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(binding))); - return $"{BackendClusterId(key)}-{version}"; + return $"{key}-backend-destination-{version}"; } /// From 62718503befa8b13309f69c9bfc2db4951e45186 Mon Sep 17 00:00:00 2001 From: woksin Date: Fri, 2 Oct 2026 03:01:34 +0200 Subject: [PATCH 13/13] Reject mismatched token policy and destination snapshots --- Documentation/configuration/services.md | 3 + .../given/a_forwarding_middleware.cs | 23 ++++++-- ...ll_destinations_include_a_stale_binding.cs | 15 +++++ .../when_no_destination_is_available.cs | 15 +++++ ...lable_destinations_have_a_stale_binding.cs | 15 +++++ ...when_the_destination_binding_is_missing.cs | 15 +++++ ...cy_is_published_before_its_destinations.cs | 59 +++++++++++++++++++ ...when_a_token_forwarding_binding_changes.cs | 3 + .../AccessTokenForwardingMiddleware.cs | 18 +++++- .../MicroserviceReverseProxyConfigProvider.cs | 16 +++-- 10 files changed, 171 insertions(+), 11 deletions(-) create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_all_destinations_include_a_stale_binding.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_no_destination_is_available.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_only_available_destinations_have_a_stale_binding.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_the_destination_binding_is_missing.cs create mode 100644 Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_the_new_policy_is_published_before_its_destinations.cs diff --git a/Documentation/configuration/services.md b/Documentation/configuration/services.md index 30436467..9466f23a 100644 --- a/Documentation/configuration/services.md +++ b/Documentation/configuration/services.md @@ -688,6 +688,9 @@ provider must be configured. AuthProxy refuses to start otherwise. - Requests to the `Frontend`, requests on [anonymous paths](#anonymous-paths), and machine callers that authenticate with their own bearer token ([client credentials](#client-credentials) or JWT bearer) are forwarded as before. +- Tokens stay bound to the backend and audience selected for the request across configuration reloads. + If a request captures a token policy and destinations from different configuration versions, AuthProxy + refuses it with `503` before obtaining or forwarding a token. Retry after the reload completes. - When no token can be obtained, the request is refused with `401` instead of being forwarded without one. This happens when the session has no refresh token, the provider rejects the refresh token, the provider cannot be reached, or the user signed in with another provider than `Provider`. An `invalid_grant` error diff --git a/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/given/a_forwarding_middleware.cs b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/given/a_forwarding_middleware.cs index 2912f7d4..f9c73fb5 100644 --- a/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/given/a_forwarding_middleware.cs +++ b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/given/a_forwarding_middleware.cs @@ -24,6 +24,9 @@ public class a_forwarding_middleware : Specification protected AccessTokenForwardingMiddleware _middleware; protected string _authorizationPolicy = "default"; protected string _endpoint = ReverseProxy.MicroserviceReverseProxyConfigProvider.BackendEndpoint; + protected string? _destinationBinding = "bound-destination"; + protected IReadOnlyList _availableDestinations = [new("bound-destination")]; + protected IReadOnlyList _allDestinations = [new("bound-destination")]; void Establish() { @@ -50,22 +53,30 @@ void Establish() protected Task Invoke() { + var metadata = new Dictionary + { + [ReverseProxy.MicroserviceReverseProxyConfigProvider.ServiceMetadataKey] = "reporting", + [ReverseProxy.MicroserviceReverseProxyConfigProvider.EndpointMetadataKey] = _endpoint, + [ReverseProxy.MicroserviceReverseProxyConfigProvider.AccessTokenMetadataKey] = JsonSerializer.Serialize(_accessToken), + }; + if (_destinationBinding is not null) + { + metadata[ReverseProxy.MicroserviceReverseProxyConfigProvider.DestinationMetadataKey] = _destinationBinding; + } + var cluster = new ClusterModel( new ClusterConfig { ClusterId = "reporting-cluster", - Metadata = new Dictionary - { - [ReverseProxy.MicroserviceReverseProxyConfigProvider.ServiceMetadataKey] = "reporting", - [ReverseProxy.MicroserviceReverseProxyConfigProvider.EndpointMetadataKey] = _endpoint, - [ReverseProxy.MicroserviceReverseProxyConfigProvider.AccessTokenMetadataKey] = JsonSerializer.Serialize(_accessToken), - }, + Metadata = metadata, }, new HttpMessageInvoker(new SocketsHttpHandler())); var route = new RouteModel(new RouteConfig { RouteId = "route", AuthorizationPolicy = _authorizationPolicy }, null, HttpTransformer.Empty); var feature = Substitute.For(); feature.Route.Returns(route); feature.Cluster.Returns(cluster); + feature.AvailableDestinations.Returns(_availableDestinations); + feature.AllDestinations.Returns(_allDestinations); _context.Features.Set(feature); return _middleware.InvokeAsync(_context, _tokens); diff --git a/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_all_destinations_include_a_stale_binding.cs b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_all_destinations_include_a_stale_binding.cs new file mode 100644 index 00000000..22496c7a --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_all_destinations_include_a_stale_binding.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.AccessTokens.for_AccessTokenForwardingMiddleware; + +public class when_all_destinations_include_a_stale_binding : given.a_forwarding_middleware +{ + void Establish() => _allDestinations = [new("bound-destination"), new("stale-destination")]; + + Task Because() => Invoke(); + + [Fact] void should_refuse_the_mixed_snapshot() => _context.Response.StatusCode.ShouldEqual(StatusCodes.Status503ServiceUnavailable); + [Fact] void should_not_obtain_a_token() => _tokens.DidNotReceive().GetFor(Arg.Any(), Arg.Any(), Arg.Any()); + [Fact] void should_not_forward_the_request() => _forwarded.ShouldBeFalse(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_no_destination_is_available.cs b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_no_destination_is_available.cs new file mode 100644 index 00000000..cc46d00b --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_no_destination_is_available.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.AccessTokens.for_AccessTokenForwardingMiddleware; + +public class when_no_destination_is_available : given.a_forwarding_middleware +{ + void Establish() => _availableDestinations = []; + + Task Because() => Invoke(); + + [Fact] void should_refuse_the_request() => _context.Response.StatusCode.ShouldEqual(StatusCodes.Status503ServiceUnavailable); + [Fact] void should_not_obtain_a_token() => _tokens.DidNotReceive().GetFor(Arg.Any(), Arg.Any(), Arg.Any()); + [Fact] void should_not_forward_the_request() => _forwarded.ShouldBeFalse(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_only_available_destinations_have_a_stale_binding.cs b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_only_available_destinations_have_a_stale_binding.cs new file mode 100644 index 00000000..1f3a8cf0 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_only_available_destinations_have_a_stale_binding.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.AccessTokens.for_AccessTokenForwardingMiddleware; + +public class when_only_available_destinations_have_a_stale_binding : given.a_forwarding_middleware +{ + void Establish() => _availableDestinations = [new("stale-destination")]; + + Task Because() => Invoke(); + + [Fact] void should_refuse_the_mixed_snapshot() => _context.Response.StatusCode.ShouldEqual(StatusCodes.Status503ServiceUnavailable); + [Fact] void should_not_obtain_a_token() => _tokens.DidNotReceive().GetFor(Arg.Any(), Arg.Any(), Arg.Any()); + [Fact] void should_not_forward_the_request() => _forwarded.ShouldBeFalse(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_the_destination_binding_is_missing.cs b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_the_destination_binding_is_missing.cs new file mode 100644 index 00000000..6d68add9 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_the_destination_binding_is_missing.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.AccessTokens.for_AccessTokenForwardingMiddleware; + +public class when_the_destination_binding_is_missing : given.a_forwarding_middleware +{ + void Establish() => _destinationBinding = null; + + Task Because() => Invoke(); + + [Fact] void should_refuse_the_unbound_snapshot() => _context.Response.StatusCode.ShouldEqual(StatusCodes.Status503ServiceUnavailable); + [Fact] void should_not_obtain_a_token() => _tokens.DidNotReceive().GetFor(Arg.Any(), Arg.Any(), Arg.Any()); + [Fact] void should_not_forward_the_request() => _forwarded.ShouldBeFalse(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_the_new_policy_is_published_before_its_destinations.cs b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_the_new_policy_is_published_before_its_destinations.cs new file mode 100644 index 00000000..e1ff0fec --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_the_new_policy_is_published_before_its_destinations.cs @@ -0,0 +1,59 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text.Json; +using Cratis.AuthProxy.ReverseProxy; +using Yarp.ReverseProxy.Model; + +namespace Cratis.AuthProxy.AccessTokens.for_AccessTokenForwardingMiddleware; + +public class when_the_new_policy_is_published_before_its_destinations : given.a_forwarding_middleware +{ + MicroserviceReverseProxyConfigProvider _provider; + string _oldDestinationId; + + void Establish() + { + var config = new C.AuthProxy + { + Services = new Dictionary + { + ["Reporting"] = new() + { + Backend = new C.ServiceEndpoint { BaseUrl = "https://old-backend/" }, + AccessToken = new C.ServiceAccessToken { Scopes = ["old-audience"] }, + }, + }, + }; + Action reload = null!; + var monitor = Substitute.For>(); + monitor.CurrentValue.Returns(config); + monitor.OnChange(Arg.Do>(listener => reload = listener)); + _provider = new(monitor, Substitute.For>()); + var oldCluster = _provider.GetConfig().Clusters.Single(); + _oldDestinationId = oldCluster.Destinations!.Single().Key; + var oldDestination = new DestinationState(_oldDestinationId, new DestinationModel(oldCluster.Destinations.Single().Value)); + + config.Services["Reporting"].Backend!.BaseUrl = "https://new-backend/"; + config.Services["Reporting"].AccessToken!.Scopes = ["new-audience"]; + reload(config, Options.DefaultName); + var newCluster = _provider.GetConfig().Clusters.Single(); + + // Freeze the snapshot at YARP's reload interval: the new Cluster.Model has been published, + // but DestinationsState still holds the old origin. No timing or real reload race is needed. + _accessToken = JsonSerializer.Deserialize(newCluster.Metadata![MicroserviceReverseProxyConfigProvider.AccessTokenMetadataKey])!; + _destinationBinding = newCluster.Metadata[MicroserviceReverseProxyConfigProvider.DestinationMetadataKey]; + _availableDestinations = [oldDestination]; + _allDestinations = [oldDestination]; + } + + Task Because() => Invoke(); + + void Destroy() => _provider.Dispose(); + + [Fact] void should_capture_a_different_binding() => (_destinationBinding != _oldDestinationId).ShouldBeTrue(); + [Fact] void should_refuse_the_mixed_snapshot() => _context.Response.StatusCode.ShouldEqual(StatusCodes.Status503ServiceUnavailable); + [Fact] void should_not_obtain_the_new_audiences_token() => _tokens.DidNotReceive().GetFor(Arg.Any(), Arg.Any(), Arg.Any()); + [Fact] void should_never_forward_to_the_old_backend() => _forwarded.ShouldBeFalse(); + [Fact] void should_not_replace_the_authorization_header() => _context.Request.Headers.Authorization.ToString().ShouldEqual("Bearer something-the-browser-sent"); +} diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_a_token_forwarding_binding_changes.cs b/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_a_token_forwarding_binding_changes.cs index a260c0b7..cc3ec720 100644 --- a/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_a_token_forwarding_binding_changes.cs +++ b/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_a_token_forwarding_binding_changes.cs @@ -54,6 +54,9 @@ void Because() [Fact] void should_keep_the_cluster_id_when_only_the_address_changes() => _newAddress.Clusters.Single().ClusterId.ShouldEqual(_newPolicy.Clusters.Single().ClusterId); [Fact] void should_version_the_destination_when_only_the_policy_changes() => (_original.Clusters.Single().Destinations!.Single().Key != _newPolicy.Clusters.Single().Destinations!.Single().Key).ShouldBeTrue(); [Fact] void should_version_the_destination_when_only_the_address_changes() => (_newPolicy.Clusters.Single().Destinations!.Single().Key != _newAddress.Clusters.Single().Destinations!.Single().Key).ShouldBeTrue(); + [Fact] void should_bind_the_original_policy_to_its_destination() => _original.Clusters.Single().Metadata![MicroserviceReverseProxyConfigProvider.DestinationMetadataKey].ShouldEqual(_original.Clusters.Single().Destinations!.Single().Key); + [Fact] void should_bind_the_new_policy_to_its_destination() => _newPolicy.Clusters.Single().Metadata![MicroserviceReverseProxyConfigProvider.DestinationMetadataKey].ShouldEqual(_newPolicy.Clusters.Single().Destinations!.Single().Key); + [Fact] void should_bind_the_new_address_to_its_destination() => _newAddress.Clusters.Single().Metadata![MicroserviceReverseProxyConfigProvider.DestinationMetadataKey].ShouldEqual(_newAddress.Clusters.Single().Destinations!.Single().Key); [Fact] void should_keep_the_original_scopes_immutable() => OriginalPolicy().Scopes.ShouldContainOnly("old-audience"); [Fact] void should_keep_the_original_resource() => OriginalPolicy().Resource.ShouldEqual("old-resource"); [Fact] void should_keep_the_original_provider() => OriginalPolicy().Provider.ShouldEqual("old-provider"); diff --git a/Source/AuthProxy/AccessTokens/AccessTokenForwardingMiddleware.cs b/Source/AuthProxy/AccessTokens/AccessTokenForwardingMiddleware.cs index 7b1dacc7..71fb53b5 100644 --- a/Source/AuthProxy/AccessTokens/AccessTokenForwardingMiddleware.cs +++ b/Source/AuthProxy/AccessTokens/AccessTokenForwardingMiddleware.cs @@ -17,7 +17,8 @@ namespace Cratis.AuthProxy.AccessTokens; /// to the backend of a service that declares . Machine callers authenticated by a /// bearer token keep their own Authorization header, and anonymous paths are forwarded as they are. /// When no token can be obtained the request is refused with 401, never forwarded without the token -/// the backend expects. +/// the backend expects. A configuration reload snapshot whose destinations do not match the token policy is refused +/// with 503 before obtaining a token. /// /// The next middleware in the proxy pipeline. /// The for diagnostics. @@ -43,6 +44,14 @@ public async Task InvokeAsync(HttpContext context, IUserAccessTokens tokens) return; } + // YARP publishes the cluster model and destinations separately. Never acquire a token for a policy + // paired with destinations from another binding, even in the short interval during a reload. + if (!HasBoundDestinations(proxy)) + { + context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + return; + } + var result = UserTokenSessions.Of(context) is { } sessionId ? await tokens.GetFor(sessionId, accessToken, context.RequestAborted) : UserAccessTokenResult.Failed(UserAccessTokenFailure.NoRefreshToken); @@ -78,6 +87,13 @@ static bool TryGetAccessToken(IReverseProxyFeature proxy, out string serviceName return true; } + static bool HasBoundDestinations(IReverseProxyFeature proxy) => + proxy.Cluster.Config.Metadata!.TryGetValue(MicroserviceReverseProxyConfigProvider.DestinationMetadataKey, out var destinationId) + && proxy.AvailableDestinations.Count > 0 + && proxy.AllDestinations.Count > 0 + && proxy.AvailableDestinations.All(destination => string.Equals(destination.DestinationId, destinationId, StringComparison.Ordinal)) + && proxy.AllDestinations.All(destination => string.Equals(destination.DestinationId, destinationId, StringComparison.Ordinal)); + static bool IsSessionRequest(HttpContext context) => context.User.Identity?.IsAuthenticated == true && string.Equals( diff --git a/Source/AuthProxy/ReverseProxy/MicroserviceReverseProxyConfigProvider.cs b/Source/AuthProxy/ReverseProxy/MicroserviceReverseProxyConfigProvider.cs index ec65f7d0..fff2a8ea 100644 --- a/Source/AuthProxy/ReverseProxy/MicroserviceReverseProxyConfigProvider.cs +++ b/Source/AuthProxy/ReverseProxy/MicroserviceReverseProxyConfigProvider.cs @@ -57,6 +57,11 @@ public class MicroserviceReverseProxyConfigProvider : IProxyConfigProvider, IDis /// internal const string AccessTokenMetadataKey = "Cratis.AuthProxy.AccessToken"; + /// + /// The cluster metadata key binding the access token policy to its versioned destination. + /// + internal const string DestinationMetadataKey = "Cratis.AuthProxy.Destination"; + /// /// The value of a service's backend cluster. /// @@ -551,17 +556,20 @@ static List BuildClusters(C.AuthProxy config) if (ms.Backend is not null) { + var destinationId = BackendDestinationId(key, ms); + var metadata = ClusterMetadata(key, BackendEndpoint, ms.AccessToken); + metadata[DestinationMetadataKey] = destinationId; clusters.Add(ClusterFor(config, ms, ms.Backend) with { ClusterId = BackendClusterId(key), - // A request retains its selected cluster config and available destinations. Give a changed - // binding a new destination state so YARP cannot mutate the address while it awaits a token. + // Give a changed binding a new destination state so YARP cannot mutate the address while + // a request awaits a token. Metadata lets forwarding reject mixed snapshots during reload. Destinations = new Dictionary { - [BackendDestinationId(key, ms)] = new() { Address = ms.Backend.BaseUrl } + [destinationId] = new() { Address = ms.Backend.BaseUrl } }, - Metadata = ClusterMetadata(key, BackendEndpoint, ms.AccessToken), + Metadata = metadata, }); }