diff --git a/Directory.Packages.props b/Directory.Packages.props index 5fff4389..e408a79c 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 5918bef4..755ed8e9 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,112 @@ 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 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. + +| `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`, `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 +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 +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 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 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_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_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_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..3444ae62 --- /dev/null +++ b/Source/AuthProxy.Specs/Authentication/for_OidcClientAssertions/when_loading_managed_identity_credentials_for_sovereign_clouds.cs @@ -0,0 +1,69 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.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.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") + }) + { + 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://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.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.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_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.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_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.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 88937cd4..6678e4ac 100644 --- a/Source/AuthProxy/AuthProxy.csproj +++ b/Source/AuthProxy/AuthProxy.csproj @@ -14,6 +14,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..74d24beb --- /dev/null +++ b/Source/AuthProxy/Authentication/CertificateClientAssertion.cs @@ -0,0 +1,88 @@ +// 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.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"; + + /// + /// 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) + { + using var ecKey = certificate.HasPrivateKey ? certificate.GetECDsaPrivateKey() : null; + var signingCredentials = SigningCredentialsFor(certificate, ecKey); + 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 + }; + 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 handler.WriteToken(token); + } + + static SigningCredentials SigningCredentialsFor(X509Certificate2 certificate, ECDsa? ecKey) + { + 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/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..f8ac4200 --- /dev/null +++ b/Source/AuthProxy/Authentication/OidcClientAssertions.cs @@ -0,0 +1,148 @@ +// 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 _credentials = 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 state = _credentials.GetOrAdd( + scheme, + static (_, configured) => new CredentialState(OidcClientCredentialDescription.From(configured.ClientCredential!, configured.Authority)), + provider); + await state.Semaphore.WaitAsync(cancellationToken); + try + { + var description = state.Description; + 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) + { + // 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(); + } + } + + certificate = description.Certificate; + } + + if (certificate is not null) + { + if (certificate.NotAfter.ToUniversalTime() <= now.UtcDateTime) + { + throw new OidcClientCredentialUnavailable($"The client certificate of OIDC provider '{provider.Name}' has expired."); + } + + return CertificateClientAssertion.Create(certificate, provider.ClientId, audience, now); + } + + if (description.CachedValue is ClientAssertionProviderBase assertionProvider) + { + 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."); + } + finally + { + state.Semaphore.Release(); + } + } + + 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); + } + } + + 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/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..77761ba9 --- /dev/null +++ b/Source/AuthProxy/Authentication/OidcClientCredentialDescription.cs @@ -0,0 +1,69 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Client.Instance.Discovery; +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 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, string authority) => credential.Source switch + { + C.OidcClientCredentialSource.CertificateFile => new() + { + SourceType = CredentialSource.Path, + CertificateDiskPath = credential.CertificatePath, + CertificatePassword = string.IsNullOrEmpty(credential.CertificatePassword) ? null : 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) ?? 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) && + 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; +} 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). ///