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).
///