From 9513ebd3ef2b855502c5442f5f8502a01baec2bc Mon Sep 17 00:00:00 2001 From: Sathyajith P S Date: Sun, 23 Aug 2026 20:37:56 +0530 Subject: [PATCH 1/2] feat: add certificate binding validation modes --- .changeset/brave-poets-decide.md | 8 + AGENTS.md | 19 +- .../CertificateBindingTests.cs | 289 ++++++++++++++++-- .../Mocks/OpenIdServerMock.cs | 66 ++-- .../MonoCloudAuthenticationOptionsTests.cs | 8 + .../PostConfigureTests.cs | 13 + .../CertificateBindingValidation.cs | 23 ++ .../MonoCloudAuthenticationHandler.cs | 72 +++-- .../MonoCloudAuthenticationOptions.cs | 7 +- ...ConfigureMonoCloudAuthenticationOptions.cs | 5 + ...tion.Api.CertificateBindingValidation.html | 153 ++++++++++ ...on.Api.MonoCloudAuthenticationOptions.html | 25 +- docs/api/MonoCloud.Authentication.Api.html | 8 + docs/api/toc.html | 3 + docs/api/toc.json | 2 +- docs/manifest.json | 14 + docs/xrefmap.yml | 24 ++ 17 files changed, 636 insertions(+), 103 deletions(-) create mode 100644 .changeset/brave-poets-decide.md create mode 100644 MonoCloud.Authentication.Api/CertificateBindingValidation.cs create mode 100644 docs/api/MonoCloud.Authentication.Api.CertificateBindingValidation.html diff --git a/.changeset/brave-poets-decide.md b/.changeset/brave-poets-decide.md new file mode 100644 index 0000000..8c75688 --- /dev/null +++ b/.changeset/brave-poets-decide.md @@ -0,0 +1,8 @@ +--- +'@monocloud/authentication-api': patch +--- + +Add certificate binding validation modes. + +- `ValidateCertificateBinding` is now a `CertificateBindingValidation` enum instead of a `Func`. +- Tokens whose `cnf` (confirmation) claim carries an `x5t#S256` thumbprint are now validated by default; previously the default never validated. Replace `ValidateCertificateBinding = _ => true` with `CertificateBindingValidation.Required`, and set `CertificateBindingValidation.DangerouslyIgnore` to opt out entirely. diff --git a/AGENTS.md b/AGENTS.md index 14de08c..5e2013a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,8 @@ Capabilities: - Opaque/reference token introspection (RFC 7662), with automatic JWT-vs-opaque detection. - Scope and group based authorization via the standard policy system. - Optional caching of introspection results via `IIntrospectionCache`. -- mTLS certificate-bound access tokens (RFC 8705) — `cnf`/`x5t#S256` validation. +- mTLS certificate-bound access tokens (RFC 8705) — `cnf`/`x5t#S256` validation, controlled by the + `CertificateBindingValidation` mode (`cnf`-bearing tokens are validated by default). - Client authentication for introspection: `client_secret_basic`, `client_secret_post`, `client_secret_jwt`, `private_key_jwt`, `tls_client_auth`, `spiffe_jwt` (JWT-SVID forwarded as a client assertion), and `spiffe_x509` (X.509-SVID over mTLS; behaves like `tls_client_auth`). @@ -159,10 +160,15 @@ pnpm changeset # record a version bump (Changesets; .changeset/, baseBranch In-flight introspections for the same scheme + token are de-duplicated via a static `IntrospectionCache` (`ConcurrentDictionary` of `Lazy>` keyed `{Scheme.Name}|{token}`) removed in a `finally` — this only collapses concurrent duplicate calls, it is not a result cache. -5. If `ValidateCertificateBinding(context)` returns true, the presented client certificate's - base64url SHA-256 is compared against the token's `cnf.x5t#S256` claim — enforced on the JWT path, - the live opaque path, and the cached opaque path, all through the single - `ValidateCertificateBinding(claims)` method. +5. Certificate binding runs per `Options.ValidateCertificateBinding` (a `CertificateBindingValidation`): + `WhenPresent` (default) validates only when the token's `cnf` claim carries an `x5t#S256` member — + a `cnf` confirming by another method (e.g. DPoP's `jkt`) is skipped, while an unparseable `cnf` or a + non-string thumbprint still validates and fails; `Required` always validates, rejecting tokens + without one; `DangerouslyIgnore` never validates. The presented client certificate's base64url + SHA-256 is compared against the token's `cnf.x5t#S256` claim — enforced on the JWT path, the live + opaque path, and the cached opaque path, all through the single `ValidateCertificateBinding(claims)` + method, which evaluates the mode gate up front and only invokes `CertificateRetriever` (and only + raises `CertificateBindingValidated`) when validation actually runs. 6. Opaque-path error semantics mirror the base `JwtBearerHandler`: genuine token verdicts (introspection `active:false`, live or cached, and certificate-binding failures) return `AuthenticateResult.Fail` → 401 `invalid_token` challenge, while infrastructure and consumer-event @@ -173,7 +179,8 @@ pnpm changeset # record a version bump (Changesets; .changeset/, baseBranch Cache read *and* write failures are logged and never affect the outcome. Known asymmetry: a discovery outage on the JWT path still surfaces as `Fail` → 401 (the fetch happens inside Wilson's `ValidateTokenAsync`), while on the opaque path it rethrows. -7. `PostConfigure` runs once per options instance: https-prefixes a scheme-less `Authority` (the tenant +7. `PostConfigure` runs once per options instance: rejects an undefined `ValidateCertificateBinding` + value (an out-of-range enum would otherwise silently skip binding at request time), https-prefixes a scheme-less `Authority` (the tenant domain; an explicit `http://` is left alone for dev setups), builds the `HttpClient` (special-cased for `TlsAuth` with a client cert), maps MonoCloud options onto the inherited ones (`Backchannel` ← `HttpClient`, and `AuthenticationType`/`NameClaimType`/`RoleClaimType`/`ClockSkew` onto diff --git a/MonoCloud.Authentication.Api.Tests/CertificateBindingTests.cs b/MonoCloud.Authentication.Api.Tests/CertificateBindingTests.cs index c4b026a..70c51a3 100644 --- a/MonoCloud.Authentication.Api.Tests/CertificateBindingTests.cs +++ b/MonoCloud.Authentication.Api.Tests/CertificateBindingTests.cs @@ -12,7 +12,6 @@ private static MonoCloudAuthenticationOptions BindingOptions(OpenIdServerMock se Authority = OpenIdServerMock.Issuer, Audience = OpenIdServerMock.Issuer, MapInboundClaims = false, - ValidateCertificateBinding = _ => true, HttpClient = server.Build() }; @@ -31,7 +30,6 @@ private static MonoCloudAuthenticationOptions OpaqueBindingOptions(OpenIdServerM Authority = OpenIdServerMock.Issuer, ClientId = OpenIdServerMock.ClientId, ClientAuth = new ClientSecretAuth(OpenIdServerMock.SymmetricSecret), - ValidateCertificateBinding = _ => true, HttpClient = server.Build() }; @@ -54,7 +52,7 @@ public async Task Should_Succeed_When_CertificateMatchesBinding() return Task.CompletedTask; }); - var token = OpenIdServerMock.CreateAccessToken(); + var token = OpenIdServerMock.CreateAccessToken(includeCnf: true); var (handler, _) = await HandlerTestHarness.CreateAsync(options, token, clientCertificate: OpenIdServerMock.MtlsClientCert); var result = await handler.AuthenticateAsync(); @@ -67,7 +65,7 @@ public async Task Should_Succeed_When_CertificateMatchesBinding() public async Task Should_Fail_When_ClientCertificateIsMissing() { var options = BindingOptions(new OpenIdServerMock()); - var token = OpenIdServerMock.CreateAccessToken(); + var token = OpenIdServerMock.CreateAccessToken(includeCnf: true); var (handler, _) = await HandlerTestHarness.CreateAsync(options, token); var result = await handler.AuthenticateAsync(); @@ -77,10 +75,10 @@ public async Task Should_Fail_When_ClientCertificateIsMissing() } [Test] - public async Task Should_Fail_When_TokenHasNoCnfClaim() + public async Task Should_Fail_When_RequiredAndTokenHasNoCnfClaim() { - var options = BindingOptions(new OpenIdServerMock()); - var token = OpenIdServerMock.CreateAccessToken(excludeClaims: ["cnf"]); + var options = BindingOptions(new OpenIdServerMock(), o => o.ValidateCertificateBinding = CertificateBindingValidation.Required); + var token = OpenIdServerMock.CreateAccessToken(); var (handler, _) = await HandlerTestHarness.CreateAsync(options, token, clientCertificate: OpenIdServerMock.MtlsClientCert); var result = await handler.AuthenticateAsync(); @@ -89,6 +87,19 @@ public async Task Should_Fail_When_TokenHasNoCnfClaim() result.Failure!.Message.ShouldBe("Access token does not contain a 'cnf' (confirmation) claim for certificate binding"); } + [Test] + public async Task Should_Fail_When_RequiredAndNeitherCertificateNorCnfIsPresent() + { + var options = BindingOptions(new OpenIdServerMock(), o => o.ValidateCertificateBinding = CertificateBindingValidation.Required); + var token = OpenIdServerMock.CreateAccessToken(); + + var (handler, _) = await HandlerTestHarness.CreateAsync(options, token); + var result = await handler.AuthenticateAsync(); + + result.Succeeded.ShouldBeFalse(); + result.Failure!.Message.ShouldBe("Client certificate is not present"); + } + [Test] public async Task Should_Fail_When_CnfClaimIsMalformed() { @@ -103,9 +114,9 @@ public async Task Should_Fail_When_CnfClaimIsMalformed() } [Test] - public async Task Should_Fail_When_CnfHasNoThumbprintMember() + public async Task Should_Fail_When_RequiredAndCnfHasNoThumbprintMember() { - var options = BindingOptions(new OpenIdServerMock()); + var options = BindingOptions(new OpenIdServerMock(), o => o.ValidateCertificateBinding = CertificateBindingValidation.Required); var token = OpenIdServerMock.CreateAccessToken(new List { new("cnf", "{\"foo\":\"bar\"}", JsonClaimValueTypes.Json) }); var (handler, _) = await HandlerTestHarness.CreateAsync(options, token, clientCertificate: OpenIdServerMock.MtlsClientCert); @@ -115,6 +126,44 @@ public async Task Should_Fail_When_CnfHasNoThumbprintMember() result.Failure!.Message.ShouldBe("The 'cnf' claim does not contain an 'x5t#S256' member specifying the certificate hash for binding"); } + [Test] + public async Task Should_SkipBinding_When_CnfConfirmsByAnotherMethod() + { + var bindingValidated = false; + + var options = BindingOptions( + new OpenIdServerMock(), + o => o.Events.OnCertificateBindingValidated = _ => + { + bindingValidated = true; + return Task.CompletedTask; + }); + + var token = OpenIdServerMock.CreateAccessToken(new List + { + new("cnf", "{\"jkt\":\"0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I\"}", JsonClaimValueTypes.Json) + }); + + var (handler, _) = await HandlerTestHarness.CreateAsync(options, token); + var result = await handler.AuthenticateAsync(); + + result.Succeeded.ShouldBeTrue(result.Failure?.ToString() ?? "no failure"); + bindingValidated.ShouldBeFalse(); + } + + [Test] + public async Task Should_Fail_When_ThumbprintMemberIsNotAString() + { + var options = BindingOptions(new OpenIdServerMock()); + var token = OpenIdServerMock.CreateAccessToken(new List { new("cnf", "{\"x5t#S256\":123}", JsonClaimValueTypes.Json) }); + + var (handler, _) = await HandlerTestHarness.CreateAsync(options, token, clientCertificate: OpenIdServerMock.MtlsClientCert); + var result = await handler.AuthenticateAsync(); + + result.Succeeded.ShouldBeFalse(); + result.Failure!.Message.ShouldBe("The 'cnf' claim does not contain an 'x5t#S256' member specifying the certificate hash for binding"); + } + [Test] public async Task Should_Fail_When_CertificateHashDoesNotMatch() { @@ -186,7 +235,7 @@ public async Task Should_RaiseTokenValidated_AfterBindingSucceedsOnTheJwtPath() }; }); - var (handler, _) = await HandlerTestHarness.CreateAsync(options, OpenIdServerMock.CreateAccessToken(), clientCertificate: OpenIdServerMock.MtlsClientCert); + var (handler, _) = await HandlerTestHarness.CreateAsync(options, OpenIdServerMock.CreateAccessToken(includeCnf: true), clientCertificate: OpenIdServerMock.MtlsClientCert); var result = await handler.AuthenticateAsync(); result.Succeeded.ShouldBeTrue(result.Failure?.ToString() ?? "no failure"); @@ -194,20 +243,17 @@ public async Task Should_RaiseTokenValidated_AfterBindingSucceedsOnTheJwtPath() } [Test] - public async Task Should_NotValidateBinding_When_PredicateReturnsFalse() + public async Task Should_SkipBinding_When_TokenHasNoCnfClaim() { - // Default predicate is false; even with no client certificate present, auth should succeed. - var server = new OpenIdServerMock(); - server.SetupDiscovery(); - server.SetupJwks(); + var bindingValidated = false; - var options = new MonoCloudAuthenticationOptions - { - Authority = OpenIdServerMock.Issuer, - Audience = OpenIdServerMock.Issuer, - MapInboundClaims = false, - HttpClient = server.Build() - }; + var options = BindingOptions( + new OpenIdServerMock(), + o => o.Events.OnCertificateBindingValidated = _ => + { + bindingValidated = true; + return Task.CompletedTask; + }); var token = OpenIdServerMock.CreateAccessToken(); @@ -215,6 +261,75 @@ public async Task Should_NotValidateBinding_When_PredicateReturnsFalse() var result = await handler.AuthenticateAsync(); result.Succeeded.ShouldBeTrue(result.Failure?.ToString() ?? "no failure"); + bindingValidated.ShouldBeFalse(); + } + + [Test] + public async Task Should_SkipBinding_When_DangerouslyIgnore_EvenWhenBindingWouldFail() + { + var bindingValidated = false; + + var options = BindingOptions(new OpenIdServerMock(), o => + { + o.ValidateCertificateBinding = CertificateBindingValidation.DangerouslyIgnore; + + o.CertificateRetriever = _ => throw new InvalidOperationException("CertificateRetriever must not be invoked"); + + o.Events.OnCertificateBindingValidated = _ => + { + bindingValidated = true; + return Task.CompletedTask; + }; + }); + + var token = OpenIdServerMock.CreateAccessToken(new List + { + new("cnf", "{\"x5t#S256\":\"a-different-thumbprint\"}", JsonClaimValueTypes.Json) + }); + + var (handler, _) = await HandlerTestHarness.CreateAsync(options, token, clientCertificate: OpenIdServerMock.MtlsClientCert); + var result = await handler.AuthenticateAsync(); + + result.Succeeded.ShouldBeTrue(result.Failure?.ToString() ?? "no failure"); + bindingValidated.ShouldBeFalse(); + } + + [Test] + public async Task Should_Fail_When_CnfClaimParsesToNull() + { + var options = BindingOptions(new OpenIdServerMock()); + var token = OpenIdServerMock.CreateAccessToken(new List { new("cnf", "null") }); + + var (handler, _) = await HandlerTestHarness.CreateAsync(options, token, clientCertificate: OpenIdServerMock.MtlsClientCert); + var result = await handler.AuthenticateAsync(); + + result.Succeeded.ShouldBeFalse(); + result.Failure!.Message.ShouldBe("The 'cnf' claim could not be parsed"); + } + + [Test] + public async Task Should_ValidateBinding_When_InboundClaimMappingIsEnabled() + { + var bindingValidated = false; + + var options = BindingOptions(new OpenIdServerMock(), o => + { + o.MapInboundClaims = true; + + o.Events.OnCertificateBindingValidated = _ => + { + bindingValidated = true; + return Task.CompletedTask; + }; + }); + + var token = OpenIdServerMock.CreateAccessToken(includeCnf: true); + + var (handler, _) = await HandlerTestHarness.CreateAsync(options, token, clientCertificate: OpenIdServerMock.MtlsClientCert); + var result = await handler.AuthenticateAsync(); + + result.Succeeded.ShouldBeTrue(result.Failure?.ToString() ?? "no failure"); + bindingValidated.ShouldBeTrue(); } [Test] @@ -223,7 +338,7 @@ public async Task Should_UseCustomCertificateRetriever_When_Configured() var options = BindingOptions(new OpenIdServerMock(), o => o.CertificateRetriever = _ => Task.FromResult(OpenIdServerMock.MtlsClientCert)); - var token = OpenIdServerMock.CreateAccessToken(); + var token = OpenIdServerMock.CreateAccessToken(includeCnf: true); // No certificate is attached to the connection; the custom retriever supplies it. var (handler, _) = await HandlerTestHarness.CreateAsync(options, token); @@ -254,7 +369,7 @@ public async Task Should_ValidateBinding_OnIntrospectedToken() { var bindingValidated = false; var server = new OpenIdServerMock(); - server.SetupIntrospection(authType: "client_secret_post"); + server.SetupIntrospection(authType: "client_secret_post", includeCnf: true); var options = OpaqueBindingOptions(server, o => o.Events.OnCertificateBindingValidated = _ => { @@ -274,7 +389,7 @@ public async Task Should_ValidateBinding_OnIntrospectedToken() public async Task Should_Fail_When_IntrospectedTokenCertificateDoesNotMatch() { var server = new OpenIdServerMock(); - server.SetupIntrospection(authType: "client_secret_post"); + server.SetupIntrospection(authType: "client_secret_post", includeCnf: true); var options = OpaqueBindingOptions(server); @@ -287,6 +402,66 @@ public async Task Should_Fail_When_IntrospectedTokenCertificateDoesNotMatch() result.Failure!.Message.ShouldBe("The certificate hash in the access token does not match the presented client certificate (certificate binding validation failed)"); } + [Test] + public async Task Should_SkipBinding_When_IntrospectedTokenHasNoCnfClaim() + { + var bindingValidated = false; + var server = new OpenIdServerMock(); + server.SetupIntrospection(authType: "client_secret_post"); + + var options = OpaqueBindingOptions(server, o => o.Events.OnCertificateBindingValidated = _ => + { + bindingValidated = true; + return Task.CompletedTask; + }); + + var (handler, _) = await HandlerTestHarness.CreateAsync(options, "opaque-unbound-token"); + var result = await handler.AuthenticateAsync(); + + result.Succeeded.ShouldBeTrue(result.Failure?.ToString() ?? "no failure"); + bindingValidated.ShouldBeFalse(); + } + + [Test] + public async Task Should_Fail_When_RequiredAndIntrospectedTokenHasNoCnfClaim() + { + var server = new OpenIdServerMock(); + server.SetupIntrospection(authType: "client_secret_post"); + + var options = OpaqueBindingOptions(server, o => o.ValidateCertificateBinding = CertificateBindingValidation.Required); + + var (handler, _) = await HandlerTestHarness.CreateAsync(options, "opaque-unbound-token-required", clientCertificate: OpenIdServerMock.MtlsClientCert); + var result = await handler.AuthenticateAsync(); + + result.Succeeded.ShouldBeFalse(); + result.Failure!.Message.ShouldBe("Access token does not contain a 'cnf' (confirmation) claim for certificate binding"); + } + + [Test] + public async Task Should_SkipBinding_OnIntrospectedToken_When_DangerouslyIgnore() + { + var bindingValidated = false; + var server = new OpenIdServerMock(); + server.SetupIntrospection(authType: "client_secret_post", includeCnf: true); + + var options = OpaqueBindingOptions(server, o => + { + o.ValidateCertificateBinding = CertificateBindingValidation.DangerouslyIgnore; + + o.Events.OnCertificateBindingValidated = _ => + { + bindingValidated = true; + return Task.CompletedTask; + }; + }); + + var (handler, _) = await HandlerTestHarness.CreateAsync(options, "opaque-bound-token-ignored", clientCertificate: OpenIdServerMock.PrivateKeyCert); + var result = await handler.AuthenticateAsync(); + + result.Succeeded.ShouldBeTrue(result.Failure?.ToString() ?? "no failure"); + bindingValidated.ShouldBeFalse(); + } + [Test] public async Task Should_ValidateBinding_OnCachedClaims() { @@ -295,7 +470,7 @@ public async Task Should_ValidateBinding_OnCachedClaims() // First request introspects and caches the claims (including cnf). var server1 = new OpenIdServerMock(); - server1.SetupIntrospection(authType: "client_secret_post"); + server1.SetupIntrospection(authType: "client_secret_post", includeCnf: true); var options1 = OpaqueBindingOptions(server1, o => o.EnableCaching = true); var (handler1, _) = await HandlerTestHarness.CreateAsync(options1, token, cache, OpenIdServerMock.MtlsClientCert); (await handler1.AuthenticateAsync()).Succeeded.ShouldBeTrue(); @@ -334,7 +509,7 @@ public async Task Should_Fail_When_CachedClaimsCertificateDoesNotMatch() // First request introspects with the bound certificate and caches the (active) claims. var server1 = new OpenIdServerMock(); - server1.SetupIntrospection(authType: "client_secret_post"); + server1.SetupIntrospection(authType: "client_secret_post", includeCnf: true); var options1 = OpaqueBindingOptions(server1, o => o.EnableCaching = true); var (handler1, _) = await HandlerTestHarness.CreateAsync(options1, token, cache, OpenIdServerMock.MtlsClientCert); (await handler1.AuthenticateAsync()).Succeeded.ShouldBeTrue(); @@ -351,4 +526,64 @@ public async Task Should_Fail_When_CachedClaimsCertificateDoesNotMatch() result.Failure!.Message.ShouldBe("The certificate hash in the access token does not match the presented client certificate (certificate binding validation failed)"); server2.VerifyIntrospectionCalled(Times.Never()); } + + [Test] + public async Task Should_SkipBinding_OnCachedClaims_When_TokenHasNoCnfClaim() + { + var cache = new IntrospectionCacheMock(); + const string token = "opaque-unbound-cached"; + + var server1 = new OpenIdServerMock(); + server1.SetupIntrospection(authType: "client_secret_post"); + var options1 = OpaqueBindingOptions(server1, o => o.EnableCaching = true); + var (handler1, _) = await HandlerTestHarness.CreateAsync(options1, token, cache); + (await handler1.AuthenticateAsync()).Succeeded.ShouldBeTrue(); + cache.SetCount.ShouldBe(1); + + var bindingValidated = false; + var server2 = new OpenIdServerMock(); + + var options2 = OpaqueBindingOptions(server2, o => + { + o.EnableCaching = true; + + o.Events.OnCertificateBindingValidated = _ => + { + bindingValidated = true; + return Task.CompletedTask; + }; + }); + + var (handler2, _) = await HandlerTestHarness.CreateAsync(options2, token, cache); + var result = await handler2.AuthenticateAsync(); + + result.Succeeded.ShouldBeTrue(result.Failure?.ToString() ?? "no failure"); + bindingValidated.ShouldBeFalse(); + server2.VerifyIntrospectionCalled(Times.Never()); + } + + [Test] + public async Task Should_SkipBinding_OnCachedClaims_When_DangerouslyIgnore() + { + var cache = new IntrospectionCacheMock(); + const string token = "opaque-bound-cached-ignored"; + + var server1 = new OpenIdServerMock(); + server1.SetupIntrospection(authType: "client_secret_post", includeCnf: true); + var options1 = OpaqueBindingOptions(server1, o => o.EnableCaching = true); + var (handler1, _) = await HandlerTestHarness.CreateAsync(options1, token, cache, OpenIdServerMock.MtlsClientCert); + (await handler1.AuthenticateAsync()).Succeeded.ShouldBeTrue(); + + var server2 = new OpenIdServerMock(); + var options2 = OpaqueBindingOptions(server2, o => + { + o.EnableCaching = true; + o.ValidateCertificateBinding = CertificateBindingValidation.DangerouslyIgnore; + }); + var (handler2, _) = await HandlerTestHarness.CreateAsync(options2, token, cache, OpenIdServerMock.PrivateKeyCert); + var result = await handler2.AuthenticateAsync(); + + result.Succeeded.ShouldBeTrue(result.Failure?.ToString() ?? "no failure"); + server2.VerifyIntrospectionCalled(Times.Never()); + } } diff --git a/MonoCloud.Authentication.Api.Tests/Mocks/OpenIdServerMock.cs b/MonoCloud.Authentication.Api.Tests/Mocks/OpenIdServerMock.cs index fc26ec7..856d209 100644 --- a/MonoCloud.Authentication.Api.Tests/Mocks/OpenIdServerMock.cs +++ b/MonoCloud.Authentication.Api.Tests/Mocks/OpenIdServerMock.cs @@ -27,34 +27,42 @@ public class OpenIdServerMock private readonly DateTime? _now = DateTime.UtcNow; private const string JwksResponse = """{"keys": [{"kty": "RSA","e": "AQAB", "use": "sig", "kid": "test", "alg": "RS256", "n": "xkgdRhX4BK3laqvI6Do0uzD6brOPh79eNs9qAEXZp93QeWhyVKpwtcPonVCiIYP2pjpso0jxuEKOSAhUPdcKBbKqFHr0tYLG_DFo_9Z42Q7jMWtUVwpDcphzsZj1v7JP1JTOPD0ub-dqZuOXDkxSYLPGq1PBuVC4ETHftTU2NORidjOfaOBKjk1zBUmYwimaGgMh6veRn_9frQE90kDoizKG4_HTo5UdwJF34RekB1BoZl-BVxl22OOCyqyI4YOxxInzC76MXW8P3JS2CeOEmMz2ZM5CgX23MdiWC2j_7IMuEzmgNMmU7KlUhO6RKgnS6HYIHp4B8VWkAA_wU3oylQ" }]}"""; private readonly Mock _handlerMock = new(); - private object IntrospectionSuccessResponse => new + private Dictionary IntrospectionSuccessResponse(bool includeCnf) { - active = true, - iss = Issuer, - scope = "openid resource", - aud = new[] { Issuer, TokenEndpoint }, - sub = "1234567890", - client_id = ClientId, - groups = new List - { - new { id = "adminId", name = "admin" }, - new { id = "moderatorId", name = "moderator" } - }, - groupsAlt = new List - { - new { id = "editorId", name = "editor" }, - new { id = "viewerId", name = "viewer" } - }, - iat = ToUnixTimeStamp(_now!.Value), - exp = ToUnixTimeStamp(_now!.Value.AddMinutes(5)), - nbf = ToUnixTimeStamp(_now!.Value), - cnf = new Dictionary { { "x5t#S256", MtlsThumbprint } } + var response = new Dictionary + { + ["active"] = true, + ["iss"] = Issuer, + ["scope"] = "openid resource", + ["aud"] = new[] { Issuer, TokenEndpoint }, + ["sub"] = "1234567890", + ["client_id"] = ClientId, + ["groups"] = new List + { + new { id = "adminId", name = "admin" }, + new { id = "moderatorId", name = "moderator" } + }, + ["groupsAlt"] = new List + { + new { id = "editorId", name = "editor" }, + new { id = "viewerId", name = "viewer" } + }, + ["iat"] = ToUnixTimeStamp(_now!.Value), + ["exp"] = ToUnixTimeStamp(_now!.Value.AddMinutes(5)), + ["nbf"] = ToUnixTimeStamp(_now!.Value) + }; - }; + if (includeCnf) + { + response["cnf"] = new Dictionary { { "x5t#S256", MtlsThumbprint } }; + } + + return response; + } - public void SetupIntrospection(bool? failure = null, HttpStatusCode? status = null, string? authType = null, string? endpoint = null, object? body = null, Func? beforeRespond = null) + public void SetupIntrospection(bool? failure = null, HttpStatusCode? status = null, string? authType = null, string? endpoint = null, object? body = null, Func? beforeRespond = null, bool includeCnf = false) { - body ??= failure.HasValue && failure.Value ? new { active = false } : IntrospectionSuccessResponse; + body ??= failure.HasValue && failure.Value ? new { active = false } : IntrospectionSuccessResponse(includeCnf); status ??= HttpStatusCode.OK; @@ -147,7 +155,7 @@ public HttpClient Build() return new HttpClient(_handlerMock.Object); } - public static string CreateAccessToken(IList? payload = null, IEnumerable? excludeClaims = null, SigningCredentials? signingCredentials = null) + public static string CreateAccessToken(IList? payload = null, IEnumerable? excludeClaims = null, SigningCredentials? signingCredentials = null, bool includeCnf = false) { var now = DateTime.UtcNow; @@ -162,10 +170,14 @@ public static string CreateAccessToken(IList? payload = null, IEnumerable new("client_id", ClientId), new("scope", "openid resource"), new("groups", "[{\"id\":\"adminId\",\"name\":\"admin\"},{\"id\":\"moderatorId\",\"name\":\"moderator\"}]", JsonClaimValueTypes.JsonArray), - new("groupsAlt", "[{\"id\":\"editorId\",\"name\":\"editor\"},{\"id\":\"viewerId\",\"name\":\"viewer\"}]", JsonClaimValueTypes.JsonArray), - new("cnf", $"{{\"x5t#S256\":\"{MtlsThumbprint}\"}}", JsonClaimValueTypes.Json) + new("groupsAlt", "[{\"id\":\"editorId\",\"name\":\"editor\"},{\"id\":\"viewerId\",\"name\":\"viewer\"}]", JsonClaimValueTypes.JsonArray) }; + if (includeCnf) + { + standardClaims.Add(new("cnf", $"{{\"x5t#S256\":\"{MtlsThumbprint}\"}}", JsonClaimValueTypes.Json)); + } + if (payload is not null) { standardClaims = standardClaims.Where(x => payload.All(y => y.Type != x.Type)).ToList(); diff --git a/MonoCloud.Authentication.Api.Tests/MonoCloudAuthenticationOptionsTests.cs b/MonoCloud.Authentication.Api.Tests/MonoCloudAuthenticationOptionsTests.cs index 203cbc9..85fdd14 100644 --- a/MonoCloud.Authentication.Api.Tests/MonoCloudAuthenticationOptionsTests.cs +++ b/MonoCloud.Authentication.Api.Tests/MonoCloudAuthenticationOptionsTests.cs @@ -42,4 +42,12 @@ public void Options_InheritJwtBearerDefaults() options.IncludeErrorDetails.ShouldBeTrue(); options.RequireHttpsMetadata.ShouldBeTrue(); } + + [Test] + public void ValidateCertificateBinding_DefaultsToWhenPresent() + { + var options = new MonoCloudAuthenticationOptions(); + + options.ValidateCertificateBinding.ShouldBe(CertificateBindingValidation.WhenPresent); + } } diff --git a/MonoCloud.Authentication.Api.Tests/PostConfigureTests.cs b/MonoCloud.Authentication.Api.Tests/PostConfigureTests.cs index 1acb56f..31dee5a 100644 --- a/MonoCloud.Authentication.Api.Tests/PostConfigureTests.cs +++ b/MonoCloud.Authentication.Api.Tests/PostConfigureTests.cs @@ -22,6 +22,19 @@ public void Should_ThrowArgumentException_When_CachingIsEnabledWithoutCache() Should.Throw(() => postConfigureOptions.PostConfigure(null, options)).Message.ShouldBe("IIntrospectionCache not found in the services collection (Parameter '_cache')"); } + [Test] + public void Should_ThrowArgumentException_When_CertificateBindingValidationIsOutOfRange() + { + var options = new MonoCloudAuthenticationOptions + { + ValidateCertificateBinding = (CertificateBindingValidation)3 + }; + + var postConfigureOptions = new PostConfigureMonoCloudAuthenticationOptions(new HttpClientFactoryMock()); + + Should.Throw(() => postConfigureOptions.PostConfigure(null, options)).Message.ShouldBe("ValidateCertificateBinding must be a defined CertificateBindingValidation value (Parameter 'options')"); + } + [Test] public void Should_PrependHttps_When_AuthorityHasNoScheme() { diff --git a/MonoCloud.Authentication.Api/CertificateBindingValidation.cs b/MonoCloud.Authentication.Api/CertificateBindingValidation.cs new file mode 100644 index 0000000..a6abbcc --- /dev/null +++ b/MonoCloud.Authentication.Api/CertificateBindingValidation.cs @@ -0,0 +1,23 @@ +namespace MonoCloud.Authentication.Api; + +/// +/// Controls how certificate binding is validated for certificate-bound access tokens. +/// +public enum CertificateBindingValidation +{ + /// + /// Validates certificate binding only when the token's cnf (confirmation) claim carries + /// an x5t#S256 thumbprint member. + /// + WhenPresent, + + /// + /// Always validates certificate binding, rejecting tokens without a cnf claim. + /// + Required, + + /// + /// Never validates certificate binding, even when the token carries a cnf claim. + /// + DangerouslyIgnore +} diff --git a/MonoCloud.Authentication.Api/MonoCloudAuthenticationHandler.cs b/MonoCloud.Authentication.Api/MonoCloudAuthenticationHandler.cs index d042e4a..f6ae650 100644 --- a/MonoCloud.Authentication.Api/MonoCloudAuthenticationHandler.cs +++ b/MonoCloud.Authentication.Api/MonoCloudAuthenticationHandler.cs @@ -44,7 +44,7 @@ public MonoCloudAuthenticationHandler(IOptionsMonitor protected new MonoCloudAuthenticationEvents Events { - get => (MonoCloudAuthenticationEvents)base.Events!; + get => (MonoCloudAuthenticationEvents)base.Events; set => base.Events = value; } @@ -92,7 +92,7 @@ protected override async Task HandleAuthenticateAsync() } Logger.LogDebug("Handling with introspection"); - return await HandleOpaqueTokenAuthenticationAsync(token!); + return await HandleOpaqueTokenAuthenticationAsync(token); } private async Task HandleJwtBearerAuthenticationAsync(string token) @@ -160,13 +160,10 @@ private async Task HandleOpaqueTokenAuthenticationAsync(stri return await AuthenticationFailed("Token inactive", Context, Scheme, Events, Options); } - if (Options.ValidateCertificateBinding(Context)) + var certificateBindingResult = await ValidateCertificateBinding(claims); + if (certificateBindingResult is not null) { - var certificateBindingResult = await ValidateCertificateBinding(claims); - if (certificateBindingResult is not null) - { - return certificateBindingResult; - } + return certificateBindingResult; } return await CreateOpaqueTokenTicket(claims, token, Context, Scheme, Events, Options, Logger); @@ -192,13 +189,10 @@ private async Task HandleOpaqueTokenAuthenticationAsync(stri await TrySetClaimsCacheAsync(token, introspectionClaims); } - if (Options.ValidateCertificateBinding(Context)) + var certificateBindingResult = await ValidateCertificateBinding(introspectionClaims); + if (certificateBindingResult is not null) { - var certificateBindingResult = await ValidateCertificateBinding(introspectionClaims); - if (certificateBindingResult is not null) - { - return certificateBindingResult; - } + return certificateBindingResult; } return await CreateOpaqueTokenTicket(introspectionClaims, token, Context, Scheme, Events, Options, Logger); @@ -386,6 +380,35 @@ private static async Task CreateOpaqueTokenTicket(IList ValidateCertificateBinding(IEnumerable claims) { + var cnfClaim = claims.FirstOrDefault(x => x.Type == "cnf"); + + Dictionary? cnfClaimValue = null; + var cnfIsMalformed = false; + + if (cnfClaim is not null) + { + try + { + cnfClaimValue = JsonSerializer.Deserialize>(cnfClaim.Value); + } + catch (Exception) + { + cnfIsMalformed = true; + } + } + + var shouldValidate = Options.ValidateCertificateBinding switch + { + CertificateBindingValidation.Required => true, + CertificateBindingValidation.WhenPresent => cnfClaim is not null && (cnfClaimValue is null || cnfClaimValue.ContainsKey("x5t#S256")), + _ => false, + }; + + if (!shouldValidate) + { + return null; + } + Logger.LogDebug("Starting certificate binding validation"); var clientCertificate = await Options.CertificateRetriever(Context); @@ -399,18 +422,12 @@ private static async Task CreateOpaqueTokenTicket(IList x.Type == "cnf"); if (cnfClaim is null) { return await AuthenticationFailed("Access token does not contain a 'cnf' (confirmation) claim for certificate binding", Context, Scheme, Events, Options); } - Dictionary? cnfClaimValue; - try - { - cnfClaimValue = JsonSerializer.Deserialize>(cnfClaim.Value); - } - catch (Exception) + if (cnfIsMalformed) { return await AuthenticationFailed("Malformed 'cnf' claim for certificate binding", Context, Scheme, Events, Options); } @@ -523,15 +540,12 @@ public override async Task TokenValidated(TokenValidatedContext context) NormalizeScopes(context); - if (handler.Options.ValidateCertificateBinding(context.HttpContext)) - { - var result = await handler.ValidateCertificateBinding(context.Principal!.Claims); + var result = await handler.ValidateCertificateBinding(context.Principal!.Claims); - if (result is not null) - { - Apply(result, context); - return; - } + if (result is not null) + { + Apply(result, context); + return; } await inner.TokenValidated(context); diff --git a/MonoCloud.Authentication.Api/MonoCloudAuthenticationOptions.cs b/MonoCloud.Authentication.Api/MonoCloudAuthenticationOptions.cs index 3be43a0..68c7112 100644 --- a/MonoCloud.Authentication.Api/MonoCloudAuthenticationOptions.cs +++ b/MonoCloud.Authentication.Api/MonoCloudAuthenticationOptions.cs @@ -84,9 +84,12 @@ public MonoCloudAuthenticationOptions() public string? JwtAssertionSigningAlgorithm { get; set; } /// - /// Delegate used to determine whether certificate binding validation should be performed for the current request. + /// Controls whether the access token's certificate binding is validated against the client + /// certificate presented with the request. Defaults to + /// , which validates whenever the token's + /// cnf (confirmation) claim carries an x5t#S256 thumbprint member. /// - public Func ValidateCertificateBinding { get; set; } = _ => false; + public CertificateBindingValidation ValidateCertificateBinding { get; set; } = CertificateBindingValidation.WhenPresent; /// /// A delegate function used to retrieve an X.509 certificate from the current HTTP context. diff --git a/MonoCloud.Authentication.Api/PostConfigureMonoCloudAuthenticationOptions.cs b/MonoCloud.Authentication.Api/PostConfigureMonoCloudAuthenticationOptions.cs index 5a7d635..bf1fd14 100644 --- a/MonoCloud.Authentication.Api/PostConfigureMonoCloudAuthenticationOptions.cs +++ b/MonoCloud.Authentication.Api/PostConfigureMonoCloudAuthenticationOptions.cs @@ -27,6 +27,11 @@ public void PostConfigure(string? name, MonoCloudAuthenticationOptions options) throw new ArgumentException("IIntrospectionCache not found in the services collection", nameof(_cache)); } + if (!Enum.IsDefined(options.ValidateCertificateBinding)) + { + throw new ArgumentException("ValidateCertificateBinding must be a defined CertificateBindingValidation value", nameof(options)); + } + if (options.Authority is not null && !options.Authority.Contains("://")) { options.Authority = $"https://{options.Authority}"; diff --git a/docs/api/MonoCloud.Authentication.Api.CertificateBindingValidation.html b/docs/api/MonoCloud.Authentication.Api.CertificateBindingValidation.html new file mode 100644 index 0000000..5cbe0bc --- /dev/null +++ b/docs/api/MonoCloud.Authentication.Api.CertificateBindingValidation.html @@ -0,0 +1,153 @@ + + + + + Enum CertificateBindingValidation + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+
+
+
Table of Contents
+ +
+
+ +
+
+
+ +
+
+ + + +
+ +
+ + + + +

+Enum CertificateBindingValidation +

+ +
+
Namespace
MonoCloud.Authentication.Api
+
Assembly
MonoCloud.Authentication.Api.dll
+
+ +

Controls how certificate binding is validated for certificate-bound access tokens.

+
+
+ +
+
public enum CertificateBindingValidation
+
+ + + + + + + + + +

Fields +

+
+
DangerouslyIgnore = 2
+ +

Never validates certificate binding, even when the token carries a cnf claim.

+
+
Required = 1
+ +

Always validates certificate binding, rejecting tokens without a cnf claim.

+
+
WhenPresent = 0
+ +

Validates certificate binding only when the token's cnf (confirmation) claim carries +an x5t#S256 thumbprint member.

+
+
+ + + +
+ + + +
+ +
+ +
+
+ + +
+
+
+ Made with docfx +
+
+
+ + diff --git a/docs/api/MonoCloud.Authentication.Api.MonoCloudAuthenticationOptions.html b/docs/api/MonoCloud.Authentication.Api.MonoCloudAuthenticationOptions.html index 2ac5c1f..7263f1f 100644 --- a/docs/api/MonoCloud.Authentication.Api.MonoCloudAuthenticationOptions.html +++ b/docs/api/MonoCloud.Authentication.Api.MonoCloudAuthenticationOptions.html @@ -292,7 +292,7 @@

Properties

AuthenticationType - +

Specifies the authentication type associated with the identity, @@ -360,7 +360,7 @@

Property Value

CacheKeyGenerator - +

A delegate used to generate a unique cache key based on the provided @@ -426,7 +426,7 @@

Property Value

CertificateRetriever - +

A delegate function used to retrieve an X.509 certificate from the current HTTP context. @@ -629,7 +629,7 @@

Property Value

HttpClient - +

Provides an instance of HttpClient used to send HTTP requests @@ -669,7 +669,7 @@

IntrospectJwtTokens - +

Indicates whether JWT tokens must be introspected during authentication. @@ -770,7 +770,7 @@

Property Value

NameClaimType - +

Specifies the claim type to be used as the username in the identity. @@ -803,7 +803,7 @@

Property Value

RoleClaimType - +

Specifies the claim type used to determine the roles of a user during token validation.

@@ -835,15 +835,18 @@

Property Value

ValidateCertificateBinding - +

-

Delegate used to determine whether certificate binding validation should be performed for the current request.

+

Controls whether the access token's certificate binding is validated against the client +certificate presented with the request. Defaults to +WhenPresent, which validates whenever the token's +cnf (confirmation) claim carries an x5t#S256 thumbprint member.

-
public Func<HttpContext, bool> ValidateCertificateBinding { get; set; }
+
public CertificateBindingValidation ValidateCertificateBinding { get; set; }
@@ -852,7 +855,7 @@

Property Value

-
Func<HttpContext, bool>
+
CertificateBindingValidation
diff --git a/docs/api/MonoCloud.Authentication.Api.html b/docs/api/MonoCloud.Authentication.Api.html index 0e272d6..18e43e4 100644 --- a/docs/api/MonoCloud.Authentication.Api.html +++ b/docs/api/MonoCloud.Authentication.Api.html @@ -121,6 +121,14 @@

Supports all classes in the .NET class hierarchy and provides low-level services to derived classes. This is the ultimate base class of all .NET classes; it is the root of the type hierarchy.

+

+Enums +

+
+
CertificateBindingValidation
+

Controls how certificate binding is validated for certificate-bound access tokens.

+
+
diff --git a/docs/api/toc.html b/docs/api/toc.html index 77f0698..2e64c66 100644 --- a/docs/api/toc.html +++ b/docs/api/toc.html @@ -17,6 +17,9 @@ MonoCloud.Authentication.Api
public Func> CertificateRetriever { get; set; } = async context => await context.Connection.GetClientCertificateAsync(); diff --git a/docs/api/MonoCloud.Authentication.Api.MonoCloudAuthenticationOptions.html b/docs/api/MonoCloud.Authentication.Api.MonoCloudAuthenticationOptions.html index 7263f1f..35922ef 100644 --- a/docs/api/MonoCloud.Authentication.Api.MonoCloudAuthenticationOptions.html +++ b/docs/api/MonoCloud.Authentication.Api.MonoCloudAuthenticationOptions.html @@ -292,7 +292,7 @@

Properties

AuthenticationType - +

Specifies the authentication type associated with the identity, @@ -360,7 +360,7 @@

Property Value

CacheKeyGenerator - +

A delegate used to generate a unique cache key based on the provided @@ -426,13 +426,15 @@

Property Value

CertificateRetriever - +

A delegate function used to retrieve an X.509 certificate from the current HTTP context. This property allows customization of how the client certificate is accessed, providing the ability to handle scenarios where the certificate is needed for additional processing -or authentication validation.

+or authentication validation. Return null when no client certificate is +present; an exception thrown here is treated as a malformed client certificate and fails +authentication with a 401.

@@ -629,7 +631,7 @@

Property Value

HttpClient - +

Provides an instance of HttpClient used to send HTTP requests @@ -669,7 +671,7 @@

IntrospectJwtTokens - +

Indicates whether JWT tokens must be introspected during authentication. @@ -770,7 +772,7 @@

Property Value

NameClaimType - +

Specifies the claim type to be used as the username in the identity. @@ -803,7 +805,7 @@

Property Value

RoleClaimType - +

Specifies the claim type used to determine the roles of a user during token validation.