From 5c1cda0ac5182008928d832294c96c3241b8ae03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Chalet?= Date: Mon, 2 Feb 2026 17:31:40 +0100 Subject: [PATCH] Implement native mTLS client authentication support in the server stack --- Directory.Build.targets | 6 + ...OpenIddictClientWebIntegrationGenerator.cs | 7 +- .../Startup.cs | 2 +- .../Startup.cs | 2 +- .../Startup.cs | 231 +++- .../Startup.cs | 156 ++- .../Worker.cs | 37 +- .../OpenIddictHelpers.cs | 101 +- .../OpenIddictPolyfills.cs | 52 + .../Managers/IOpenIddictApplicationManager.cs | 62 +- .../IOpenIddictAuthorizationManager.cs | 4 +- .../Managers/IOpenIddictScopeManager.cs | 4 +- .../Managers/IOpenIddictTokenManager.cs | 4 +- .../OpenIddictConstants.cs | 14 + .../OpenIddictResources.resx | 82 ++ ...OpenIddictClientAspNetCoreConfiguration.cs | 2 +- ...nIddictClientSystemNetHttpConfiguration.cs | 63 +- .../OpenIddictClientBuilder.cs | 22 +- .../OpenIddictClientHandlers.Discovery.cs | 9 +- .../Managers/OpenIddictApplicationManager.cs | 277 +++- ...OpenIddictServerAspNetCoreConfiguration.cs | 23 +- .../OpenIddictServerAspNetCoreExtensions.cs | 5 +- ...ServerAspNetCoreHandlers.Authentication.cs | 1 + ...enIddictServerAspNetCoreHandlers.Device.cs | 1 + ...IddictServerAspNetCoreHandlers.Exchange.cs | 1 + ...tServerAspNetCoreHandlers.Introspection.cs | 1 + ...dictServerAspNetCoreHandlers.Revocation.cs | 1 + .../OpenIddictServerAspNetCoreHandlers.cs | 88 +- .../OpenIddictServerOwinConfiguration.cs | 19 + .../OpenIddictServerOwinExtensions.cs | 3 + ...IddictServerOwinHandlers.Authentication.cs | 1 + .../OpenIddictServerOwinHandlers.Device.cs | 1 + .../OpenIddictServerOwinHandlers.Exchange.cs | 1 + ...nIddictServerOwinHandlers.Introspection.cs | 1 + ...OpenIddictServerOwinHandlers.Revocation.cs | 1 + .../OpenIddictServerOwinHandlers.cs | 121 +- .../OpenIddictServerBuilder.cs | 443 ++++++- .../OpenIddictServerConfiguration.cs | 110 +- .../OpenIddictServerEvents.Discovery.cs | 25 + .../OpenIddictServerEvents.cs | 10 + .../OpenIddictServerExtensions.cs | 1 + .../OpenIddictServerHandlerFilters.cs | 14 + .../OpenIddictServerHandlers.Discovery.cs | 49 + .../OpenIddictServerHandlers.Protection.cs | 2 +- .../OpenIddictServerHandlers.cs | 134 +- .../OpenIddictServerOptions.cs | 89 ++ .../OpenIddictServerTransaction.cs | 6 + ...IddictValidationAspNetCoreConfiguration.cs | 2 +- ...ictValidationSystemNetHttpConfiguration.cs | 62 +- .../OpenIddictValidationBuilder.cs | 18 +- .../OpenIddictValidationConfiguration.cs | 7 +- ...OpenIddictValidationHandlers.Protection.cs | 2 +- ...ctServerIntegrationTests.Authentication.cs | 124 -- ...OpenIddictServerIntegrationTests.Device.cs | 118 -- ...nIddictServerIntegrationTests.Discovery.cs | 65 +- ...enIddictServerIntegrationTests.Exchange.cs | 127 -- ...ictServerIntegrationTests.Introspection.cs | 118 -- ...IddictServerIntegrationTests.Revocation.cs | 124 -- .../OpenIddictServerIntegrationTests.cs | 1162 +++++++++++++++++ .../OpenIddictServerBuilderTests.cs | 756 ++++++++++- 60 files changed, 4124 insertions(+), 850 deletions(-) diff --git a/Directory.Build.targets b/Directory.Build.targets index 6db20806..93a20cbe 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -68,6 +68,7 @@ $(DefineConstants);SUPPORTS_HTTP_CLIENT_DEFAULT_REQUEST_VERSION_POLICY $(DefineConstants);SUPPORTS_HTTP_CLIENT_RESILIENCE $(DefineConstants);SUPPORTS_INT32_RANDOM_NUMBER_GENERATOR_METHODS + $(DefineConstants);SUPPORTS_KESTREL_TLS_HANDSHAKE_CALLBACK_OPTIONS $(DefineConstants);SUPPORTS_MULTIPLE_VALUES_IN_QUERYHELPERS $(DefineConstants);SUPPORTS_NAMED_PIPE_STATIC_FACTORY_WITH_ACL $(DefineConstants);SUPPORTS_ONE_SHOT_HASHING_METHODS @@ -78,6 +79,9 @@ $(DefineConstants);SUPPORTS_TEXT_ELEMENT_ENUMERATOR $(DefineConstants);SUPPORTS_VALUETASK_COMPLETED_TASK $(DefineConstants);SUPPORTS_WINFORMS_TASK_DIALOG + $(DefineConstants);SUPPORTS_X509_CHAIN_POLICY_CUSTOM_TRUST_STORE + $(DefineConstants);SUPPORTS_X509_CHAIN_POLICY_DOWNLOAD_MODE + $(DefineConstants);SUPPORTS_X509_CHAIN_POLICY_TRUST_MODE $(DefineConstants);SUPPORTS_ZLIB_COMPRESSION @@ -114,6 +118,8 @@ $(DefineConstants);SUPPORTS_JSON_ELEMENT_DEEP_EQUALS $(DefineConstants);SUPPORTS_JSON_ELEMENT_PROPERTY_COUNT $(DefineConstants);SUPPORTS_TYPE_DESCRIPTOR_TYPE_REGISTRATION + $(DefineConstants);SUPPORTS_X509_CHAIN_POLICY_CLONING + $(DefineConstants);SUPPORTS_X509_CHAIN_POLICY_VERIFICATION_TIME_MODE () + .Cast() .SingleOrDefault(); } } @@ -652,7 +652,7 @@ public sealed partial class OpenIddictClientWebIntegrationBuilder return Set{{ setting.property_name }}( store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, validOnly: false) - .OfType() + .Cast() .SingleOrDefault() ?? throw new InvalidOperationException(SR.GetResourceString(SR.ID0066))); } {{~ else if setting.clr_type == 'bool' ~}} @@ -1163,8 +1163,7 @@ public sealed partial class OpenIddictClientWebIntegrationConfiguration if (settings.{{ setting.property_name }} is not null) { // If the signing key is an asymmetric security key, ensure it has a private key. - if (settings.{{ setting.property_name }} is AsymmetricSecurityKey asymmetricSecurityKey && - asymmetricSecurityKey.PrivateKeyStatus is PrivateKeyStatus.DoesNotExist) + if (settings.{{ setting.property_name }} is AsymmetricSecurityKey { PrivateKeyStatus: PrivateKeyStatus.DoesNotExist }) { throw new InvalidOperationException(SR.GetResourceString(SR.ID0067)); } diff --git a/sandbox/OpenIddict.Sandbox.AspNet.Client/Startup.cs b/sandbox/OpenIddict.Sandbox.AspNet.Client/Startup.cs index 7e7932e0..3e9e1b59 100644 --- a/sandbox/OpenIddict.Sandbox.AspNet.Client/Startup.cs +++ b/sandbox/OpenIddict.Sandbox.AspNet.Client/Startup.cs @@ -71,7 +71,7 @@ public class Startup ProviderDisplayName = "Local OIDC server", ClientId = "mvc", - ClientSecret = "901564A5-E7FE-42CB-B10D-61EF6A8F3654", + ClientSecret = "emCimpdc9SeOaZzN5jzm4_eek-STF6VenfVlKO1_qt0", Scopes = { Scopes.Email, Scopes.Profile, Scopes.OfflineAccess, "demo_api" }, RedirectUri = new Uri("callback/login/local", UriKind.Relative), diff --git a/sandbox/OpenIddict.Sandbox.AspNet.Server/Startup.cs b/sandbox/OpenIddict.Sandbox.AspNet.Server/Startup.cs index cd45438d..c5e20904 100644 --- a/sandbox/OpenIddict.Sandbox.AspNet.Server/Startup.cs +++ b/sandbox/OpenIddict.Sandbox.AspNet.Server/Startup.cs @@ -199,7 +199,7 @@ public class Startup { ApplicationType = ApplicationTypes.Web, ClientId = "mvc", - ClientSecret = "901564A5-E7FE-42CB-B10D-61EF6A8F3654", + ClientSecret = "emCimpdc9SeOaZzN5jzm4_eek-STF6VenfVlKO1_qt0", ClientType = ClientTypes.Confidential, ConsentType = ConsentTypes.Systematic, DisplayName = "MVC client application", diff --git a/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Startup.cs b/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Startup.cs index 53115126..0f017395 100644 --- a/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Startup.cs +++ b/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Startup.cs @@ -1,4 +1,5 @@ using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; @@ -112,26 +113,52 @@ public class Startup PostLogoutRedirectUri = new Uri("callback/logout/local", UriKind.Relative), #if SUPPORTS_PEM_ENCODED_KEY_IMPORT - // On supported platforms, this application authenticates by generating JWT client - // assertions that are signed using a signing key instead of using a client secret. + // On supported platforms, this application can authenticate using 3 different client + // authentication methods that all offer a higher security level than shared client secrets: // - // As such, no client secret is set, but an ECDSA key is registered and used by - // the OpenIddict client to automatically generate client assertions when needed. + // 1) tls_client_auth (PKI-based mutual TLS authentication): while it requires + // setting up a proper Public Key Infrastructure, this method offers a very + // high level of security, as the authorization server never has access to the + // private key used by the client to authenticate itself and can dynamically check + // the revocation status of the client certificate using standard PKI mechanisms. // - // Note: while the server only needs access to the public key, the client needs - // to know the private key to be able to generate and sign the client assertions. + // 2) self_signed_tls_client_auth (self-signed certificate-based mutual TLS authentication): + // this method is easier to deploy than PKI-based mutual TLS authentication, while + // still offering a high level of security. Unlike PKI-based mutual TLS authentication, + // the revocation status of the client certificate is never checked but certificates can + // be "revoked" by being removed from the JSON Web Key Set associated with the client. + // + // 3) private_key_jwt (JWT client assertions signed with a private key): while this + // method doesn't offer the same security guarantees as mutual TLS authentication, + // it is more secure than shared secrets and doesn't have the operational constraints + // required by the two mutual TLS methods described above (such as TLS configuration). + // + // The actual client authentication method used by the OpenIddict client is automatically + // selected based on the registered credentials and the methods supported by the server: + // when supported by the server, mutual TLS authentication methods are always preferred. + // + // In all cases, no client secret is necessary but the client needs to be able to access the + // private key of the certificate/key to be able to generate and sign the client assertions. + SigningCredentials = { - new SigningCredentials(GetECDsaSigningKey($""" - -----BEGIN EC PRIVATE KEY----- - MHcCAQEEIMGxf/eMzKuW2F8KKWPJo3bwlrO68rK5+xCeO1atwja2oAoGCCqGSM49 - AwEHoUQDQgAEI23kaVsRRAWIez/pqEZOByJFmlXda6iSQ4QqcH23Ir8aYPPX5lsV - nBsExNsl7SOYOiIhgTaX6+PTS7yxTnmvSw== - -----END EC PRIVATE KEY----- - """), SecurityAlgorithms.EcdsaSha256, SecurityAlgorithms.Sha256) - } + // Note: this certificate can be used with either tls_client_auth or private_key_jwt, + // depending on the server configuration (and the client authentication methods explicitly + // configured via OpenIddictClientRegistration.ClientAuthenticationMethods, if applicable). + // + // GetPublicKeyInfrastructureCertificate(), + + // Note: this certificate can be used with either self_signed_tls_client_auth or private_key_jwt, + // depending on the server configuration (and the client authentication methods explicitly + // configured via OpenIddictClientRegistration.ClientAuthenticationMethods, if applicable): + // + // GetSelfSignedCertificate(), + + // Note: this key can only be used with private_key_jwt as raw keys cannot be used with TLS. + GetSigningKey() + }, #else - ClientSecret = "901564A5-E7FE-42CB-B10D-61EF6A8F3654" + ClientSecret = "emCimpdc9SeOaZzN5jzm4_eek-STF6VenfVlKO1_qt0" #endif }); @@ -165,13 +192,181 @@ public class Startup }); #if SUPPORTS_PEM_ENCODED_KEY_IMPORT - static ECDsaSecurityKey GetECDsaSigningKey(ReadOnlySpan key) +#pragma warning disable CS8321 + static X509SigningCredentials GetPublicKeyInfrastructureCertificate() + { + // Note: OpenIddict only negotiates PKI-based or self-signed mutual + // TLS authentication if the certificate explicitly contains the + // "digitalSignature" key usage and the "clientAuth" extended key usage. + var certificate = X509Certificate2.CreateFromPem( + certPem: $""" + -----BEGIN CERTIFICATE----- + MIIEezCCAmOgAwIBAgIRALTZE9ezjPCWDFr38cp6AMAwDQYJKoZIhvcNAQELBQAw + GjEYMBYGA1UEAxMPSW50ZXJtZWRpYXRlIENBMCAXDTI2MDIwMTE0MTQ0M1oYDzIx + MjYwMjAyMTQxNDQzWjAaMRgwFgYDVQQDEw9FbmQgY2VydGlmaWNhdGUwggEiMA0G + CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDMs9spnvKeKw6VwPbpB47ikC6bL0Cn + S+K19Fp8dJg8b4dA1J1Y8dA2gi2nU/+ntOMYp1A6EvMZ8UpbgnSmhUN/2JQFU5Hc + PP0/IMjZAl2Iseh2yiK3Ril4Agbng6YW7e9P5YtMV+6i/stYujwNTXsUMr/+QSUI + Nze7856XSIl9gRjWEKJ17Jk/tJpun/zdpl4hXcptrsxxLU/E03bC3LcjiXzg8/Zl + 3/oEHqcHfv9C8RTdIBBw66zJAYzGfxwV31cJ9QQ2udlipi2l+ZR6jFWzzJI4XmiC + FzdwZRvhMLJsyK5miVIl0qPp3zJ2IyEb/2pLA0bc/ylZwVq6Z49k2xhZAgMBAAGj + gbkwgbYwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwFgYDVR0lAQH/BAww + CgYIKwYBBQUHAwIwHQYDVR0OBBYEFHPamNF/deBBv5JpDwiiRctPw4ziMEkGA1Ud + IwRCMECAFOEWwW18w3rZ6/5iIwAB12592OlHoRakFDASMRAwDgYDVQQDEwdSb290 + IENBghBvqw/xqI/LNgVfSURP9ck7MBQGA1UdEQQNMAuCCWxvY2FsaG9zdDANBgkq + hkiG9w0BAQsFAAOCAgEAbBq73JzDpK11hoRUxHq7LvplQEe/FNuD/slvn9Crfm2d + jJj0HsQQZpMgxP7SZ9FNvFqCo+/dm9PchIlwqwSjWtTxgYmcMOXw0Rzst85Ug4U1 + I2PG6iPxJ4WLSW2gzo//jFPa7MD1AnqDYwcCQTVsQW6aJavY3mFD31SJKsvSKqsV + 6xTXsajLRetCSXGe5qFgfyLC9tOhtTWXsCed/ISoQ9bljhOSqT6pxkpOVu0AHHMB + 1CMZay/B5ecjb66mwSoRcAPweMlAYJkjU5HXHSi7kB3gRQTsb1ZymEn67Q4C5cpI + Lq6UFK5bWZf1A0kFbYJBmn3oHsWxMQqv0F6QE7r4Mg6pfk9swzYZ8WqcgjiGHQET + pVU7ZKkUsg2JREXxRnhh5+Q+vGsF/DjhzQ6NrfPm8sqs+X+LzUN2cne8ZPclfyW2 + VKCHTPZ6o8mELiAlIPdBYUYsgUEOsfmUWbx4wfx5IB7vnenrenInLLyGOOCxR33d + o/gDMLFdeKHXK2ISsbDCk+zwEF8kztn1cXWK+K6H9cr8oJjDi1OJwTkqz9msar+9 + mjZ1CPAF0X+mLgrhVnNYqd5oqeeLerXKkAvpC2TgvlWJRGyDILhjva3J+2fQAYXZ + +OKFHNPf3n8Co4s5TMr1eiGVtS1etH6hPxnn5Jwnes9JZWFRLcjeTmPLSRWFucg= + -----END CERTIFICATE----- + """, + keyPem: $""" + -----BEGIN RSA PRIVATE KEY----- + MIIEpAIBAAKCAQEAzLPbKZ7ynisOlcD26QeO4pAumy9Ap0vitfRafHSYPG+HQNSd + WPHQNoItp1P/p7TjGKdQOhLzGfFKW4J0poVDf9iUBVOR3Dz9PyDI2QJdiLHodsoi + t0YpeAIG54OmFu3vT+WLTFfuov7LWLo8DU17FDK//kElCDc3u/Oel0iJfYEY1hCi + deyZP7Sabp/83aZeIV3Kba7McS1PxNN2wty3I4l84PP2Zd/6BB6nB37/QvEU3SAQ + cOusyQGMxn8cFd9XCfUENrnZYqYtpfmUeoxVs8ySOF5oghc3cGUb4TCybMiuZolS + JdKj6d8ydiMhG/9qSwNG3P8pWcFaumePZNsYWQIDAQABAoIBACorfyHC4d5dpmKJ + XxRAf1oDM+a6REpyoqCzVxS+fEIvA6ECa+vP3QHtrXQEJO2qoQIKLcfY8YXNpHDX + nipT18T1nADA55KEafNgUKAMEbLAW9Bk8ePpq09Ss5NsFoIwwBUoh5rRnpKrhL6h + lw9yf8F4dv7s8rEPlwa8OFaYFeLpoBLsPaX3nMu45CKb25dFZzSv9ORVs28LALrS + oK9MbtNFkmf/4EmpYA+nblkZd2bu4BomOF7C2F4bwtikN29vl4NPMhlbZGTy1hm9 + jzMOOvO1DwvIjHRVcfHKMDZ7cw1Pj5TmeApToSs6ygu1lce0GQcNVm+KV2qZMjNQ + Al6cdFUCgYEA7HZ6wvZU/WA7ei+jIwrJdyQQ3jU/LAu7GGhXiMU3z2RSR/vieY5R + 4IjQOgUkLBuQcy9uoQcSLpH/SNLIi6qhlMBvZuHq9QKF60t68tuW0PFSoa+SKaEn + DCZ70bnxo4OSRUtrzxikYHnwOvRGEli4EAOENETaQBKJUUygov/pOWMCgYEA3Z2a + TJlptRq75G6LHZvbBBzZdG9Mr04O6zvh5TGsJW86b6ov9BTAGz6Z37KWR1yUDfyH + dqNf90kJ8hs1eO6gGDQyGaH9yerrlULukANQfvpC0rEeJ7DfXSc1iLa3Q6+AOt5v + 9TkQY7s/47iOPoCmblZ4FeVcIMx88ms2mBRXshMCgYAN9pkdNiqio7Ifbvy1Lwfi + jzCnzoEierbbpB23J9450vTA53DiOLNBDRMuuer+58nJ430m6SH7ugdXJ4tMJBFS + lWJ+ssyLF1ENKfHisXDgeb+laJa6+pcxsnwRUGeifjx+9wswuYXLZKf48z/ICZEk + 8PA3nfE9Y1rUgC/kMDR3fQKBgQCyQRRdTICUJV7ATJIlTLmLw1C9sNBzqUuitlXq + rluS+LZ+HtvXbeFfiKjoH5N07ug/n8GuEZcdJmiTjoMiNH4dOc6ag4vJH+ZB9sZA + nAnhOJcLNV/V+RSQrvsGbkFWdhGkSEqxaibesTyghFAVwhEcavzIT+Yck55ktwwA + o0wudQKBgQDR0hyl/cf6MBgZ3gce6dOcznLKoa2icypmmfNkA6sqwXwW20/WfDGb + ZNdaL4U3xReSN1mzrs0yStq0UrAChwrwqJc6T7uhGR/lDjvJCeZP9zO2yCSBvtul + LWFkJnofc7NUYkhVSGaAMeT14xUY/XlFbkXp0jZOqKMRo7PeeeXZaQ== + -----END RSA PRIVATE KEY----- + """); + + // On Windows, a certificate loaded from PEM-encoded material is ephemeral and + // cannot be directly used with TLS, as Schannel cannot access it in this case. + // + // To work this limitation, the certificate is exported and re-imported from a + // PFX blob to ensure the private key is persisted in a way that Schannel can use. + // + // In a real world application, the certificate wouldn't be embedded in the source code + // and would be installed in the certificate store, making this workaround unnecessary. + if (OperatingSystem.IsWindows()) + { + certificate = X509CertificateLoader.LoadPkcs12( + data : certificate.Export(X509ContentType.Pfx, string.Empty), + password : string.Empty, + keyStorageFlags: X509KeyStorageFlags.DefaultKeySet); + } + + return new X509SigningCredentials(certificate); + } + + static X509SigningCredentials GetSelfSignedCertificate() + { + // Note: OpenIddict only negotiates PKI-based or self-signed mutual + // TLS authentication if the certificate explicitly contains the + // "digitalSignature" key usage and the "clientAuth" extended key usage. + var certificate = X509Certificate2.CreateFromPem( + certPem: $""" + -----BEGIN CERTIFICATE----- + MIIC8zCCAdugAwIBAgIJAIZ9BN3TUnZQMA0GCSqGSIb3DQEBCwUAMCIxIDAeBgNV + BAMTF1NlbGYtc2lnbmVkIGNlcnRpZmljYXRlMCAXDTI2MDIwMjE0MzM0OVoYDzIx + MjYwMjAyMTQzMzQ5WjAiMSAwHgYDVQQDExdTZWxmLXNpZ25lZCBjZXJ0aWZpY2F0 + ZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAOtfKVPM7ghVFh4U/sz4 + sTrpaNJGQ2NORqawYxAHwluhr101yIOW7rWvFlFncA64Lkq9SAbFFCVSAbo28c6B + 2Mi41jyC4LHQU11jhv08K/3FUuckCuzEpzTnXUhxJHWxrRDVEuvKINGPs1VgVtTT + ra8rjP8s1YRAzCYnByxSx+8GXNGHprylLh0agpWKb2+2FYwDqY5ME2g3xTL9FTUu + FYWTcyspsvN0U1Eo1vlCeOxSYGPRct0MK0AS6eXEGBv+3kCYI7a5+UhQok0WvErF + pjIVo7USISDgKhW9GhTsWN+WywwdG4Kx4V6SB8ZLAHFSBSR3gjWS3TGOyqAWoBXc + znkCAwEAAaMqMCgwDgYDVR0PAQH/BAQDAgeAMBYGA1UdJQEB/wQMMAoGCCsGAQUF + BwMCMA0GCSqGSIb3DQEBCwUAA4IBAQBf5i/S7shmNalVxMuP8/Mk8cOhRRZjnAXd + zz3eOuXu0CH8iY/DwCgss04O2NTxuz87rKiuNKOrtY0oN/G4aFjWPvbgoQ+N1XP1 + zvbhqbyo3fQr07FyjWkrIUoHYFQ3JRfL+GPGjWizJsgdpdCRJSK6G9VX8eU3Akjv + YhMRLmbkrH5etOURqFtLpZlxNmLzCpqWIvzRiYyyj74iOipA2I0acgcvkakWn6rE + Wio7luBAZ3dXlukEfHTOg+ft4k0nOlRXPTtASOmyFQBOs6iYJeztHDz6MQnknAPe + +W53US8kLWktspcOQmxhVVH1g1/T4ynl9iX7tzqvUbdYwZNi92+x + -----END CERTIFICATE----- + """, + keyPem: $""" + -----BEGIN RSA PRIVATE KEY----- + MIIEpAIBAAKCAQEA618pU8zuCFUWHhT+zPixOulo0kZDY05GprBjEAfCW6GvXTXI + g5buta8WUWdwDrguSr1IBsUUJVIBujbxzoHYyLjWPILgsdBTXWOG/Twr/cVS5yQK + 7MSnNOddSHEkdbGtENUS68og0Y+zVWBW1NOtryuM/yzVhEDMJicHLFLH7wZc0Yem + vKUuHRqClYpvb7YVjAOpjkwTaDfFMv0VNS4VhZNzKymy83RTUSjW+UJ47FJgY9Fy + 3QwrQBLp5cQYG/7eQJgjtrn5SFCiTRa8SsWmMhWjtRIhIOAqFb0aFOxY35bLDB0b + grHhXpIHxksAcVIFJHeCNZLdMY7KoBagFdzOeQIDAQABAoIBAQDgBOqov9uuQo2S + hBkfrXPBxnXl7MomslG8RRWEJF5wKCtoY9A8rmL0uXhccj7NQ6+LoyvyhZDvFGZg + ffsXua5DHOmLHmYN12IA+MF6NNMJ7c1CAaQERgd+6tZ2JHm3Kyy1YJdppDAoRMVC + 9Tavyej9WE4ScPGntqSXi33gScnRTEGuuC0HydomT/rmguSWx8oPumeWelSTCh9c + vZ9Q1NOnRlW/VrNbYyyByiaWEgdrM2E/z3p+MFgrIsYxnIGQ/Ql1FbT0LxbeIYzc + 9MT4cbOlMrD0SZVk9lyxnCs/c1pN7pXDHutmDg6JzSj0xW5AYKzKSvXKjy7+uQay + YVyYh/QhAoGBAPKL1cZJMqwdQBzHMaHChth5cMh8/IkU6m3U7Ll75dztmaLFce+Y + Ova6te/D5Cm/l9pxx+vL5fuAafc2/FTesmKkE2DEERvy4EOQqB1Uho6XEoBBfnJT + 0xmNY5Jvh0TfyquS23KvzezT7+epFYNhZDQwgWPnx2z+jwa/zn8Ows/nAoGBAPht + crkmXBMncO7CXzFzFbDghIitW9cZnqBTzKwr2k9lVsbioTIYDbGruvABwI5sN2b4 + gJqcvnkun7dmooRPAGX/nMl5UxeGhdSlYGVzHchZz/310MdEg/JThIV219sHR5fd + pBlrydWDyfDTkiGZHDiYUzuZ6hCyOjf+MUgGlyKfAoGBAKYblF1G9hgftC/BT8Fb + quQIT3BPANiU5XQwtarWKndilax/EmenVwJwnndFLjZVS5dEA0n+i1Px/yBanPc2 + yO57NfY4cQs2C9bZ8/iaUcjHt9j0gbekptdCGKZKEVbe+TsFyZrCwgHmp8984gnn + IiwH6CVWsCJ6N9PEepRTtKGTAoGAV/wTdKW0WIhQhA9NPas/1GxAJFQZwd3uA2SK + ibPiVtpSWJAtfRttxi5HP/eu5gJHwO1kRt4ay7qKkJ8GEgwU3Qsh0W1p01wui/ii + YmvZ8Xp1osFr1xdaD/oqZkaH/qfeYFf8ZZB6ZGePnv6fs8yRZS311JcXgiBNZEVf + 2N2Uq4sCgYAoVe3zkP37MjIH6nykFiR396den5ZyMflR42QtO0Z2QJuQKs6yZ7ii + cqQy4r1Z2i6bdtUlesyGF5U7BPvcers/Mczax0u81Y2S9PdIsv8cw8sr8M6HHiS3 + IWBJpVJNyoHKLusRTYVqti+b5EHXQ55FZ9EJggvceGbcBamZ+ynYrg== + -----END RSA PRIVATE KEY----- + """); + + // On Windows, a certificate loaded from PEM-encoded material is ephemeral and + // cannot be directly used with TLS, as Schannel cannot access it in this case. + // + // To work this limitation, the certificate is exported and re-imported from a + // PFX blob to ensure the private key is persisted in a way that Schannel can use. + // + // In a real world application, the certificate wouldn't be embedded in the source code + // and would be installed in the certificate store, making this workaround unnecessary. + if (OperatingSystem.IsWindows()) + { + certificate = X509CertificateLoader.LoadPkcs12( + data : certificate.Export(X509ContentType.Pfx, string.Empty), + password : string.Empty, + keyStorageFlags: X509KeyStorageFlags.DefaultKeySet); + } + + return new X509SigningCredentials(certificate); + } + + static SigningCredentials GetSigningKey() { var algorithm = ECDsa.Create(); - algorithm.ImportFromPem(key); + algorithm.ImportFromPem($""" + -----BEGIN EC PRIVATE KEY----- + MHcCAQEEIMGxf/eMzKuW2F8KKWPJo3bwlrO68rK5+xCeO1atwja2oAoGCCqGSM49 + AwEHoUQDQgAEI23kaVsRRAWIez/pqEZOByJFmlXda6iSQ4QqcH23Ir8aYPPX5lsV + nBsExNsl7SOYOiIhgTaX6+PTS7yxTnmvSw== + -----END EC PRIVATE KEY----- + """); + + var key = new ECDsaSecurityKey(algorithm); - return new ECDsaSecurityKey(algorithm); + return new SigningCredentials(key, SecurityAlgorithms.EcdsaSha256, SecurityAlgorithms.Sha256); } +#pragma warning restore CS8321 #endif }); diff --git a/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Startup.cs b/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Startup.cs index c93aff16..9478fa2b 100644 --- a/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Startup.cs +++ b/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Startup.cs @@ -1,4 +1,8 @@ +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.AspNetCore.Server.Kestrel.Https; using Microsoft.EntityFrameworkCore; using OpenIddict.Sandbox.AspNetCore.Server.Models; using OpenIddict.Sandbox.AspNetCore.Server.Services; @@ -158,6 +162,106 @@ public class Startup // you don't own, you can disable access token encryption: // // options.DisableAccessTokenEncryption(); + +#if SUPPORTS_KESTREL_TLS_HANDSHAKE_CALLBACK_OPTIONS + // Enable both tls_client_auth and self_signed_tls_client_auth to allow clients + // to authenticate using either PKI certificates or self-signed certificates. + // + // Note: PKI and self-signed certificate authentication can be enabled independently. + options.EnablePublicKeyInfrastructureClientCertificateAuthentication( + [ + // Root certificate: + X509Certificate2.CreateFromPem($""" + -----BEGIN CERTIFICATE----- + MIIE7jCCAtagAwIBAgIJAN+SZB+xc7usMA0GCSqGSIb3DQEBCwUAMBIxEDAOBgNV + BAMTB1Jvb3QgQ0EwIBcNMjYwMjAxMTQxNDQzWhgPMjEyNjAyMDIxNDE0NDNaMBIx + EDAOBgNVBAMTB1Jvb3QgQ0EwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoIC + AQC68DOD6IvsJM0mc7n8bYeNaVe8e0ytJCJozdMPNXAe80vMPP4cVUPFvJ/tbSjX + yhREJ9xz2dYgQAhWCaTnEHY4AaE1Tj2rYqotenDQxs18qqaqoZlcaFfkRUPHRH3Q + iS8D8gbxzlYkjxNsfDJRi0cXFxr4wb4FmSP4ES2DFWWAWbN9wt7Tb2uDiHkjSefZ + Pni5F6fN6nE7wgGMYrdrCiiwJf7jEZiIZ60bsiUnJ5VUX6g4ob469CLocH/q/9Yr + Dad9/+YYp6SuHZilsPmW4X0fziuF/RvtsRLw4bw5jwj69KH3Y0jqUMQoyzz2CIJz + cDMB/MLREgcT9jTVB/M5Pl61DCzR/0d4t6RENpkNqpAIVM0Unp0nDuHPwjoeEZn3 + vSvUiGpiYY355GaSl05OE3SOKoRHt4lBXvY43y8fRBMOwlNHYn4eO3ZDuzZYzhfs + 68ywK4zUy47Qyn1BgNNqc/KC7kzxeLFxqTg2VJgBeXuJfucwzhOFqkOSfpeIGDK9 + 8MODFlA3usf5LXxQ7DJhkeBgPkW56BUlYVkenm9ORWe77GnoXL95p2HQUEXATHir + unXFPVcHET6hyegvc9AzSTZFQL4RMO3ZV5ESs+JF/YY4ycBc1+WYy2kAuP5sfsGn + mpeKf2Dv9MGFcDxU7iimeM92n7t5lbCRlX8NUaYeQ8jKgQIDAQABo0UwQzASBgNV + HRMBAf8ECDAGAQH/AgEBMA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUyHIqQnw/ + x6e92OuJ+y+e0LSKSqQwDQYJKoZIhvcNAQELBQADggIBACCOjX4+RyoZt9tVoKdT + uGFoEJUGBUfcYaMdqiDstuSoNrqZXHnilzj33ZbNqj6X6rS1w5qVnkj7ZY4Wu8MP + Sj85Wp9cq0jMv3NfPZfKmJd2K7favGQKvgSPptSl9VgcIrpRam9BG2db1IP053tf + ydBB3w/yI7MTb4fkPqLWtKcuPPM8t9SsxAlKhEm+gbNEsqDX9ZIfxolHEpL2zLOi + a4v7+SlJdVBfo4mj+iLUeZXFRPglAnPQ3CZngfbPsjEklpOCU1v8TnhHwV8jyCgl + oLAceLjdlXHWVfhKU+N0jdAt8V2NPxq+yJ/gPX+J3YOrYRCHMdQZ/OFEUhmkxHNp + UUPkL1VJ9c8ZW2/gszFPyvsh7GHwl43y7bN8doiQVOSj6jZ7uCkQl1oz731fl97b + FqKVyGGx6UUEi57YS7mWsY02qNvYSObOxhSNusX/Ct06XbXS1Pn+co/3FMGMcEVf + IwzboV61sMqRu4l3YD0Z1AxdhXFERMlHBYyyj8CQYIXtnCUoeT40IIR3aFmEG5u8 + /lwehTnV4slDDMJMFSW54aENpT1XP4b8m46kioNhxN+7ukdcWnYoapePuiDRboA3 + GwRExwFUDiGO8zpnyvV4JTIGu9MZi51O3RbSlIDDhyzFsdQW3PeztwGjZWZAXD+7 + qIoAaBbG/12cvNeZH7L9Mcpo + -----END CERTIFICATE----- + """), + + // Intermediate certificate (optional): + X509Certificate2.CreateFromPem($""" + -----BEGIN CERTIFICATE----- + MIIFQzCCAyugAwIBAgIQb6sP8aiPyzYFX0lET/XJOzANBgkqhkiG9w0BAQsFADAS + MRAwDgYDVQQDEwdSb290IENBMCAXDTI2MDIwMTE0MTQ0M1oYDzIxMjYwMjAyMTQx + NDQzWjAaMRgwFgYDVQQDEw9JbnRlcm1lZGlhdGUgQ0EwggIiMA0GCSqGSIb3DQEB + AQUAA4ICDwAwggIKAoICAQCfObzu9v5dfio8kCCKpb0vLXUrilcOM6FGVx50rPtc + MjlHNG4GpghoLXjJxrUIsoeGdsCI6W+K3R+5PRlEsbCT3l/0n2/ixW3rN9rO3FOt + VGOHYrE2wI+i1aWP1/w/0bcCbH1J6PLKPv5syzhWWdkoTy2K72gye5Kx3zXkFQoC + uBFMvj3HBgmTngaDRTT1QGsRSlhuvoEiHHAvgoTfYt7bgbRhM5I5upEbXB0cucj7 + Ghzws5R2/4qsr/QorwA8l6aNeb1dm0uB+FlMVlGelYMZ76+SjBs1rOxD0qt82h13 + BYPBR4gLvNFafOEskFndeP3OkNaQ6kPm+uClj5OxwONnBcy7neJPqZGMtxpApLK3 + reK3IZ/ieg1nY9zZ7OkqIzQDt1CeCBQWU3RkpEtVojkRDLCmg+pKSjHtLxUUGiQ3 + UHrXO2Yrej8Qpx4JHdKGUfku25r4SSaj2YF61ZIvsDxlOMROfJUFbpQdyAtQPCCq + zpfkVKaCeCiTlifI6AZODngc9c8U+s7vLxjucz/Q4gNHwcgg/mASbjh8A2hYsbW6 + qeg7lE9k5t6Lv820FudjfgFiq7k+zIbNsDNy3Y7CSsgBHQQSyNFngg25PPQOon9c + yd3PiFK36OzktnRkcTs98i3fwO2+3pp6qgOSk1Sdx877egszMjBPFzxrBX9CXIpM + yQIDAQABo4GKMIGHMBIGA1UdEwEB/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgEG + MB0GA1UdDgQWBBThFsFtfMN62ev+YiMAAddufdjpRzBCBgNVHSMEOzA5gBTIcipC + fD/Hp73Y64n7L57QtIpKpKEWpBQwEjEQMA4GA1UEAxMHUm9vdCBDQYIJAN+SZB+x + c7usMA0GCSqGSIb3DQEBCwUAA4ICAQA6sBGs28FWhKgh6TxZ6U8Lc+iCdc4c9PeM + L5pQQosHekT0oBJK8WdvyXZS95Fz2ddJaKiiQyUKSP4XHpxE+6tBt8OOV0LJJxnx + yKTBZtcSiOFssu2j6aqx3oMotRZJrhuI/5ChExaPwFT1W7aQDIY6lN2KcQ/xndbX + Nts/2nwCvlplfiOGM7XrRMU8b4X+AVWXSksvLXiByrDh9W6WGDsBHyu+FKQVnwmW + QVnshKpwxIsW25JDOhFE8+VHn6yciUKUTqnCFt5HjZpZh00q8hhmlhrNBEdkxA8N + OF7S1uWWftJywqq23qG6pGIDQ1r1dwNzgaeNhmW6QKm2zBXUmuOW//Xt+1wtHrly + bDjXKKSa/zhR9plYPdvGe9PopXwTw/fQWRYcxML6aH+WbWY9AgCHFgY56YCJYZd9 + eUIfrvPVJLn8fqwLmsWQtIY+XkAS/YQ4wTQs0zZS3+bdxeGQ6oHIMgxCDiBK8Qcc + RHf+RvYHiBllOJmaRaJHdsauMk9IlYYYpxPPwuWGti9B5HI4JO6bIqmR5Q8x3L/g + tFGMPzvWDTA2+dQcrh7WKULDH9Ngnnoodc6Hb9Iv1yCGYahcS6ARt9BzRyG1+6d9 + bq/zCH8KQCjryiTn3ZEpsln/iXtp5nHiLegUc1OoXldrUKAz9V93l61GHUw1kdhD + V+KJceDj3Q== + -----END CERTIFICATE----- + """) + ]); + + options.EnableSelfSignedClientCertificateAuthentication(); + + // Note: setting a static issuer is mandatory when using mTLS aliases + // to ensure it is not dynamically computed based on the request URI, + // as this would result in two different issuers being used (one + // pointing to the mTLS domain and one pointing to the regular one). + options.SetIssuer("https://localhost:44395/"); + + // Configure the mTLS endpoint aliases that will be used by client applications opting + // for TLS-based client authentication to communicate with the authorization server: + // the configured URIs MUST point to a domain for which the HTTPS server is configured + // to require the use of client certificates when receiving TLS handshakes from clients. + // + // Using mTLS endpoint aliases is not mandatory but is strongly recommended to avoid + // severely degrading the experience of users of browser-based clients, as TLS client + // authentication can only be enforced globally and not per-client, which would result + // in certificate selection prompts being systematically displayed by browsers. + options.SetMtlsDeviceAuthorizationEndpointAliasUri("https://mtls.dev.localhost:44395/connect/device") + .SetMtlsIntrospectionEndpointAliasUri("https://mtls.dev.localhost:44395/connect/introspect") + .SetMtlsPushedAuthorizationEndpointAliasUri("https://mtls.dev.localhost:44395/connect/par") + .SetMtlsRevocationEndpointAliasUri("https://mtls.dev.localhost:44395/connect/revoke") + .SetMtlsTokenEndpointAliasUri("https://mtls.dev.localhost:44395/connect/token"); +#endif }) // Register the OpenIddict validation components. @@ -178,7 +282,7 @@ public class Startup // options.UseIntrospection() // .SetIssuer("https://localhost:44395/") // .SetClientId("resource_server") - // .SetClientSecret("80B552BB-4CD8-48DA-946E-0815E0147DD2"); + // .SetClientSecret("vVQ-yjr42sXP5VHj6AswkXuS7MU1i2gFjvJjY0TdGMk"); // // When introspection is used, the System.Net.Http integration must be enabled. // @@ -202,6 +306,56 @@ public class Startup // Register the worker responsible for seeding the database with the sample clients. // Note: in a real world application, this step should be part of a setup script. services.AddHostedService(); + +#if SUPPORTS_KESTREL_TLS_HANDSHAKE_CALLBACK_OPTIONS + // Configure Kestrel to listen on the 44395 port and configure it to enforce mTLS. + // + // Note: depending on the operating system, the mtls.dev.localhost + // subdomain MAY have to be manually mapped to 127.0.0.1 or ::1. + services.Configure(options => options.ListenAnyIP(44395, options => + { + options.UseHttps(new TlsHandshakeCallbackOptions + { + OnConnection = GetServerAuthenticationOptionsAsync + }); + })); + + static ValueTask GetServerAuthenticationOptionsAsync(TlsHandshakeCallbackContext context) + { + using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser); + store.Open(OpenFlags.ReadOnly); + + return ValueTask.FromResult(new SslServerAuthenticationOptions + { + // Require a client certificate for all the requests pointing to the mTLS subdomain. + ClientCertificateRequired = string.Equals(context.ClientHelloInfo.ServerName, + "mtls.dev.localhost", StringComparison.OrdinalIgnoreCase), + + // Ignore all the client certificate errors for requests pointing to + // the mTLS-specific domain, even if they indicate that the chain is + // invalid: this is necessary to allow OpenIddict to validate the PKI + // and self-signed certificates using its own per-client chain policies. + RemoteCertificateValidationCallback = (sender, certificate, chain, errors) => + { + if (string.Equals(context.ClientHelloInfo.ServerName, + "mtls.dev.localhost", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return errors is SslPolicyErrors.None or SslPolicyErrors.RemoteCertificateNotAvailable; + }, + + // Use the same TLS server certificate as the default server instance. + ServerCertificate = store.Certificates + .Find(X509FindType.FindByExtension, "1.3.6.1.4.1.311.84.1.1", validOnly: false) + .Cast() + .OrderByDescending(static certificate => certificate.NotAfter) + .FirstOrDefault() ?? + throw new InvalidOperationException("The ASP.NET Core HTTPS development certificate was not found.") + }); + } +#endif } public void Configure(IApplicationBuilder app) diff --git a/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Worker.cs b/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Worker.cs index ed53e2f6..8653b910 100644 --- a/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Worker.cs +++ b/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Worker.cs @@ -1,5 +1,6 @@ using System.Globalization; using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; using Microsoft.IdentityModel.Tokens; using OpenIddict.Abstractions; using OpenIddict.Sandbox.AspNetCore.Server.Models; @@ -157,8 +158,36 @@ public class Worker : IHostedService { Keys = { - // On supported platforms, this application authenticates by generating JWT client - // assertions that are signed using a signing key instead of using a client secret. + // On supported platforms, this application can authenticate by using a + // self-signed client authentication certificate during the TLS handshake + // (a method known as "mutual TLS" or mTLS). + // + // Note: while the client needs access to the private key, the server only needs + // to know the public part to be able to validate the certificates it receives. + JsonWebKeyConverter.ConvertFromX509SecurityKey(new X509SecurityKey( + X509Certificate2.CreateFromPem($""" + -----BEGIN CERTIFICATE----- + MIIC8zCCAdugAwIBAgIJAIZ9BN3TUnZQMA0GCSqGSIb3DQEBCwUAMCIxIDAeBgNV + BAMTF1NlbGYtc2lnbmVkIGNlcnRpZmljYXRlMCAXDTI2MDIwMjE0MzM0OVoYDzIx + MjYwMjAyMTQzMzQ5WjAiMSAwHgYDVQQDExdTZWxmLXNpZ25lZCBjZXJ0aWZpY2F0 + ZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAOtfKVPM7ghVFh4U/sz4 + sTrpaNJGQ2NORqawYxAHwluhr101yIOW7rWvFlFncA64Lkq9SAbFFCVSAbo28c6B + 2Mi41jyC4LHQU11jhv08K/3FUuckCuzEpzTnXUhxJHWxrRDVEuvKINGPs1VgVtTT + ra8rjP8s1YRAzCYnByxSx+8GXNGHprylLh0agpWKb2+2FYwDqY5ME2g3xTL9FTUu + FYWTcyspsvN0U1Eo1vlCeOxSYGPRct0MK0AS6eXEGBv+3kCYI7a5+UhQok0WvErF + pjIVo7USISDgKhW9GhTsWN+WywwdG4Kx4V6SB8ZLAHFSBSR3gjWS3TGOyqAWoBXc + znkCAwEAAaMqMCgwDgYDVR0PAQH/BAQDAgeAMBYGA1UdJQEB/wQMMAoGCCsGAQUF + BwMCMA0GCSqGSIb3DQEBCwUAA4IBAQBf5i/S7shmNalVxMuP8/Mk8cOhRRZjnAXd + zz3eOuXu0CH8iY/DwCgss04O2NTxuz87rKiuNKOrtY0oN/G4aFjWPvbgoQ+N1XP1 + zvbhqbyo3fQr07FyjWkrIUoHYFQ3JRfL+GPGjWizJsgdpdCRJSK6G9VX8eU3Akjv + YhMRLmbkrH5etOURqFtLpZlxNmLzCpqWIvzRiYyyj74iOipA2I0acgcvkakWn6rE + Wio7luBAZ3dXlukEfHTOg+ft4k0nOlRXPTtASOmyFQBOs6iYJeztHDz6MQnknAPe + +W53US8kLWktspcOQmxhVVH1g1/T4ynl9iX7tzqvUbdYwZNi92+x + -----END CERTIFICATE----- + """))), + + // On supported platforms, this application can also authenticate by + // generating JWT client assertions that are signed using a signing key. // // Note: while the client needs access to the private key, the server only needs // to know the public key to be able to validate the client assertions it receives. @@ -171,7 +200,7 @@ public class Worker : IHostedService } }, #else - ClientSecret = "901564A5-E7FE-42CB-B10D-61EF6A8F3654", + ClientSecret = "emCimpdc9SeOaZzN5jzm4_eek-STF6VenfVlKO1_qt0", #endif RedirectUris = { @@ -306,7 +335,7 @@ public class Worker : IHostedService var descriptor = new OpenIddictApplicationDescriptor { ClientId = "resource_server", - ClientSecret = "80B552BB-4CD8-48DA-946E-0815E0147DD2", + ClientSecret = "vVQ-yjr42sXP5VHj6AswkXuS7MU1i2gFjvJjY0TdGMk", ClientType = ClientTypes.Confidential, Permissions = { diff --git a/shared/OpenIddict.Extensions/OpenIddictHelpers.cs b/shared/OpenIddict.Extensions/OpenIddictHelpers.cs index 3a7328c6..7ca3e7ab 100644 --- a/shared/OpenIddict.Extensions/OpenIddictHelpers.cs +++ b/shared/OpenIddict.Extensions/OpenIddictHelpers.cs @@ -11,6 +11,7 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Runtime.CompilerServices; using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; @@ -196,7 +197,7 @@ internal static class OpenIddictHelpers ArgumentNullException.ThrowIfNull(uri); var builder = new StringBuilder(uri.Query); - if (builder.Length > 0) + if (builder.Length is > 0) { builder.Append('&'); } @@ -238,7 +239,7 @@ internal static class OpenIddictHelpers // only append the parameter key to the query string. if (parameter.Value.Count is 0) { - if (builder.Length > 0) + if (builder.Length is > 0) { builder.Append('&'); } @@ -252,7 +253,7 @@ internal static class OpenIddictHelpers { foreach (var value in parameter.Value) { - if (builder.Length > 0) + if (builder.Length is > 0) { builder.Append('&'); } @@ -286,7 +287,7 @@ internal static class OpenIddictHelpers .Select(static parameter => parameter.Split(Separators.EqualsSign, StringSplitOptions.RemoveEmptyEntries)) .Select(static parts => ( Key: parts[0] is string key ? Uri.UnescapeDataString(key) : null, - Value: parts.Length > 1 && parts[1] is string value ? Uri.UnescapeDataString(value) : null)) + Value: parts.Length is > 1 && parts[1] is string value ? Uri.UnescapeDataString(value) : null)) .Where(static pair => !string.IsNullOrEmpty(pair.Key)) .GroupBy(static pair => pair.Key) .ToDictionary(static pair => pair.Key!, static pair => new StringValues([.. pair.Select(parts => parts.Value)])); @@ -307,7 +308,7 @@ internal static class OpenIddictHelpers .Select(static parameter => parameter.Split(Separators.EqualsSign, StringSplitOptions.RemoveEmptyEntries)) .Select(static parts => ( Key: parts[0] is string key ? Uri.UnescapeDataString(key) : null, - Value: parts.Length > 1 && parts[1] is string value ? Uri.UnescapeDataString(value) : null)) + Value: parts.Length is > 1 && parts[1] is string value ? Uri.UnescapeDataString(value) : null)) .Where(static pair => !string.IsNullOrEmpty(pair.Key)) .GroupBy(static pair => pair.Key) .ToDictionary(static pair => pair.Key!, static pair => new StringValues([.. pair.Select(parts => parts.Value)])); @@ -998,7 +999,7 @@ internal static class OpenIddictHelpers /// /// The . /// - /// if the JSON node is null or empty otherwise. + /// if the JSON node is null or empty, otherwise. /// public static bool IsNullOrEmpty([NotNullWhen(false)] JsonNode? node) => node switch { @@ -1015,6 +1016,94 @@ internal static class OpenIddictHelpers JsonNode value => IsNullOrEmpty(value.Deserialize(OpenIddictSerializer.Default.JsonElement)) }; + /// + /// Determines whether the specified is a certificate authority. + /// + /// The . + /// + /// if the certificate is a certificate authority, otherwise. + /// + public static bool IsCertificateAuthority(X509Certificate2 certificate) + { + ArgumentNullException.ThrowIfNull(certificate); + + return certificate.Extensions.OfType() + .Any(static extension => extension.CertificateAuthority); + } + + /// + /// Determines whether the specified has the specified extended key usage. + /// + /// The . + /// The extended key usage. + /// + /// if the certificate has the specified extended key usage, otherwise. + /// + public static bool HasExtendedKeyUsage(X509Certificate2 certificate, string usage) + { + for (var index = 0; index < certificate.Extensions.Count; index++) + { + if (certificate.Extensions[index] is X509EnhancedKeyUsageExtension extension && + HasOid(extension.EnhancedKeyUsages, usage)) + { + return true; + } + } + + return false; + + static bool HasOid(OidCollection collection, string value) + { + for (var index = 0; index < collection.Count; index++) + { + if (collection[index] is Oid oid && string.Equals(oid.Value, value, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + } + + /// + /// Determines whether the specified has the specified key usage. + /// + /// The . + /// The . + /// + /// if the certificate has the specified key usage, otherwise. + /// + public static bool HasKeyUsage(X509Certificate2 certificate, X509KeyUsageFlags usage) + { + ArgumentNullException.ThrowIfNull(certificate); + + for (var index = 0; index < certificate.Extensions.Count; index++) + { + if (certificate.Extensions[index] is X509KeyUsageExtension extension && + extension.KeyUsages.HasFlag(usage)) + { + return true; + } + } + + return false; + } + + /// + /// Determines whether the specified is self-issued. + /// + /// The . + /// + /// if the certificate is self-issued, otherwise. + /// + public static bool IsSelfIssuedCertificate(X509Certificate2 certificate) + { + ArgumentNullException.ThrowIfNull(certificate); + + return certificate.SubjectName.RawData.AsSpan().SequenceEqual(certificate.IssuerName.RawData); + } + /// /// Determines whether the items contained in /// are of the specified . diff --git a/shared/OpenIddict.Extensions/OpenIddictPolyfills.cs b/shared/OpenIddict.Extensions/OpenIddictPolyfills.cs index 07f12b29..78d8e04d 100644 --- a/shared/OpenIddict.Extensions/OpenIddictPolyfills.cs +++ b/shared/OpenIddict.Extensions/OpenIddictPolyfills.cs @@ -8,6 +8,8 @@ using System.Diagnostics; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Runtime.Versioning; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; namespace OpenIddict.Extensions; @@ -303,4 +305,54 @@ internal static class OpenIddictPolyfills return currentRevision >= revision; } #endif + + extension(X509ChainPolicy policy) + { +#if !SUPPORTS_X509_CHAIN_POLICY_CLONING + public X509ChainPolicy Clone() + { + var clone = new X509ChainPolicy + { +#if SUPPORTS_X509_CHAIN_POLICY_DOWNLOAD_MODE + DisableCertificateDownloads = policy.DisableCertificateDownloads, +#endif + RevocationMode = policy.RevocationMode, + RevocationFlag = policy.RevocationFlag, +#if SUPPORTS_X509_CHAIN_POLICY_TRUST_MODE + TrustMode = policy.TrustMode, +#endif + UrlRetrievalTimeout = policy.UrlRetrievalTimeout, + VerificationFlags = policy.VerificationFlags, + VerificationTime = policy.VerificationTime, +#if SUPPORTS_X509_CHAIN_POLICY_VERIFICATION_TIME_MODE + VerificationTimeIgnored = policy.VerificationTimeIgnored +#endif + }; + + if (policy.ApplicationPolicy.Count is > 0) + { + for (var index = 0; index < policy.ApplicationPolicy.Count; index++) + { + clone.ApplicationPolicy.Add(policy.ApplicationPolicy[index]); + } + } + + if (policy.CertificatePolicy.Count is > 0) + { + for (var index = 0; index < policy.CertificatePolicy.Count; index++) + { + clone.CertificatePolicy.Add(policy.CertificatePolicy[index]); + } + } + +#if SUPPORTS_X509_CHAIN_POLICY_CUSTOM_TRUST_STORE + clone.CustomTrustStore.AddRange(policy.CustomTrustStore); +#endif + + clone.ExtraStore.AddRange(policy.ExtraStore); + + return clone; + } +#endif + } } diff --git a/src/OpenIddict.Abstractions/Managers/IOpenIddictApplicationManager.cs b/src/OpenIddict.Abstractions/Managers/IOpenIddictApplicationManager.cs index 55b47d9a..2accbe4d 100644 --- a/src/OpenIddict.Abstractions/Managers/IOpenIddictApplicationManager.cs +++ b/src/OpenIddict.Abstractions/Managers/IOpenIddictApplicationManager.cs @@ -8,6 +8,7 @@ using System.Collections.Immutable; using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; using System.Globalization; +using System.Security.Cryptography.X509Certificates; using System.Text.Json; using Microsoft.IdentityModel.Tokens; @@ -15,12 +16,14 @@ namespace OpenIddict.Abstractions; /// /// Provides methods allowing to manage the applications stored in the store. +/// +/// /// Note: this interface is not meant to be implemented by custom managers, /// that should inherit from the generic OpenIddictApplicationManager class. /// It is primarily intended to be used by services that cannot easily depend /// on the generic application manager. The actual application entity type /// is automatically determined at runtime based on the OpenIddict core options. -/// +/// public interface IOpenIddictApplicationManager { /// @@ -171,6 +174,19 @@ public interface IOpenIddictApplicationManager Func, TState, IQueryable> query, TState state, CancellationToken cancellationToken = default); + /// + /// Retrieves the client certificate chain policy enforced for this application. + /// + /// The application. + /// The base policy from which the returned instance will be derived. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the client certificate chain policy enforced for this application. + /// + ValueTask GetClientCertificateChainPolicyAsync( + object application, X509ChainPolicy policy, CancellationToken cancellationToken = default); + /// /// Retrieves the client identifier associated with an application. /// @@ -330,6 +346,19 @@ public interface IOpenIddictApplicationManager /// ValueTask> GetRequirementsAsync(object application, CancellationToken cancellationToken = default); + /// + /// Retrieves the self-signed client certificate chain policy enforced for this application. + /// + /// The application. + /// The base policy from which the returned instance will be derived. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, whose + /// result returns the self-signed client certificate chain policy enforced for this application. + /// + ValueTask GetSelfSignedClientCertificateChainPolicyAsync( + object application, X509ChainPolicy policy, CancellationToken cancellationToken = default); + /// /// Retrieves the settings associated with an application. /// @@ -475,6 +504,21 @@ public interface IOpenIddictApplicationManager /// ValueTask UpdateAsync(object application, string secret, CancellationToken cancellationToken = default); + /// + /// Validates the client certificate associated with an application. + /// + /// The application. + /// The certificate that should be compared to the certificates associated with the application. + /// The chain policy used to validate the certificate. + /// The that can be used to abort the operation. + /// A that can be used to monitor the asynchronous operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns a boolean indicating whether the client certificate was valid. + /// + ValueTask ValidateClientCertificateAsync(object application, + X509Certificate2 certificate, X509ChainPolicy policy, CancellationToken cancellationToken = default); + /// /// Validates the application to ensure it's in a consistent state. /// @@ -522,4 +566,20 @@ public interface IOpenIddictApplicationManager /// ValueTask ValidateRedirectUriAsync(object application, [StringSyntax(StringSyntaxAttribute.Uri)] string uri, CancellationToken cancellationToken = default); + + /// + /// Validates the self-signed client certificate associated with an application. + /// + /// The application. + /// The certificate that should be compared to the certificates associated with the application. + /// The chain policy used to validate the certificate. + /// The that can be used to abort the operation. + /// A that can be used to monitor the asynchronous operation. + /// + /// A that can be used to monitor the asynchronous operation, whose + /// result returns a boolean indicating whether the self-signed client certificate was valid. + /// + ValueTask ValidateSelfSignedClientCertificateAsync( + object application, X509Certificate2 certificate, + X509ChainPolicy policy, CancellationToken cancellationToken = default); } diff --git a/src/OpenIddict.Abstractions/Managers/IOpenIddictAuthorizationManager.cs b/src/OpenIddict.Abstractions/Managers/IOpenIddictAuthorizationManager.cs index a50cb1ba..a5ad9310 100644 --- a/src/OpenIddict.Abstractions/Managers/IOpenIddictAuthorizationManager.cs +++ b/src/OpenIddict.Abstractions/Managers/IOpenIddictAuthorizationManager.cs @@ -13,12 +13,14 @@ namespace OpenIddict.Abstractions; /// /// Provides methods allowing to manage the authorizations stored in the store. +/// +/// /// Note: this interface is not meant to be implemented by custom managers, /// that should inherit from the generic OpenIddictAuthorizationManager class. /// It is primarily intended to be used by services that cannot easily depend /// on the generic authorization manager. The actual authorization entity type /// is automatically determined at runtime based on the OpenIddict core options. -/// +/// public interface IOpenIddictAuthorizationManager { /// diff --git a/src/OpenIddict.Abstractions/Managers/IOpenIddictScopeManager.cs b/src/OpenIddict.Abstractions/Managers/IOpenIddictScopeManager.cs index 8dbb5459..65c4fc33 100644 --- a/src/OpenIddict.Abstractions/Managers/IOpenIddictScopeManager.cs +++ b/src/OpenIddict.Abstractions/Managers/IOpenIddictScopeManager.cs @@ -13,12 +13,14 @@ namespace OpenIddict.Abstractions; /// /// Provides methods allowing to manage the scopes stored in the store. +/// +/// /// Note: this interface is not meant to be implemented by custom managers, /// that should inherit from the generic OpenIddictScopeManager class. /// It is primarily intended to be used by services that cannot easily /// depend on the generic scope manager. The actual scope entity type is /// automatically determined at runtime based on the OpenIddict core options. -/// +/// public interface IOpenIddictScopeManager { /// diff --git a/src/OpenIddict.Abstractions/Managers/IOpenIddictTokenManager.cs b/src/OpenIddict.Abstractions/Managers/IOpenIddictTokenManager.cs index 3773e702..4b035d93 100644 --- a/src/OpenIddict.Abstractions/Managers/IOpenIddictTokenManager.cs +++ b/src/OpenIddict.Abstractions/Managers/IOpenIddictTokenManager.cs @@ -12,12 +12,14 @@ namespace OpenIddict.Abstractions; /// /// Provides methods allowing to manage the tokens stored in the store. +/// +/// /// Note: this interface is not meant to be implemented by custom managers, /// that should inherit from the generic OpenIddictTokenManager class. /// It is primarily intended to be used by services that cannot easily /// depend on the generic token manager. The actual token entity type is /// automatically determined at runtime based on the OpenIddict core options. -/// +/// public interface IOpenIddictTokenManager { /// diff --git a/src/OpenIddict.Abstractions/OpenIddictConstants.cs b/src/OpenIddict.Abstractions/OpenIddictConstants.cs index d1abe08d..db113429 100644 --- a/src/OpenIddict.Abstractions/OpenIddictConstants.cs +++ b/src/OpenIddict.Abstractions/OpenIddictConstants.cs @@ -336,6 +336,20 @@ public static class OpenIddictConstants public const string UserInfoSigningAlgValuesSupported = "userinfo_signing_alg_values_supported"; } + public static class ObjectIdentifiers + { + public static class CertificateExtensions + { + public const string AuthorityInfoAccess = "1.3.6.1.5.5.7.1.1"; + public const string CrlDistributionPoints = "2.5.29.31"; + } + + public static class ExtendedKeyUsages + { + public const string ClientAuthentication = "1.3.6.1.5.5.7.3.2"; + } + } + public static class Parameters { public const string AccessToken = "access_token"; diff --git a/src/OpenIddict.Abstractions/OpenIddictResources.resx b/src/OpenIddict.Abstractions/OpenIddictResources.resx index bdab1a15..7089451b 100644 --- a/src/OpenIddict.Abstractions/OpenIddictResources.resx +++ b/src/OpenIddict.Abstractions/OpenIddictResources.resx @@ -1802,6 +1802,55 @@ Alternatively, any value respecting the '[region]-[subregion]-[identifier]' patt An unknown error occurred while trying to start a custom tabs intent. + + The X.509 TLS client certificate provided by the OWIN host is not an instance of type 'X509Certificate2'. + + + mTLS endpoint aliases must be absolute HTTPS URLs. + + + A static issuer must be explicitly set when configuring a mTLS endpoint alias. +To set a static issuer, use 'services.AddOpenIddict().AddServer().SetIssuer()'. + + + End certificates are not allowed in the client certificate chain base policies attached to the server options. +To attach an end certificate to a specific client, override the 'OpenIddictApplicationManager.GetClientCertificateChainPolicyAsync()' method. + + + Certificates are not allowed in the self-signed client certificate chain base policies attached to the server options. +To attach a self-signed certificate to a specific client, override the 'OpenIddictApplicationManager.GetSelfSignedClientCertificateChainPolicyAsync()' method. + + + Public Key Infrastructure-based client authentication cannot be used with self-signed certificates. + + + Self-signed client authentication can only be used with self-signed certificates. + + + A certificate chain policy must be configured when enabling the 'tls_client_auth' authentication method. +To configure a policy, use 'services.AddOpenIddict().AddServer().EnablePublicKeyInfrastructureClientCertificateAuthentication()'. + + + A self-signed certificate chain policy must be configured when enabling the 'self_signed_tls_client_auth' authentication method. +To configure a policy, use 'services.AddOpenIddict().AddServer().EnableSelfSignedClientCertificateAuthentication()'. + + + At least one certificate authority must be added to the certificate collection. + + + X.509 custom trust stores are not supported on this platform. +While not recommended, certificate-based client authentication can be manually implemented on unsupported platforms by setting 'OpenIddictServerOptions.ClientCertificateChainPolicy'/'OpenIddictServerOptions.SelfSignedClientCertificateChainPolicy' and overriding the the 'OpenIddictApplicationManager.ValidateClientCertificateAsync()'/'OpenIddictApplicationManager.ValidateSelfSignedClientCertificateAsync()' methods. + + + Changing the trust mode of the X.509 chain policy used for client authentication is not allowed by default for security reasons. +To use a custom policy relying on the system store, set 'OpenIddictServerOptions.ClientCertificateChainPolicy' or 'OpenIddictServerOptions.SelfSignedClientCertificateChainPolicy' manually. + + + mTLS endpoint aliases cannot be set when the corresponding endpoints have not been enabled. + + + Public Key Infrastructure certificates cannot contain private keys. + The security token is missing. @@ -2387,6 +2436,15 @@ Alternatively, any value respecting the '[region]-[subregion]-[identifier]' patt The '{0}' parameter is only allowed for OAuth 2.0 Token Exchange requests. + + Certificate-based authentication is not valid for this client application. + + + The specified TLS client certificate is invalid, expired or has been revoked. + + + Client authentication is required for this application. + The '{0}' parameter shouldn't be null or empty at this point. @@ -2447,6 +2505,9 @@ Alternatively, any value respecting the '[region]-[subregion]-[identifier]' patt The nonce shouldn't be null or empty at this point. + + The X.509 client certificate shouldn't be null at this point. + An error occurred while validating the token '{Token}'. @@ -3194,6 +3255,27 @@ This may indicate that the hashed entry is corrupted or malformed. The token request was rejected because the '{Parameter}' contained a URI fragment: {RedirectUri}. + + The authentication demand was rejected because the public application '{ClientId}' was not allowed to send a client certificate. + + + The authentication demand was rejected because the confidential application '{ClientId}' didn't specify a valid client certificate. + + + The authentication demand was rejected because the confidential application '{ClientId}' didn't specify a valid self-signed client certificate. + + + Certificate-based client authentication failed for {ClientId} because no redirection URI was associated with the application. + + + Certificate-based client authentication failed for {ClientId} because the certificate was not valid: {Errors}. + + + Certificate-based client authentication failed for {ClientId} because the certificate didn't match any of the hostnames extracted from the redirection URIs associated with the application. + + + An error occurred while trying to validate a client certificate, which may indicate that the certificate is malformed or has an invalid chain. + https://documentation.openiddict.com/errors/{0} diff --git a/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreConfiguration.cs b/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreConfiguration.cs index cf699322..ad850f42 100644 --- a/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreConfiguration.cs +++ b/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreConfiguration.cs @@ -125,7 +125,7 @@ public sealed class OpenIddictClientAspNetCoreConfiguration : IConfigureOptions< // on invalid endpoints. To opt out this undesirable behavior, a fake entry // is dynamically added if one of the default schemes properties is not set // and less than 2 handlers were registered in the authentication options. - if (options.SchemeMap.Count < 2 && string.IsNullOrEmpty(options.DefaultScheme) && + if (options.SchemeMap.Count is < 2 && string.IsNullOrEmpty(options.DefaultScheme) && (string.IsNullOrEmpty(options.DefaultAuthenticateScheme) || string.IsNullOrEmpty(options.DefaultSignInScheme) || string.IsNullOrEmpty(options.DefaultSignOutScheme))) diff --git a/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpConfiguration.cs b/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpConfiguration.cs index fb84d7da..c017dba1 100644 --- a/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpConfiguration.cs +++ b/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpConfiguration.cs @@ -7,7 +7,6 @@ using System.ComponentModel; using System.Net; using System.Net.Http; -using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Http; @@ -271,10 +270,12 @@ public sealed class OpenIddictClientSystemNetHttpConfiguration : IConfigureOptio { foreach (var credentials in registration.SigningCredentials) { + // Note: to avoid building and introspecting a X.509 certificate chain and reduce the cost + // of this check, a certificate is always assumed to be self-signed when it is self-issued. if (credentials.Key is X509SecurityKey { Certificate: X509Certificate2 certificate } && - certificate.Version is >= 3 && IsSelfIssuedCertificate(certificate) && - HasDigitalSignatureKeyUsage(certificate) && - HasClientAuthenticationExtendedKeyUsage(certificate)) + certificate.Version is >= 3 && OpenIddictHelpers.IsSelfIssuedCertificate(certificate) && + OpenIddictHelpers.HasKeyUsage(certificate, X509KeyUsageFlags.DigitalSignature) && + OpenIddictHelpers.HasExtendedKeyUsage(certificate, ObjectIdentifiers.ExtendedKeyUsages.ClientAuthentication)) { return certificate; } @@ -287,10 +288,12 @@ public sealed class OpenIddictClientSystemNetHttpConfiguration : IConfigureOptio { foreach (var credentials in registration.SigningCredentials) { + // Note: to avoid building and introspecting a X.509 certificate chain and reduce the cost + // of this check, a certificate is always assumed to be self-signed when it is self-issued. if (credentials.Key is X509SecurityKey { Certificate: X509Certificate2 certificate } && - certificate.Version is >= 3 && !IsSelfIssuedCertificate(certificate) && - HasDigitalSignatureKeyUsage(certificate) && - HasClientAuthenticationExtendedKeyUsage(certificate)) + certificate.Version is >= 3 && !OpenIddictHelpers.IsSelfIssuedCertificate(certificate) && + OpenIddictHelpers.HasKeyUsage(certificate, X509KeyUsageFlags.DigitalSignature) && + OpenIddictHelpers.HasExtendedKeyUsage(certificate, ObjectIdentifiers.ExtendedKeyUsages.ClientAuthentication)) { return certificate; } @@ -298,51 +301,5 @@ public sealed class OpenIddictClientSystemNetHttpConfiguration : IConfigureOptio return null; }; - - static bool HasClientAuthenticationExtendedKeyUsage(X509Certificate2 certificate) - { - for (var index = 0; index < certificate.Extensions.Count; index++) - { - if (certificate.Extensions[index] is X509EnhancedKeyUsageExtension extension && - HasOid(extension.EnhancedKeyUsages, "1.3.6.1.5.5.7.3.2")) - { - return true; - } - } - - return false; - - static bool HasOid(OidCollection collection, string value) - { - for (var index = 0; index < collection.Count; index++) - { - if (collection[index] is Oid oid && string.Equals(oid.Value, value, StringComparison.Ordinal)) - { - return true; - } - } - - return false; - } - } - - static bool HasDigitalSignatureKeyUsage(X509Certificate2 certificate) - { - for (var index = 0; index < certificate.Extensions.Count; index++) - { - if (certificate.Extensions[index] is X509KeyUsageExtension extension && - extension.KeyUsages.HasFlag(X509KeyUsageFlags.DigitalSignature)) - { - return true; - } - } - - return false; - } - - // Note: to avoid building and introspecting a X.509 certificate chain and reduce the cost - // of this check, a certificate is always assumed to be self-signed when it is self-issued. - static bool IsSelfIssuedCertificate(X509Certificate2 certificate) - => certificate.SubjectName.RawData.AsSpan().SequenceEqual(certificate.IssuerName.RawData); } } diff --git a/src/OpenIddict.Client/OpenIddictClientBuilder.cs b/src/OpenIddict.Client/OpenIddictClientBuilder.cs index 8f27ebbc..0eb9d157 100644 --- a/src/OpenIddict.Client/OpenIddictClientBuilder.cs +++ b/src/OpenIddict.Client/OpenIddictClientBuilder.cs @@ -135,8 +135,7 @@ public sealed class OpenIddictClientBuilder ArgumentNullException.ThrowIfNull(key); // If the encryption key is an asymmetric security key, ensure it has a private key. - if (key is AsymmetricSecurityKey asymmetricSecurityKey && - asymmetricSecurityKey.PrivateKeyStatus is PrivateKeyStatus.DoesNotExist) + if (key is AsymmetricSecurityKey { PrivateKeyStatus: PrivateKeyStatus.DoesNotExist }) { throw new InvalidOperationException(SR.GetResourceString(SR.ID0055)); } @@ -202,7 +201,7 @@ public sealed class OpenIddictClientBuilder // If no valid existing certificate was found, create a new encryption certificate. var certificates = store.Certificates .Find(X509FindType.FindBySubjectDistinguishedName, subject.Name, validOnly: false) - .OfType() + .Cast() .ToList(); if (!certificates.Exists(certificate => certificate.NotBefore < now.LocalDateTime && certificate.NotAfter > now.LocalDateTime)) @@ -318,7 +317,7 @@ public sealed class OpenIddictClientBuilder // If the certificate is a X.509v3 certificate that specifies at least one // key usage, ensure that the certificate key can be used for key encryption. - if (certificate.Version >= 3) + if (certificate.Version is >= 3) { var extensions = certificate.Extensions.OfType().ToList(); if (extensions.Count is not 0 && !extensions.Exists(static extension => @@ -437,7 +436,7 @@ public sealed class OpenIddictClientBuilder store.Open(OpenFlags.ReadOnly); return store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, validOnly: false) - .OfType() + .Cast() .SingleOrDefault(); } } @@ -458,7 +457,7 @@ public sealed class OpenIddictClientBuilder return AddEncryptionCertificate( store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, validOnly: false) - .OfType() + .Cast() .SingleOrDefault() ?? throw new InvalidOperationException(SR.GetResourceString(SR.ID0066))); } @@ -496,8 +495,7 @@ public sealed class OpenIddictClientBuilder ArgumentNullException.ThrowIfNull(key); // If the signing key is an asymmetric security key, ensure it has a private key. - if (key is AsymmetricSecurityKey asymmetricSecurityKey && - asymmetricSecurityKey.PrivateKeyStatus is PrivateKeyStatus.DoesNotExist) + if (key is AsymmetricSecurityKey { PrivateKeyStatus: PrivateKeyStatus.DoesNotExist }) { throw new InvalidOperationException(SR.GetResourceString(SR.ID0067)); } @@ -581,7 +579,7 @@ public sealed class OpenIddictClientBuilder // If no valid existing certificate was found, create a new signing certificate. var certificates = store.Certificates .Find(X509FindType.FindBySubjectDistinguishedName, subject.Name, validOnly: false) - .OfType() + .Cast() .ToList(); if (!certificates.Exists(certificate => certificate.NotBefore < now.LocalDateTime && certificate.NotAfter > now.LocalDateTime)) @@ -725,7 +723,7 @@ public sealed class OpenIddictClientBuilder // If the certificate is a X.509v3 certificate that specifies at least // one key usage, ensure that the certificate key can be used for signing. - if (certificate.Version >= 3) + if (certificate.Version is >= 3) { var extensions = certificate.Extensions.OfType().ToList(); if (extensions.Count is not 0 && !extensions.Exists(static extension => @@ -844,7 +842,7 @@ public sealed class OpenIddictClientBuilder store.Open(OpenFlags.ReadOnly); return store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, validOnly: false) - .OfType() + .Cast() .SingleOrDefault(); } } @@ -865,7 +863,7 @@ public sealed class OpenIddictClientBuilder return AddSigningCertificate( store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, validOnly: false) - .OfType() + .Cast() .SingleOrDefault() ?? throw new InvalidOperationException(SR.GetResourceString(SR.ID0066))); } diff --git a/src/OpenIddict.Client/OpenIddictClientHandlers.Discovery.cs b/src/OpenIddict.Client/OpenIddictClientHandlers.Discovery.cs index c9687c7d..96de6d31 100644 --- a/src/OpenIddict.Client/OpenIddictClientHandlers.Discovery.cs +++ b/src/OpenIddict.Client/OpenIddictClientHandlers.Discovery.cs @@ -568,7 +568,8 @@ public static partial class OpenIddictClientHandlers } /// - /// Contains the logic responsible for extracting the mTLS-enabled revocation endpoint URI from the discovery document. + /// Contains the logic responsible for extracting the mTLS-enabled + /// revocation endpoint URI from the discovery document. /// public sealed class ExtractMtlsRevocationEndpoint : IOpenIddictClientHandler { @@ -600,7 +601,8 @@ public static partial class OpenIddictClientHandlers } /// - /// Contains the logic responsible for extracting the mTLS-enabled token endpoint URI from the discovery document. + /// Contains the logic responsible for extracting the mTLS-enabled + /// token endpoint URI from the discovery document. /// public sealed class ExtractMtlsTokenEndpoint : IOpenIddictClientHandler { @@ -632,7 +634,8 @@ public static partial class OpenIddictClientHandlers } /// - /// Contains the logic responsible for extracting the mTLS-enabled userinfo endpoint URI from the discovery document. + /// Contains the logic responsible for extracting the mTLS-enabled + /// userinfo endpoint URI from the discovery document. /// public sealed class ExtractMtlsUserInfoEndpoint : IOpenIddictClientHandler { diff --git a/src/OpenIddict.Core/Managers/OpenIddictApplicationManager.cs b/src/OpenIddict.Core/Managers/OpenIddictApplicationManager.cs index bb529182..70641f43 100644 --- a/src/OpenIddict.Core/Managers/OpenIddictApplicationManager.cs +++ b/src/OpenIddict.Core/Managers/OpenIddictApplicationManager.cs @@ -11,6 +11,7 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Runtime.CompilerServices; using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; using System.Text; using System.Text.Json; using Microsoft.Extensions.Logging; @@ -18,7 +19,6 @@ using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using ValidationException = OpenIddict.Abstractions.OpenIddictExceptions.ValidationException; - #if !SUPPORTS_KEY_DERIVATION_WITH_SPECIFIED_HASH_ALGORITHM using Org.BouncyCastle.Crypto; using Org.BouncyCastle.Crypto.Digests; @@ -471,6 +471,25 @@ public class OpenIddictApplicationManager : IOpenIddictApplication return Store.GetAsync(query, state, cancellationToken); } + /// + /// Retrieves the client certificate chain policy enforced for this application. + /// + /// The application. + /// The base policy from which the returned instance will be derived. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the client certificate chain policy enforced for this application. + /// + public virtual ValueTask GetClientCertificateChainPolicyAsync( + TApplication application, X509ChainPolicy policy, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(application); + + // Always clone the X.509 chain policy to ensure the original instance is never mutated. + return new(policy.Clone()); + } + /// /// Retrieves the client identifier associated with an application. /// @@ -739,6 +758,48 @@ public class OpenIddictApplicationManager : IOpenIddictApplication return Store.GetRequirementsAsync(application, cancellationToken); } + /// + /// Retrieves the self-signed client certificate chain policy enforced for this application. + /// + /// The application. + /// The base policy from which the returned instance will be derived. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, whose + /// result returns the self-signed client certificate chain policy enforced for this application. + /// + public virtual async ValueTask GetSelfSignedClientCertificateChainPolicyAsync( + TApplication application, X509ChainPolicy policy, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(application); + + // Always clone the X.509 chain policy to ensure the original instance is never mutated. + policy = policy.Clone(); + + // If a JSON Web Key Set was associated to the client application, extract the signing keys containing + // a X.509 certificate suitable for signing and client authentication and attach them to the chain policy. + if (await GetJsonWebKeySetAsync(application, cancellationToken) is { Keys: [_, ..] keys }) + { + for (var index = 0; index < keys.Count; index++) + { + if (JsonWebKeyConverter.TryConvertToSecurityKey(keys[index], out SecurityKey key) && + key is X509SecurityKey { Certificate: X509Certificate2 certificate } && + certificate.Version is >= 3 && + OpenIddictHelpers.HasKeyUsage(certificate, X509KeyUsageFlags.DigitalSignature) && + OpenIddictHelpers.HasExtendedKeyUsage(certificate, ObjectIdentifiers.ExtendedKeyUsages.ClientAuthentication)) + { +#if SUPPORTS_X509_CHAIN_POLICY_CUSTOM_TRUST_STORE + policy.CustomTrustStore.Add(certificate); +#else + policy.ExtraStore.Add(certificate); +#endif + } + } + } + + return policy; + } + /// /// Retrieves the settings associated with an application. /// @@ -1226,6 +1287,114 @@ public class OpenIddictApplicationManager : IOpenIddictApplication } } + /// + /// Validates the client certificate associated with an application. + /// + /// The application. + /// The certificate that should be compared to the certificates associated with the application. + /// The chain policy used to validate the certificate. + /// The that can be used to abort the operation. + /// A that can be used to monitor the asynchronous operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns a boolean indicating whether the client certificate was valid. + /// + public virtual async ValueTask ValidateClientCertificateAsync( + TApplication application, X509Certificate2 certificate, + X509ChainPolicy policy, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(application); + ArgumentNullException.ThrowIfNull(certificate); + ArgumentNullException.ThrowIfNull(policy); + + // Important: the certificate and policy instances MUST NOT be mutated in this method. + + if (OpenIddictHelpers.IsSelfIssuedCertificate(certificate)) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0503), nameof(certificate)); + } + + // Note: using a policy relying on the default system trust store + // is strongly discouraged but deliberately not prevented here. + + if (await HasClientTypeAsync(application, ClientTypes.Public, cancellationToken)) + { + Logger.LogWarning(6159, SR.GetResourceString(SR.ID6159)); + + return false; + } + +#if SUPPORTS_X509_CHAIN_POLICY_CUSTOM_TRUST_STORE + var uris = await GetRedirectUrisAsync(application, cancellationToken); + if (uris.IsDefaultOrEmpty) + { + Logger.LogInformation(6285, SR.GetResourceString(SR.ID6285), await GetClientIdAsync(application, cancellationToken)); + + return false; + } + + using var chain = new X509Chain() + { + ChainPolicy = policy + }; + + try + { + // Ensure the specified certificate is valid based on the chain policy. + if (!chain.Build(certificate)) + { + Logger.LogInformation(6286, SR.GetResourceString(SR.ID6286), + await GetClientIdAsync(application, cancellationToken), + chain.ChainStatus.Select(static status => status.Status).ToArray()); + + return false; + } + + // Note: this method MUST NOT be used with self-signed certificates. While self-issued + // certificates are immediately rejected by this method, determining whether a certificate + // is actually self-signed can only be done after building and validating the chain. + if (chain.ChainElements.Count is not > 1) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0503), nameof(certificate)); + } + + // By default, OpenIddict requires that certificates issued by PKIs be valid for one of the domains + // used in redirect URIs. Implementations that need a different logic can override this method. + for (var index = 0; index < uris.Length; index++) + { + if (Uri.TryCreate(uris[index], UriKind.Absolute, out Uri? uri) && !OpenIddictHelpers.IsImplicitFileUri(uri) && + uri.HostNameType is UriHostNameType.Dns or UriHostNameType.IPv4 or UriHostNameType.IPv6 && + certificate.MatchesHostname(hostname: uri.IdnHost, allowWildcards: true, allowCommonName: true)) + { + return true; + } + } + + Logger.LogInformation(6287, SR.GetResourceString(SR.ID6287), await GetClientIdAsync(application, cancellationToken)); + + return false; + } + + catch (CryptographicException exception) when (!OpenIddictHelpers.IsFatal(exception)) + { + Logger.LogWarning(6288, exception, SR.GetResourceString(SR.ID6288)); + + return false; + } + + finally + { + // Dispose the certificates instantiated internally while building the chain. + for (var index = 0; index < chain.ChainElements.Count; index++) + { + chain.ChainElements[index].Certificate.Dispose(); + } + } +#else + throw new PlatformNotSupportedException(SR.GetResourceString(SR.ID0508)); +#endif + } + /// /// Validates the client_secret associated with an application. /// @@ -1399,6 +1568,92 @@ public class OpenIddictApplicationManager : IOpenIddictApplication return false; } + /// + /// Validates the self-signed client certificate associated with an application. + /// + /// The application. + /// The certificate that should be compared to the certificates associated with the application. + /// The chain policy used to validate the certificate. + /// The that can be used to abort the operation. + /// A that can be used to monitor the asynchronous operation. + /// + /// A that can be used to monitor the asynchronous operation, whose + /// result returns a boolean indicating whether the self-signed client certificate was valid. + /// + public virtual async ValueTask ValidateSelfSignedClientCertificateAsync( + TApplication application, X509Certificate2 certificate, + X509ChainPolicy policy, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(application); + ArgumentNullException.ThrowIfNull(certificate); + ArgumentNullException.ThrowIfNull(policy); + + // Important: the certificate and policy instances MUST NOT be mutated in this method. + + if (!OpenIddictHelpers.IsSelfIssuedCertificate(certificate)) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0504), nameof(certificate)); + } + + // Note: using a policy relying on the default system trust store + // is strongly discouraged but deliberately not prevented here. + + if (await HasClientTypeAsync(application, ClientTypes.Public, cancellationToken)) + { + Logger.LogWarning(6159, SR.GetResourceString(SR.ID6159)); + + return false; + } + +#if SUPPORTS_X509_CHAIN_POLICY_CUSTOM_TRUST_STORE + using var chain = new X509Chain() + { + ChainPolicy = policy + }; + + try + { + // Ensure the specified certificate is valid based on the chain policy. + if (!chain.Build(certificate)) + { + Logger.LogInformation(6286, SR.GetResourceString(SR.ID6286), + await GetClientIdAsync(application, cancellationToken), + chain.ChainStatus.Select(static status => status.Status).ToArray()); + + return false; + } + + // Note: this method MUST be used with self-signed certificates. While self-issued + // certificates are immediately rejected by this method, determining whether a certificate + // is actually self-signed can only be done after building and validating the chain. + if (chain.ChainElements is not [X509ChainElement]) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0504), nameof(certificate)); + } + + return true; + } + + catch (CryptographicException exception) when (!OpenIddictHelpers.IsFatal(exception)) + { + Logger.LogWarning(6288, exception, SR.GetResourceString(SR.ID6288)); + + return false; + } + + finally + { + // Dispose the certificates instantiated internally while building the chain. + for (var index = 0; index < chain.ChainElements.Count; index++) + { + chain.ChainElements[index].Certificate.Dispose(); + } + } +#else + throw new PlatformNotSupportedException(SR.GetResourceString(SR.ID0508)); +#endif + } + /// /// Obfuscates the specified client secret so it can be safely stored in a database. /// By default, this method returns a complex hashed representation computed using PBKDF2. @@ -1520,7 +1775,7 @@ public class OpenIddictApplicationManager : IOpenIddictApplication // Read the size of the salt and ensure it's more than 128 bits. var saltLength = (int) BinaryPrimitives.ReadUInt32BigEndian(payload.Slice(9, sizeof(uint))); - if (saltLength < 128 / 8) + if (saltLength is < 128 / 8) { return false; } @@ -1530,7 +1785,7 @@ public class OpenIddictApplicationManager : IOpenIddictApplication // Ensure the derived key length is more than 128 bits. var keyLength = payload.Length - 13 - salt.Length; - if (keyLength < 128 / 8) + if (keyLength is < 128 / 8) { return false; } @@ -1614,6 +1869,10 @@ public class OpenIddictApplicationManager : IOpenIddictApplication ValueTask IOpenIddictApplicationManager.GetAsync(Func, TState, IQueryable> query, TState state, CancellationToken cancellationToken) where TResult : default => GetAsync(query, state, cancellationToken); + /// + ValueTask IOpenIddictApplicationManager.GetClientCertificateChainPolicyAsync(object application, X509ChainPolicy policy, CancellationToken cancellationToken) + => GetClientCertificateChainPolicyAsync((TApplication) application, policy, cancellationToken); + /// ValueTask IOpenIddictApplicationManager.GetClientIdAsync(object application, CancellationToken cancellationToken) => GetClientIdAsync((TApplication) application, cancellationToken); @@ -1670,6 +1929,10 @@ public class OpenIddictApplicationManager : IOpenIddictApplication ValueTask> IOpenIddictApplicationManager.GetRequirementsAsync(object application, CancellationToken cancellationToken) => GetRequirementsAsync((TApplication) application, cancellationToken); + /// + ValueTask IOpenIddictApplicationManager.GetSelfSignedClientCertificateChainPolicyAsync(object application, X509ChainPolicy policy, CancellationToken cancellationToken) + => GetSelfSignedClientCertificateChainPolicyAsync((TApplication) application, policy, cancellationToken); + /// ValueTask> IOpenIddictApplicationManager.GetSettingsAsync(object application, CancellationToken cancellationToken) => GetSettingsAsync((TApplication) application, cancellationToken); @@ -1730,6 +1993,10 @@ public class OpenIddictApplicationManager : IOpenIddictApplication IAsyncEnumerable IOpenIddictApplicationManager.ValidateAsync(object application, CancellationToken cancellationToken) => ValidateAsync((TApplication) application, cancellationToken); + /// + ValueTask IOpenIddictApplicationManager.ValidateClientCertificateAsync(object application, X509Certificate2 certificate, X509ChainPolicy policy, CancellationToken cancellationToken) + => ValidateClientCertificateAsync((TApplication) application, certificate, policy, cancellationToken); + /// ValueTask IOpenIddictApplicationManager.ValidateClientSecretAsync(object application, string secret, CancellationToken cancellationToken) => ValidateClientSecretAsync((TApplication) application, secret, cancellationToken); @@ -1741,4 +2008,8 @@ public class OpenIddictApplicationManager : IOpenIddictApplication /// ValueTask IOpenIddictApplicationManager.ValidateRedirectUriAsync(object application, [StringSyntax(StringSyntaxAttribute.Uri)] string uri, CancellationToken cancellationToken) => ValidateRedirectUriAsync((TApplication) application, uri, cancellationToken); + + /// + ValueTask IOpenIddictApplicationManager.ValidateSelfSignedClientCertificateAsync(object application, X509Certificate2 certificate, X509ChainPolicy policy, CancellationToken cancellationToken) + => ValidateSelfSignedClientCertificateAsync((TApplication) application, certificate, policy, cancellationToken); } diff --git a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreConfiguration.cs b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreConfiguration.cs index 58052a70..309d1749 100644 --- a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreConfiguration.cs +++ b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreConfiguration.cs @@ -16,7 +16,8 @@ namespace OpenIddict.Server.AspNetCore; public sealed class OpenIddictServerAspNetCoreConfiguration : IConfigureOptions, IConfigureOptions, IPostConfigureOptions, - IPostConfigureOptions + IPostConfigureOptions, + IPostConfigureOptions { /// public void Configure(AuthenticationOptions options) @@ -71,7 +72,7 @@ public sealed class OpenIddictServerAspNetCoreConfiguration : IConfigureOptions< // on invalid endpoints. To opt out this undesirable behavior, a fake entry // is dynamically added if one of the default schemes properties is not set // and less than 2 handlers were registered in the authentication options. - if (options.SchemeMap.Count < 2 && string.IsNullOrEmpty(options.DefaultScheme) && + if (options.SchemeMap.Count is < 2 && string.IsNullOrEmpty(options.DefaultScheme) && (string.IsNullOrEmpty(options.DefaultAuthenticateScheme) || string.IsNullOrEmpty(options.DefaultChallengeScheme) || string.IsNullOrEmpty(options.DefaultForbidScheme) || @@ -104,4 +105,22 @@ public sealed class OpenIddictServerAspNetCoreConfiguration : IConfigureOptions< throw new InvalidOperationException(SR.GetResourceString(SR.ID0110)); } } + + /// + public void PostConfigure(string? name, OpenIddictServerOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + // Enable tls_client_auth and self_signed_tls_client_auth support if the + // corresponding chain policies have been configured in the server options. + if (options.ClientCertificateChainPolicy is not null) + { + options.ClientAuthenticationMethods.Add(ClientAuthenticationMethods.TlsClientAuth); + } + + if (options.SelfSignedClientCertificateChainPolicy is not null) + { + options.ClientAuthenticationMethods.Add(ClientAuthenticationMethods.SelfSignedTlsClientAuth); + } + } } diff --git a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreExtensions.cs b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreExtensions.cs index ea6cebb6..3caea366 100644 --- a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreExtensions.cs +++ b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreExtensions.cs @@ -45,7 +45,7 @@ public static class OpenIddictServerAspNetCoreExtensions builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); - // Register the option initializer used by the OpenIddict ASP.NET Core server integration services. + // Register the option initializers used by the OpenIddict ASP.NET Core server integration services. // Note: TryAddEnumerable() is used here to ensure the initializers are only registered once. builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton< IConfigureOptions, OpenIddictServerAspNetCoreConfiguration>()); @@ -59,6 +59,9 @@ public static class OpenIddictServerAspNetCoreExtensions builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton< IPostConfigureOptions, OpenIddictServerAspNetCoreConfiguration>()); + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton< + IPostConfigureOptions, OpenIddictServerAspNetCoreConfiguration>()); + return new OpenIddictServerAspNetCoreBuilder(builder.Services); } diff --git a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Authentication.cs b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Authentication.cs index f7d75027..11376e4b 100644 --- a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Authentication.cs +++ b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Authentication.cs @@ -48,6 +48,7 @@ public static partial class OpenIddictServerAspNetCoreHandlers */ ExtractPostRequest.Descriptor, ValidateClientAuthenticationMethod.Descriptor, + ExtractClientAuthenticationCertificate.Descriptor, ExtractBasicAuthenticationCredentials.Descriptor, /* diff --git a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Device.cs b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Device.cs index 2f27242f..f1187caf 100644 --- a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Device.cs +++ b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Device.cs @@ -21,6 +21,7 @@ public static partial class OpenIddictServerAspNetCoreHandlers */ ExtractPostRequest.Descriptor, ValidateClientAuthenticationMethod.Descriptor, + ExtractClientAuthenticationCertificate.Descriptor, ExtractBasicAuthenticationCredentials.Descriptor, /* diff --git a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Exchange.cs b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Exchange.cs index e76c2972..5b1c5d83 100644 --- a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Exchange.cs +++ b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Exchange.cs @@ -19,6 +19,7 @@ public static partial class OpenIddictServerAspNetCoreHandlers */ ExtractPostRequest.Descriptor, ValidateClientAuthenticationMethod.Descriptor, + ExtractClientAuthenticationCertificate.Descriptor, ExtractBasicAuthenticationCredentials.Descriptor, /* diff --git a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Introspection.cs b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Introspection.cs index 5ac7e188..6f7331c4 100644 --- a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Introspection.cs +++ b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Introspection.cs @@ -19,6 +19,7 @@ public static partial class OpenIddictServerAspNetCoreHandlers */ ExtractGetOrPostRequest.Descriptor, ValidateClientAuthenticationMethod.Descriptor, + ExtractClientAuthenticationCertificate.Descriptor, ExtractBasicAuthenticationCredentials.Descriptor, /* diff --git a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Revocation.cs b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Revocation.cs index 77368682..11dec150 100644 --- a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Revocation.cs +++ b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Revocation.cs @@ -19,6 +19,7 @@ public static partial class OpenIddictServerAspNetCoreHandlers */ ExtractPostRequest.Descriptor, ValidateClientAuthenticationMethod.Descriptor, + ExtractClientAuthenticationCertificate.Descriptor, ExtractBasicAuthenticationCredentials.Descriptor, /* diff --git a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.cs b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.cs index ece0221a..0a354278 100644 --- a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.cs +++ b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.cs @@ -8,6 +8,7 @@ using System.Collections.Immutable; using System.ComponentModel; using System.Diagnostics; using System.Runtime.InteropServices; +using System.Security.Cryptography.X509Certificates; using System.Text; using System.Text.Encodings.Web; using System.Text.Json; @@ -626,7 +627,7 @@ public static partial class OpenIddictServerAspNetCoreHandlers .Build(); /// - public ValueTask HandleAsync(TContext context) + public async ValueTask HandleAsync(TContext context) { ArgumentNullException.ThrowIfNull(context); @@ -648,13 +649,10 @@ public static partial class OpenIddictServerAspNetCoreHandlers description: SR.FormatID2174(ClientAuthenticationMethods.ClientSecretPost), uri: SR.FormatID8000(SR.ID2174)); - return ValueTask.CompletedTask; + return; } // Reject requests that use client_secret_basic if support was explicitly disabled in the options. - // - // Note: the client_secret_jwt authentication method is not supported by OpenIddict out-of-the-box but - // is specified here to account for custom implementations that explicitly add client_secret_jwt support. string? header = request.Headers[HeaderNames.Authorization]; if (!string.IsNullOrEmpty(header) && header.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase) && !context.Options.ClientAuthenticationMethods.Contains(ClientAuthenticationMethods.ClientSecretBasic)) @@ -666,10 +664,82 @@ public static partial class OpenIddictServerAspNetCoreHandlers description: SR.FormatID2174(ClientAuthenticationMethods.ClientSecretBasic), uri: SR.FormatID8000(SR.ID2174)); - return ValueTask.CompletedTask; + return; } - return ValueTask.CompletedTask; + // If the request was sent using HTTPS, reject requests that use mTLS-based client authentication + // (self_signed_tls_client_auth or tls_client_auth) if support was not enabled in the server options. + if (request.IsHttps && await request.HttpContext.Connection.GetClientCertificateAsync( + request.HttpContext.RequestAborted) is X509Certificate2 certificate) + { + // Note: to avoid building and introspecting a X.509 certificate chain and reduce the cost + // of this check, a certificate is always assumed to be self-signed when it is self-issued. + if (OpenIddictHelpers.IsSelfIssuedCertificate(certificate)) + { + if (!context.Options.ClientAuthenticationMethods.Contains(ClientAuthenticationMethods.SelfSignedTlsClientAuth)) + { + context.Logger.LogInformation(6227, SR.GetResourceString(SR.ID6227), ClientAuthenticationMethods.SelfSignedTlsClientAuth); + + context.Reject( + error: Errors.InvalidClient, + description: SR.FormatID2174(ClientAuthenticationMethods.SelfSignedTlsClientAuth), + uri: SR.FormatID8000(SR.ID2174)); + + return; + } + } + + else if (!context.Options.ClientAuthenticationMethods.Contains(ClientAuthenticationMethods.TlsClientAuth)) + { + context.Logger.LogInformation(6227, SR.GetResourceString(SR.ID6227), ClientAuthenticationMethods.TlsClientAuth); + + context.Reject( + error: Errors.InvalidClient, + description: SR.FormatID2174(ClientAuthenticationMethods.TlsClientAuth), + uri: SR.FormatID8000(SR.ID2174)); + + return; + } + } + } + } + + /// + /// Contains the logic responsible for extracting a client authentication certificate from the request context. + /// Note: this handler is not used when the OpenID Connect request is not initially handled by ASP.NET Core. + /// + public sealed class ExtractClientAuthenticationCertificate : IOpenIddictServerHandler + where TContext : BaseValidatingContext + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler>() + .SetOrder(ValidateClientAuthenticationMethod.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public async ValueTask HandleAsync(TContext context) + { + ArgumentNullException.ThrowIfNull(context); + + Debug.Assert(context.Transaction.Request is not null, SR.GetResourceString(SR.ID4008)); + + // This handler only applies to ASP.NET Core requests. If the HTTP context cannot be resolved, + // this may indicate that the request was incorrectly processed by another server stack. + var request = context.Transaction.GetHttpRequest() ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0114)); + + // If a client certificate was used during the TLS handshake, attach it to the context. + if (request.IsHttps && await request.HttpContext.Connection.GetClientCertificateAsync( + request.HttpContext.RequestAborted) is X509Certificate2 certificate) + { + context.Transaction.ClientCertificate = certificate; + } } } @@ -687,7 +757,7 @@ public static partial class OpenIddictServerAspNetCoreHandlers = OpenIddictServerHandlerDescriptor.CreateBuilder() .AddFilter() .UseSingletonHandler>() - .SetOrder(ValidateClientAuthenticationMethod.Descriptor.Order + 1_000) + .SetOrder(ExtractClientAuthenticationCertificate.Descriptor.Order + 1_000) .SetType(OpenIddictServerHandlerType.BuiltIn) .Build(); @@ -730,7 +800,7 @@ public static partial class OpenIddictServerAspNetCoreHandlers var data = Encoding.ASCII.GetString(Convert.FromBase64String(value)); var index = data.IndexOf(':'); - if (index < 0) + if (index is < 0) { context.Reject( error: Errors.InvalidRequest, diff --git a/src/OpenIddict.Server.Owin/OpenIddictServerOwinConfiguration.cs b/src/OpenIddict.Server.Owin/OpenIddictServerOwinConfiguration.cs index ae00aa30..cd2ddd65 100644 --- a/src/OpenIddict.Server.Owin/OpenIddictServerOwinConfiguration.cs +++ b/src/OpenIddict.Server.Owin/OpenIddictServerOwinConfiguration.cs @@ -14,6 +14,7 @@ namespace OpenIddict.Server.Owin; /// [EditorBrowsable(EditorBrowsableState.Advanced)] public sealed class OpenIddictServerOwinConfiguration : IConfigureOptions, + IPostConfigureOptions, IPostConfigureOptions { /// @@ -28,6 +29,24 @@ public sealed class OpenIddictServerOwinConfiguration : IConfigureOptions + public void PostConfigure(string? name, OpenIddictServerOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + // Enable tls_client_auth and self_signed_tls_client_auth support if the + // corresponding chain policies have been configured in the server options. + if (options.ClientCertificateChainPolicy is not null) + { + options.ClientAuthenticationMethods.Add(ClientAuthenticationMethods.TlsClientAuth); + } + + if (options.SelfSignedClientCertificateChainPolicy is not null) + { + options.ClientAuthenticationMethods.Add(ClientAuthenticationMethods.SelfSignedTlsClientAuth); + } + } + /// public void PostConfigure(string? name, OpenIddictServerOwinOptions options) { diff --git a/src/OpenIddict.Server.Owin/OpenIddictServerOwinExtensions.cs b/src/OpenIddict.Server.Owin/OpenIddictServerOwinExtensions.cs index 72fa200b..9088b27b 100644 --- a/src/OpenIddict.Server.Owin/OpenIddictServerOwinExtensions.cs +++ b/src/OpenIddict.Server.Owin/OpenIddictServerOwinExtensions.cs @@ -55,6 +55,9 @@ public static class OpenIddictServerOwinExtensions builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton< IPostConfigureOptions, OpenIddictServerOwinConfiguration>()); + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton< + IPostConfigureOptions, OpenIddictServerOwinConfiguration>()); + return new OpenIddictServerOwinBuilder(builder.Services); } diff --git a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Authentication.cs b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Authentication.cs index 0ce2d6ac..7f34049d 100644 --- a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Authentication.cs +++ b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Authentication.cs @@ -48,6 +48,7 @@ public static partial class OpenIddictServerOwinHandlers */ ExtractPostRequest.Descriptor, ValidateClientAuthenticationMethod.Descriptor, + ExtractClientAuthenticationCertificate.Descriptor, ExtractBasicAuthenticationCredentials.Descriptor, /* diff --git a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Device.cs b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Device.cs index a4b99066..08f434ab 100644 --- a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Device.cs +++ b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Device.cs @@ -21,6 +21,7 @@ public static partial class OpenIddictServerOwinHandlers */ ExtractPostRequest.Descriptor, ValidateClientAuthenticationMethod.Descriptor, + ExtractClientAuthenticationCertificate.Descriptor, ExtractBasicAuthenticationCredentials.Descriptor, /* diff --git a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Exchange.cs b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Exchange.cs index 86968326..6e388389 100644 --- a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Exchange.cs +++ b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Exchange.cs @@ -19,6 +19,7 @@ public static partial class OpenIddictServerOwinHandlers */ ExtractPostRequest.Descriptor, ValidateClientAuthenticationMethod.Descriptor, + ExtractClientAuthenticationCertificate.Descriptor, ExtractBasicAuthenticationCredentials.Descriptor, /* diff --git a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Introspection.cs b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Introspection.cs index 58c449be..af3064e4 100644 --- a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Introspection.cs +++ b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Introspection.cs @@ -19,6 +19,7 @@ public static partial class OpenIddictServerOwinHandlers */ ExtractGetOrPostRequest.Descriptor, ValidateClientAuthenticationMethod.Descriptor, + ExtractClientAuthenticationCertificate.Descriptor, ExtractBasicAuthenticationCredentials.Descriptor, /* diff --git a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Revocation.cs b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Revocation.cs index fcb34cc4..f0ef99f5 100644 --- a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Revocation.cs +++ b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Revocation.cs @@ -19,6 +19,7 @@ public static partial class OpenIddictServerOwinHandlers */ ExtractPostRequest.Descriptor, ValidateClientAuthenticationMethod.Descriptor, + ExtractClientAuthenticationCertificate.Descriptor, ExtractBasicAuthenticationCredentials.Descriptor, /* diff --git a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.cs b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.cs index ea560cc8..04092b9b 100644 --- a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.cs +++ b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.cs @@ -8,6 +8,7 @@ using System.Collections.Immutable; using System.ComponentModel; using System.Diagnostics; using System.Globalization; +using System.Security.Cryptography.X509Certificates; using System.Text; using System.Text.Encodings.Web; using System.Text.Json; @@ -688,7 +689,7 @@ public static partial class OpenIddictServerOwinHandlers .Build(); /// - public ValueTask HandleAsync(TContext context) + public async ValueTask HandleAsync(TContext context) { ArgumentNullException.ThrowIfNull(context); @@ -710,7 +711,7 @@ public static partial class OpenIddictServerOwinHandlers description: SR.FormatID2174(ClientAuthenticationMethods.ClientSecretPost), uri: SR.FormatID8000(SR.ID2174)); - return ValueTask.CompletedTask; + return; } // Reject requests that use client_secret_basic if support was explicitly disabled in the options. @@ -725,10 +726,118 @@ public static partial class OpenIddictServerOwinHandlers description: SR.FormatID2174(ClientAuthenticationMethods.ClientSecretBasic), uri: SR.FormatID8000(SR.ID2174)); - return ValueTask.CompletedTask; + return; } - return ValueTask.CompletedTask; + // If the request was sent using HTTPS, reject requests that use mTLS-based client authentication + // (self_signed_tls_client_auth or tls_client_auth) if support was not enabled in the server options. + if (request.IsSecure && await GetClientCertificateAsync(request.Context) is X509Certificate2 certificate) + { + // Note: to avoid building and introspecting a X.509 certificate chain and reduce the cost + // of this check, a certificate is always assumed to be self-signed when it is self-issued. + if (OpenIddictHelpers.IsSelfIssuedCertificate(certificate)) + { + if (!context.Options.ClientAuthenticationMethods.Contains(ClientAuthenticationMethods.SelfSignedTlsClientAuth)) + { + context.Logger.LogInformation(6227, SR.GetResourceString(SR.ID6227), ClientAuthenticationMethods.SelfSignedTlsClientAuth); + + context.Reject( + error: Errors.InvalidClient, + description: SR.FormatID2174(ClientAuthenticationMethods.SelfSignedTlsClientAuth), + uri: SR.FormatID8000(SR.ID2174)); + + return; + } + } + + else if (!context.Options.ClientAuthenticationMethods.Contains(ClientAuthenticationMethods.TlsClientAuth)) + { + context.Logger.LogInformation(6227, SR.GetResourceString(SR.ID6227), ClientAuthenticationMethods.TlsClientAuth); + + context.Reject( + error: Errors.InvalidClient, + description: SR.FormatID2174(ClientAuthenticationMethods.TlsClientAuth), + uri: SR.FormatID8000(SR.ID2174)); + + return; + } + } + + static async ValueTask GetClientCertificateAsync(IOwinContext context) + { + // If a loading function was provided by the OWIN host, always invoke it before trying + // to resolve the certificate to ensure it is present in the environment dictionary. + if (context.Get>("ssl.LoadClientCertAsync") is Func loader) + { + await loader(); + } + + if (context.Get("ssl.ClientCertificateErrors") is not null) + { + return null; + } + + return context.Get("ssl.ClientCertificate") is X509Certificate certificate + ? certificate as X509Certificate2 ?? throw new InvalidOperationException(SR.GetResourceString(SR.ID0498)) + : null; + } + } + } + + /// + /// Contains the logic responsible for extracting a client authentication certificate from the request context. + /// Note: this handler is not used when the OpenID Connect request is not initially handled by OWIN. + /// + public sealed class ExtractClientAuthenticationCertificate : IOpenIddictServerHandler + where TContext : BaseValidatingContext + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler>() + .SetOrder(ValidateClientAuthenticationMethod.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public async ValueTask HandleAsync(TContext context) + { + ArgumentNullException.ThrowIfNull(context); + + Debug.Assert(context.Transaction.Request is not null, SR.GetResourceString(SR.ID4008)); + + // This handler only applies to OWIN requests. If The OWIN request cannot be resolved, + // this may indicate that the request was incorrectly processed by another server stack. + var request = context.Transaction.GetOwinRequest() ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0120)); + + // If a client certificate was used during the TLS handshake, attach it to the context. + if (request.IsSecure && await GetClientCertificateAsync(request.Context) is X509Certificate2 certificate) + { + context.Transaction.ClientCertificate = certificate; + } + + static async ValueTask GetClientCertificateAsync(IOwinContext context) + { + // If a loading function was provided by the OWIN host, always invoke it before trying + // to resolve the certificate to ensure it is present in the environment dictionary. + if (context.Get>("ssl.LoadClientCertAsync") is Func loader) + { + await loader(); + } + + if (context.Get("ssl.ClientCertificateErrors") is not null) + { + return null; + } + + return context.Get("ssl.ClientCertificate") is X509Certificate certificate + ? certificate as X509Certificate2 ?? throw new InvalidOperationException(SR.GetResourceString(SR.ID0498)) + : null; + } } } @@ -746,7 +855,7 @@ public static partial class OpenIddictServerOwinHandlers = OpenIddictServerHandlerDescriptor.CreateBuilder() .AddFilter() .UseSingletonHandler>() - .SetOrder(ValidateClientAuthenticationMethod.Descriptor.Order + 1_000) + .SetOrder(ExtractClientAuthenticationCertificate.Descriptor.Order + 1_000) .SetType(OpenIddictServerHandlerType.BuiltIn) .Build(); @@ -789,7 +898,7 @@ public static partial class OpenIddictServerOwinHandlers var data = Encoding.ASCII.GetString(Convert.FromBase64String(value)); var index = data.IndexOf(':'); - if (index < 0) + if (index is < 0) { context.Reject( error: Errors.InvalidRequest, diff --git a/src/OpenIddict.Server/OpenIddictServerBuilder.cs b/src/OpenIddict.Server/OpenIddictServerBuilder.cs index f0ec51fd..3a6e19fb 100644 --- a/src/OpenIddict.Server/OpenIddictServerBuilder.cs +++ b/src/OpenIddict.Server/OpenIddictServerBuilder.cs @@ -4,6 +4,7 @@ * the license and the contributors participating to this project. */ +using System; using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Globalization; @@ -11,6 +12,7 @@ using System.Reflection; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using OpenIddict.Server; @@ -144,8 +146,7 @@ public sealed class OpenIddictServerBuilder ArgumentNullException.ThrowIfNull(key); // If the encryption key is an asymmetric security key, ensure it has a private key. - if (key is AsymmetricSecurityKey asymmetricSecurityKey && - asymmetricSecurityKey.PrivateKeyStatus is PrivateKeyStatus.DoesNotExist) + if (key is AsymmetricSecurityKey { PrivateKeyStatus: PrivateKeyStatus.DoesNotExist }) { throw new InvalidOperationException(SR.GetResourceString(SR.ID0055)); } @@ -211,7 +212,7 @@ public sealed class OpenIddictServerBuilder // If no valid existing certificate was found, create a new encryption certificate. var certificates = store.Certificates .Find(X509FindType.FindBySubjectDistinguishedName, subject.Name, validOnly: false) - .OfType() + .Cast() .ToList(); if (!certificates.Exists(certificate => certificate.NotBefore < now.LocalDateTime && certificate.NotAfter > now.LocalDateTime)) @@ -327,7 +328,7 @@ public sealed class OpenIddictServerBuilder // If the certificate is a X.509v3 certificate that specifies at least one // key usage, ensure that the certificate key can be used for key encryption. - if (certificate.Version >= 3) + if (certificate.Version is >= 3) { var extensions = certificate.Extensions.OfType().ToList(); if (extensions.Count is not 0 && !extensions.Exists(static extension => @@ -446,7 +447,7 @@ public sealed class OpenIddictServerBuilder store.Open(OpenFlags.ReadOnly); return store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, validOnly: false) - .OfType() + .Cast() .SingleOrDefault(); } } @@ -467,7 +468,7 @@ public sealed class OpenIddictServerBuilder return AddEncryptionCertificate( store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, validOnly: false) - .OfType() + .Cast() .SingleOrDefault() ?? throw new InvalidOperationException(SR.GetResourceString(SR.ID0066))); } @@ -505,8 +506,7 @@ public sealed class OpenIddictServerBuilder ArgumentNullException.ThrowIfNull(key); // If the signing key is an asymmetric security key, ensure it has a private key. - if (key is AsymmetricSecurityKey asymmetricSecurityKey && - asymmetricSecurityKey.PrivateKeyStatus is PrivateKeyStatus.DoesNotExist) + if (key is AsymmetricSecurityKey { PrivateKeyStatus: PrivateKeyStatus.DoesNotExist }) { throw new InvalidOperationException(SR.GetResourceString(SR.ID0067)); } @@ -590,7 +590,7 @@ public sealed class OpenIddictServerBuilder // If no valid existing certificate was found, create a new signing certificate. var certificates = store.Certificates .Find(X509FindType.FindBySubjectDistinguishedName, subject.Name, validOnly: false) - .OfType() + .Cast() .ToList(); if (!certificates.Exists(certificate => @@ -735,7 +735,7 @@ public sealed class OpenIddictServerBuilder // If the certificate is a X.509v3 certificate that specifies at least // one key usage, ensure that the certificate key can be used for signing. - if (certificate.Version >= 3) + if (certificate.Version is >= 3) { var extensions = certificate.Extensions.OfType().ToList(); if (extensions.Count is not 0 && !extensions.Exists(static extension => @@ -854,7 +854,7 @@ public sealed class OpenIddictServerBuilder store.Open(OpenFlags.ReadOnly); return store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, validOnly: false) - .OfType() + .Cast() .SingleOrDefault(); } } @@ -875,7 +875,7 @@ public sealed class OpenIddictServerBuilder return AddSigningCertificate( store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, validOnly: false) - .OfType() + .Cast() .SingleOrDefault() ?? throw new InvalidOperationException(SR.GetResourceString(SR.ID0066))); } @@ -1893,7 +1893,7 @@ public sealed class OpenIddictServerBuilder /// Sets the issuer URI, which is used as the value of the "issuer" claim and /// is returned from the discovery endpoint to identify the authorization server. /// - /// The issuer uri. + /// The issuer URI. /// The instance. public OpenIddictServerBuilder SetIssuer(Uri uri) { @@ -1906,7 +1906,7 @@ public sealed class OpenIddictServerBuilder /// Sets the issuer URI, which is used as the value of the "issuer" claim and /// is returned from the discovery endpoint to identify the authorization server. /// - /// The issuer uri. + /// The issuer URI. /// The instance. public OpenIddictServerBuilder SetIssuer( [StringSyntax(StringSyntaxAttribute.Uri, UriKind.Absolute)] string uri) @@ -1921,6 +1921,256 @@ public sealed class OpenIddictServerBuilder return SetIssuer(value); } + /// + /// Sets the URI listed as the mTLS device authorization + /// endpoint alias in the server configuration metadata. + /// + /// + /// Note: this URI MUST be absolute and MUST point to a domain for + /// which TLS client authentication is enforced by the web server. + /// + /// The endpoint URI. + /// The instance. + public OpenIddictServerBuilder SetMtlsDeviceAuthorizationEndpointAliasUri(Uri uri) + { + ArgumentNullException.ThrowIfNull(uri); + + if (OpenIddictHelpers.IsImplicitFileUri(uri)) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0072), nameof(uri)); + } + + if (uri.OriginalString.StartsWith("~", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException(SR.FormatID0081("~"), nameof(uri)); + } + + return Configure(options => options.MtlsDeviceAuthorizationEndpointAliasUri = uri); + } + + /// + /// Sets the URI listed as the mTLS device authorization + /// endpoint alias in the server configuration metadata. + /// + /// + /// Note: this URI MUST be absolute and MUST point to a domain for + /// which TLS client authentication is enforced by the web server. + /// + /// The endpoint URI. + /// The instance. + public OpenIddictServerBuilder SetMtlsDeviceAuthorizationEndpointAliasUri( + [StringSyntax(StringSyntaxAttribute.Uri, UriKind.Absolute)] string uri) + { + ArgumentException.ThrowIfNullOrEmpty(uri); + + if (!Uri.TryCreate(uri, UriKind.Absolute, out Uri? value) || OpenIddictHelpers.IsImplicitFileUri(value)) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0072), nameof(uri)); + } + + return SetMtlsDeviceAuthorizationEndpointAliasUri(value); + } + + /// + /// Sets the URI listed as the mTLS introspection + /// endpoint alias in the server configuration metadata. + /// + /// + /// Note: this URI MUST be absolute and MUST point to a domain for + /// which TLS client authentication is enforced by the web server. + /// + /// The endpoint URI. + /// The instance. + public OpenIddictServerBuilder SetMtlsIntrospectionEndpointAliasUri(Uri uri) + { + ArgumentNullException.ThrowIfNull(uri); + + if (OpenIddictHelpers.IsImplicitFileUri(uri)) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0072), nameof(uri)); + } + + if (uri.OriginalString.StartsWith("~", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException(SR.FormatID0081("~"), nameof(uri)); + } + + return Configure(options => options.MtlsIntrospectionEndpointAliasUri = uri); + } + + /// + /// Sets the URI listed as the mTLS introspection endpoint + /// alias in the server configuration metadata. + /// + /// + /// Note: this URI MUST be absolute and MUST point to a domain for + /// which TLS client authentication is enforced by the web server. + /// + /// The endpoint URI. + /// The instance. + public OpenIddictServerBuilder SetMtlsIntrospectionEndpointAliasUri( + [StringSyntax(StringSyntaxAttribute.Uri, UriKind.Absolute)] string uri) + { + ArgumentException.ThrowIfNullOrEmpty(uri); + + if (!Uri.TryCreate(uri, UriKind.Absolute, out Uri? value) || OpenIddictHelpers.IsImplicitFileUri(value)) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0072), nameof(uri)); + } + + return SetMtlsIntrospectionEndpointAliasUri(value); + } + + /// + /// Sets the URI listed as the mTLS pushed authorization + /// endpoint alias in the server configuration metadata. + /// + /// + /// Note: this URI MUST be absolute and MUST point to a domain for + /// which TLS client authentication is enforced by the web server. + /// + /// The endpoint URI. + /// The instance. + public OpenIddictServerBuilder SetMtlsPushedAuthorizationEndpointAliasUri(Uri uri) + { + ArgumentNullException.ThrowIfNull(uri); + + if (OpenIddictHelpers.IsImplicitFileUri(uri)) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0072), nameof(uri)); + } + + if (uri.OriginalString.StartsWith("~", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException(SR.FormatID0081("~"), nameof(uri)); + } + + return Configure(options => options.MtlsPushedAuthorizationEndpointAliasUri = uri); + } + + /// + /// Sets the URI listed as the mTLS pushed authorization + /// endpoint alias in the server configuration metadata. + /// + /// + /// Note: this URI MUST be absolute and MUST point to a domain for + /// which TLS client authentication is enforced by the web server. + /// + /// The endpoint URI. + /// The instance. + public OpenIddictServerBuilder SetMtlsPushedAuthorizationEndpointAliasUri( + [StringSyntax(StringSyntaxAttribute.Uri, UriKind.Absolute)] string uri) + { + ArgumentException.ThrowIfNullOrEmpty(uri); + + if (!Uri.TryCreate(uri, UriKind.Absolute, out Uri? value) || OpenIddictHelpers.IsImplicitFileUri(value)) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0072), nameof(uri)); + } + + return SetMtlsPushedAuthorizationEndpointAliasUri(value); + } + + /// + /// Sets the URI listed as the mTLS revocation endpoint + /// alias in the server configuration metadata. + /// + /// + /// Note: this URI MUST be absolute and MUST point to a domain for + /// which TLS client authentication is enforced by the web server. + /// + /// The endpoint URI. + /// The instance. + public OpenIddictServerBuilder SetMtlsRevocationEndpointAliasUri(Uri uri) + { + ArgumentNullException.ThrowIfNull(uri); + + if (OpenIddictHelpers.IsImplicitFileUri(uri)) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0072), nameof(uri)); + } + + if (uri.OriginalString.StartsWith("~", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException(SR.FormatID0081("~"), nameof(uri)); + } + + return Configure(options => options.MtlsRevocationEndpointAliasUri = uri); + } + + /// + /// Sets the URI listed as the mTLS revocation endpoint + /// alias in the server configuration metadata. + /// + /// + /// Note: this URI MUST be absolute and MUST point to a domain for + /// which TLS client authentication is enforced by the web server. + /// + /// The endpoint URI. + /// The instance. + public OpenIddictServerBuilder SetMtlsRevocationEndpointAliasUri( + [StringSyntax(StringSyntaxAttribute.Uri, UriKind.Absolute)] string uri) + { + ArgumentException.ThrowIfNullOrEmpty(uri); + + if (!Uri.TryCreate(uri, UriKind.Absolute, out Uri? value) || OpenIddictHelpers.IsImplicitFileUri(value)) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0072), nameof(uri)); + } + + return SetMtlsRevocationEndpointAliasUri(value); + } + + /// + /// Sets the URI listed as the mTLS token endpoint + /// alias in the server configuration metadata. + /// + /// + /// Note: this URI MUST be absolute and MUST point to a domain for + /// which TLS client authentication is enforced by the web server. + /// + /// The endpoint URI. + /// The instance. + public OpenIddictServerBuilder SetMtlsTokenEndpointAliasUri(Uri uri) + { + ArgumentNullException.ThrowIfNull(uri); + + if (OpenIddictHelpers.IsImplicitFileUri(uri)) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0072), nameof(uri)); + } + + if (uri.OriginalString.StartsWith("~", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException(SR.FormatID0081("~"), nameof(uri)); + } + + return Configure(options => options.MtlsTokenEndpointAliasUri = uri); + } + + /// + /// Sets the URI listed as the mTLS token endpoint + /// alias in the server configuration metadata. + /// + /// + /// Note: this URI MUST be absolute and MUST point to a domain for + /// which TLS client authentication is enforced by the web server. + /// + /// The endpoint URI. + /// The instance. + public OpenIddictServerBuilder SetMtlsTokenEndpointAliasUri( + [StringSyntax(StringSyntaxAttribute.Uri, UriKind.Absolute)] string uri) + { + ArgumentException.ThrowIfNullOrEmpty(uri); + + if (!Uri.TryCreate(uri, UriKind.Absolute, out Uri? value) || OpenIddictHelpers.IsImplicitFileUri(value)) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0072), nameof(uri)); + } + + return SetMtlsTokenEndpointAliasUri(value); + } + /// /// Configures OpenIddict to use reference tokens, so that the access token payloads /// are stored in the database (only an identifier is returned to the client application). @@ -1962,6 +2212,171 @@ public sealed class OpenIddictServerBuilder public OpenIddictServerBuilder EnableEndSessionRequestCaching() => Configure(options => options.EnableEndSessionRequestCaching = true); + /// + /// Configures OpenIddict to enable PKI client certificate authentication (mTLS) and trust + /// the specified root and intermediate certificates when validating client certificates. + /// + /// The store containing the root and intermediate certificates to trust. + /// The instance. + [EditorBrowsable(EditorBrowsableState.Advanced)] + public OpenIddictServerBuilder EnablePublicKeyInfrastructureClientCertificateAuthentication(X509Certificate2Collection certificates) + => EnablePublicKeyInfrastructureClientCertificateAuthentication(certificates, static policy => { }); + + /// + /// Configures OpenIddict to enable PKI client certificate authentication (mTLS) and trust + /// the specified root and intermediate certificates when validating client certificates. + /// + /// The store containing the root and intermediate certificates to trust. + /// The delegate used to amend the created X.509 chain policy. + /// The instance. + [EditorBrowsable(EditorBrowsableState.Advanced)] + public OpenIddictServerBuilder EnablePublicKeyInfrastructureClientCertificateAuthentication( + X509Certificate2Collection certificates, Action configuration) + { + ArgumentNullException.ThrowIfNull(certificates); + ArgumentNullException.ThrowIfNull(configuration); + +#if SUPPORTS_X509_CHAIN_POLICY_CUSTOM_TRUST_STORE + // Ensure at least one root certificate authority was included in the certificate collection. + if (!certificates.Cast().Any(static certificate => + OpenIddictHelpers.IsCertificateAuthority(certificate) && + OpenIddictHelpers.HasKeyUsage(certificate, X509KeyUsageFlags.KeyCertSign) && + OpenIddictHelpers.IsSelfIssuedCertificate(certificate))) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0507), nameof(certificates)); + } + + // Ensure no end certificate was included in the certificate collection. + if (certificates.Cast().Any(static certificate => + !OpenIddictHelpers.IsCertificateAuthority(certificate) || + !OpenIddictHelpers.HasKeyUsage(certificate, X509KeyUsageFlags.KeyCertSign))) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0501), nameof(certificates)); + } + + // Ensure none of the certificates contains a private key. + if (certificates.Cast().Any(static certificate => certificate.HasPrivateKey)) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0511), nameof(certificates)); + } + + var policy = new X509ChainPolicy + { + // Note: by default, OpenIddict requires that end certificates used for authentication + // explicitly list client authentication as an allowed extended key usage. + ApplicationPolicy = { new Oid(ObjectIdentifiers.ExtendedKeyUsages.ClientAuthentication) }, + TrustMode = X509ChainTrustMode.CustomRootTrust + }; + + policy.CustomTrustStore.AddRange(certificates); + + // If one of the root certificates doesn't include a CRL or AIA + // extension, ignore root revocation unknown status errors by default. + if (certificates.Cast() + .Where(static certificate => + OpenIddictHelpers.IsCertificateAuthority(certificate) && + OpenIddictHelpers.HasKeyUsage(certificate, X509KeyUsageFlags.KeyCertSign) && + OpenIddictHelpers.IsSelfIssuedCertificate(certificate)) + .Any(static certificate => + certificate.Extensions[ObjectIdentifiers.CertificateExtensions.CrlDistributionPoints] is null && + certificate.Extensions[ObjectIdentifiers.CertificateExtensions.AuthorityInfoAccess] is null)) + { + policy.VerificationFlags |= X509VerificationFlags.IgnoreRootRevocationUnknown; + } + + // If one of the intermediate certificates doesn't include a CRL or AIA + // extension, ignore root revocation unknown status errors by default. + if (certificates.Cast() + .Where(static certificate => + OpenIddictHelpers.IsCertificateAuthority(certificate) && + OpenIddictHelpers.HasKeyUsage(certificate, X509KeyUsageFlags.KeyCertSign) && + !OpenIddictHelpers.IsSelfIssuedCertificate(certificate)) + .Any(static certificate => + certificate.Extensions[ObjectIdentifiers.CertificateExtensions.CrlDistributionPoints] is null && + certificate.Extensions[ObjectIdentifiers.CertificateExtensions.AuthorityInfoAccess] is null)) + { + policy.VerificationFlags |= X509VerificationFlags.IgnoreCertificateAuthorityRevocationUnknown; + } + + // If the root or intermediate certificates doesn't include a CRL or AIA, assume + // by default that the end certificates won't have a CRL or AIA extension either. + if (certificates.Cast() + .Where(static certificate => + OpenIddictHelpers.IsCertificateAuthority(certificate) && + OpenIddictHelpers.HasKeyUsage(certificate, X509KeyUsageFlags.KeyCertSign)) + .Any(static certificate => + certificate.Extensions[ObjectIdentifiers.CertificateExtensions.CrlDistributionPoints] is null && + certificate.Extensions[ObjectIdentifiers.CertificateExtensions.AuthorityInfoAccess] is null)) + { + policy.VerificationFlags |= X509VerificationFlags.IgnoreEndRevocationUnknown; + } + + // Run the user-provided configuration delegate and ensure the trust mode wasn't accidentally changed to + // prevent spoofing attacks (i.e attacks that consist in using a client certificate issued by a certificate + // authority trusted by the operating system but that isn't the one expected by the authorization server + // for client authentication). While discouraged, applications that need to use the system root store + // (e.g applications running on .NET Framework) can manually attach a custom policy to the server options. + configuration(policy); + + if (policy.TrustMode is not X509ChainTrustMode.CustomRootTrust) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0509)); + } + + return Configure(options => options.ClientCertificateChainPolicy = policy); +#else + throw new PlatformNotSupportedException(SR.GetResourceString(SR.ID0508)); +#endif + } + + /// + /// Configures OpenIddict to enable self-signed client certificate authentication (mTLS). + /// + /// The instance. + [EditorBrowsable(EditorBrowsableState.Advanced)] + public OpenIddictServerBuilder EnableSelfSignedClientCertificateAuthentication() + => EnableSelfSignedClientCertificateAuthentication(static policy => { }); + + /// + /// Configures OpenIddict to enable self-signed client certificate authentication (mTLS). + /// + /// The delegate used to amend the created X.509 chain policy. + /// The instance. + [EditorBrowsable(EditorBrowsableState.Advanced)] + public OpenIddictServerBuilder EnableSelfSignedClientCertificateAuthentication(Action configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + +#if SUPPORTS_X509_CHAIN_POLICY_CUSTOM_TRUST_STORE + var policy = new X509ChainPolicy + { + // Note: by default, OpenIddict requires that end certificates used for authentication + // explicitly list client authentication as an allowed extended key usage. + ApplicationPolicy = { new Oid(ObjectIdentifiers.ExtendedKeyUsages.ClientAuthentication) }, + // Note: self-signed certificates used for client authentication typically never include revocation + // information (CRL or AIA) and are "revoked" by simply being removed from the JSON Web Key Set. + RevocationMode = X509RevocationMode.NoCheck, + TrustMode = X509ChainTrustMode.CustomRootTrust + }; + + // Run the user-provided configuration delegate and ensure the trust mode wasn't accidentally changed to + // prevent spoofing attacks (i.e attacks that consist in using a client certificate issued by a certificate + // authority trusted by the operating system but that isn't the one expected by the authorization server + // for client authentication). While discouraged, applications that need to use the system root store + // (e.g applications running on .NET Framework) can manually attach a custom policy to the server options. + configuration(policy); + + if (policy.TrustMode is not X509ChainTrustMode.CustomRootTrust) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0509)); + } + + return Configure(options => options.SelfSignedClientCertificateChainPolicy = policy); +#else + throw new PlatformNotSupportedException(SR.GetResourceString(SR.ID0508)); +#endif + } + /// [EditorBrowsable(EditorBrowsableState.Never)] public override bool Equals(object? obj) => base.Equals(obj); diff --git a/src/OpenIddict.Server/OpenIddictServerConfiguration.cs b/src/OpenIddict.Server/OpenIddictServerConfiguration.cs index 1bcc9e4e..58b504f4 100644 --- a/src/OpenIddict.Server/OpenIddictServerConfiguration.cs +++ b/src/OpenIddict.Server/OpenIddictServerConfiguration.cs @@ -7,6 +7,7 @@ using System.ComponentModel; using System.Diagnostics; using System.Globalization; +using System.Security.Cryptography.X509Certificates; using System.Text; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -164,6 +165,19 @@ public sealed class OpenIddictServerConfiguration : IPostConfigureOptions credentials.Key is X509SecurityKey x509SecurityKey && - (x509SecurityKey.Certificate.NotBefore > now || x509SecurityKey.Certificate.NotAfter < now))) + if (options.EncryptionCredentials.TrueForAll(credentials => + credentials.Key is X509SecurityKey { Certificate: X509Certificate2 certificate } && + (certificate.NotBefore > now || certificate.NotAfter < now))) { throw new InvalidOperationException(SR.GetResourceString(SR.ID0087)); } // If all the registered signing credentials are backed by a X.509 certificate, at least one of them must be valid. - if (options.SigningCredentials.TrueForAll(credentials => credentials.Key is X509SecurityKey x509SecurityKey && - (x509SecurityKey.Certificate.NotBefore > now || x509SecurityKey.Certificate.NotAfter < now))) + if (options.SigningCredentials.TrueForAll(credentials => + credentials.Key is X509SecurityKey { Certificate: X509Certificate2 certificate } && + (certificate.NotBefore > now || certificate.NotAfter < now))) { throw new InvalidOperationException(SR.GetResourceString(SR.ID0088)); } + // When set, the mTLS endpoint aliases MUST represent absolute HTTPS URLs. + if (!TryValidateMtlsEndpointAlias(options.MtlsDeviceAuthorizationEndpointAliasUri) || + !TryValidateMtlsEndpointAlias(options.MtlsIntrospectionEndpointAliasUri) || + !TryValidateMtlsEndpointAlias(options.MtlsPushedAuthorizationEndpointAliasUri) || + !TryValidateMtlsEndpointAlias(options.MtlsRevocationEndpointAliasUri) || + !TryValidateMtlsEndpointAlias(options.MtlsTokenEndpointAliasUri)) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0499)); + } + + // Prevent the mTLS aliases from being configured if the corresponding endpoints haven't been enabled. + if ((options.MtlsDeviceAuthorizationEndpointAliasUri is not null && options.DeviceAuthorizationEndpointUris.Count is 0) || + (options.MtlsIntrospectionEndpointAliasUri is not null && options.IntrospectionEndpointUris.Count is 0) || + (options.MtlsPushedAuthorizationEndpointAliasUri is not null && options.PushedAuthorizationEndpointUris.Count is 0) || + (options.MtlsRevocationEndpointAliasUri is not null && options.RevocationEndpointUris.Count is 0) || + (options.MtlsTokenEndpointAliasUri is not null && options.TokenEndpointUris.Count is 0)) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0510)); + } + + // If at least one mTLS endpoint alias was configured, require that the issuer be explicitly set + // to ensure it is not dynamically computed based on the current URI, as this would result in two + // different issuers being used (one pointing to the mTLS domain and one pointing to the regular one). + if (options.Issuer is null && (options.MtlsDeviceAuthorizationEndpointAliasUri is not null || + options.MtlsIntrospectionEndpointAliasUri is not null || + options.MtlsPushedAuthorizationEndpointAliasUri is not null || + options.MtlsRevocationEndpointAliasUri is not null || + options.MtlsTokenEndpointAliasUri is not null)) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0500)); + } + + // Ensure no end certificate was included in the PKI client certificate + // chain policy and that none of the certificates contains a private key. + if (options.ClientCertificateChainPolicy is not null) + { + if (options.ClientCertificateChainPolicy.ExtraStore.Cast() + .Any(static certificate => + !OpenIddictHelpers.IsCertificateAuthority(certificate) || + !OpenIddictHelpers.HasKeyUsage(certificate, X509KeyUsageFlags.KeyCertSign))) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0501)); + } + + if (options.ClientCertificateChainPolicy.ExtraStore.Cast() + .Any(static certificate => certificate.HasPrivateKey)) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0511)); + } + +#if SUPPORTS_X509_CHAIN_POLICY_CUSTOM_TRUST_STORE && SUPPORTS_X509_CHAIN_POLICY_TRUST_MODE + if (options.ClientCertificateChainPolicy.CustomTrustStore.Cast() + .Any(static certificate => + !OpenIddictHelpers.IsCertificateAuthority(certificate) || + !OpenIddictHelpers.HasKeyUsage(certificate, X509KeyUsageFlags.KeyCertSign))) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0501)); + } + + if (options.ClientCertificateChainPolicy.CustomTrustStore.Cast() + .Any(static certificate => certificate.HasPrivateKey)) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0511)); + } +#endif + } + + // Ensure the self-signed client certificate chain policy doesn't contain any certificate. + if (options.SelfSignedClientCertificateChainPolicy is not null) + { + if (options.SelfSignedClientCertificateChainPolicy.ExtraStore.Cast().Any()) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0502)); + } + +#if SUPPORTS_X509_CHAIN_POLICY_CUSTOM_TRUST_STORE && SUPPORTS_X509_CHAIN_POLICY_TRUST_MODE + if (options.SelfSignedClientCertificateChainPolicy.CustomTrustStore.Cast().Any()) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0502)); + } +#endif + } + if (options.EnableDegradedMode) { // If the degraded mode was enabled, ensure custom validation handlers @@ -529,5 +628,8 @@ public sealed class OpenIddictServerConfiguration : IPostConfigureOptions uri is null || + (uri.IsAbsoluteUri && string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)); } } diff --git a/src/OpenIddict.Server/OpenIddictServerEvents.Discovery.cs b/src/OpenIddict.Server/OpenIddictServerEvents.Discovery.cs index 50ac2b82..0222a8b0 100644 --- a/src/OpenIddict.Server/OpenIddictServerEvents.Discovery.cs +++ b/src/OpenIddict.Server/OpenIddictServerEvents.Discovery.cs @@ -136,6 +136,31 @@ public static partial class OpenIddictServerEvents /// public Uri? UserInfoEndpoint { get; set; } + /// + /// Gets or sets the mTLS device authorization endpoint alias URI. + /// + public Uri? MtlsDeviceAuthorizationEndpointAlias { get; set; } + + /// + /// Gets or sets the mTLS-specific introspection endpoint alias URI. + /// + public Uri? MtlsIntrospectionEndpointAlias { get; set; } + + /// + /// Gets or sets the mTLS pushed authorization endpoint alias URI. + /// + public Uri? MtlsPushedAuthorizationEndpointAlias { get; set; } + + /// + /// Gets or sets the mTLS revocation endpoint alias URI. + /// + public Uri? MtlsRevocationEndpointAlias { get; set; } + + /// + /// Gets or sets the mTLS token endpoint alias URI. + /// + public Uri? MtlsTokenEndpointAlias { get; set; } + /// /// Gets the list of claims supported by the authorization server. /// diff --git a/src/OpenIddict.Server/OpenIddictServerEvents.cs b/src/OpenIddict.Server/OpenIddictServerEvents.cs index 6346072b..c0cdd265 100644 --- a/src/OpenIddict.Server/OpenIddictServerEvents.cs +++ b/src/OpenIddict.Server/OpenIddictServerEvents.cs @@ -6,6 +6,7 @@ using System.ComponentModel; using System.Security.Claims; +using System.Security.Cryptography.X509Certificates; using Microsoft.Extensions.Logging; namespace OpenIddict.Server; @@ -764,6 +765,15 @@ public static partial class OpenIddictServerEvents /// public ClaimsPrincipal? ClientAssertionPrincipal { get; set; } + /// + /// Gets or sets the client certificate (typically obtained via mTLS), if applicable. + /// + public X509Certificate2? ClientCertificate + { + get => Transaction.ClientCertificate; + set => Transaction.ClientCertificate = value; + } + /// /// Gets or sets the device code to validate, if applicable. /// diff --git a/src/OpenIddict.Server/OpenIddictServerExtensions.cs b/src/OpenIddict.Server/OpenIddictServerExtensions.cs index 55449f73..f6be36be 100644 --- a/src/OpenIddict.Server/OpenIddictServerExtensions.cs +++ b/src/OpenIddict.Server/OpenIddictServerExtensions.cs @@ -49,6 +49,7 @@ public static class OpenIddictServerExtensions builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); diff --git a/src/OpenIddict.Server/OpenIddictServerHandlerFilters.cs b/src/OpenIddict.Server/OpenIddictServerHandlerFilters.cs index d2f091f9..18e31440 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlerFilters.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlerFilters.cs @@ -179,6 +179,20 @@ public static class OpenIddictServerHandlerFilters } } + /// + /// Represents a filter that excludes the associated handlers if no client authentication certificate is available. + /// + public sealed class RequireClientCertificate : IOpenIddictServerHandlerFilter + { + /// + public ValueTask IsActiveAsync(ProcessAuthenticationContext context) + { + ArgumentNullException.ThrowIfNull(context); + + return new(context.ClientCertificate is not null); + } + } + /// /// Represents a filter that excludes the associated handlers when no client identifier is received. /// diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Discovery.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Discovery.cs index 2b572c28..c99467b4 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Discovery.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Discovery.cs @@ -8,6 +8,7 @@ using System.Collections.Immutable; using System.Diagnostics; using System.Security.Cryptography; using System.Text.Json; +using System.Text.Json.Nodes; using Microsoft.Extensions.Logging; using Microsoft.IdentityModel.Tokens; @@ -235,6 +236,7 @@ public static partial class OpenIddictServerHandlers [Metadata.UserInfoEndpoint] = notification.UserInfoEndpoint?.AbsoluteUri, [Metadata.DeviceAuthorizationEndpoint] = notification.DeviceAuthorizationEndpoint?.AbsoluteUri, [Metadata.PushedAuthorizationRequestEndpoint] = notification.PushedAuthorizationEndpoint?.AbsoluteUri, + [Metadata.MtlsEndpointAliases] = CreateMtlsEndpointAliases(notification), [Metadata.JwksUri] = notification.JsonWebKeySetEndpoint?.AbsoluteUri, [Metadata.GrantTypesSupported] = notification.GrantTypes.ToImmutableArray(), [Metadata.ResponseTypesSupported] = notification.ResponseTypes.ToImmutableArray(), @@ -259,6 +261,38 @@ public static partial class OpenIddictServerHandlers } context.Transaction.Response = response; + + static JsonObject CreateMtlsEndpointAliases(HandleConfigurationRequestContext context) + { + var node = new JsonObject(); + + if (context.MtlsDeviceAuthorizationEndpointAlias is not null) + { + node.Add(Metadata.DeviceAuthorizationEndpoint, context.MtlsDeviceAuthorizationEndpointAlias.AbsoluteUri); + } + + if (context.MtlsIntrospectionEndpointAlias is not null) + { + node.Add(Metadata.IntrospectionEndpoint, context.MtlsIntrospectionEndpointAlias.AbsoluteUri); + } + + if (context.MtlsPushedAuthorizationEndpointAlias is not null) + { + node.Add(Metadata.PushedAuthorizationRequestEndpoint, context.MtlsPushedAuthorizationEndpointAlias.AbsoluteUri); + } + + if (context.MtlsRevocationEndpointAlias is not null) + { + node.Add(Metadata.RevocationEndpoint, context.MtlsRevocationEndpointAlias.AbsoluteUri); + } + + if (context.MtlsTokenEndpointAlias is not null) + { + node.Add(Metadata.TokenEndpoint, context.MtlsTokenEndpointAlias.AbsoluteUri); + } + + return node; + } } } @@ -377,6 +411,21 @@ public static partial class OpenIddictServerHandlers context.JsonWebKeySetEndpoint ??= OpenIddictHelpers.CreateAbsoluteUri( context.BaseUri, context.Options.JsonWebKeySetEndpointUris.FirstOrDefault()); + context.MtlsDeviceAuthorizationEndpointAlias ??= OpenIddictHelpers.CreateAbsoluteUri( + context.BaseUri, context.Options.MtlsDeviceAuthorizationEndpointAliasUri); + + context.MtlsIntrospectionEndpointAlias ??= OpenIddictHelpers.CreateAbsoluteUri( + context.BaseUri, context.Options.MtlsIntrospectionEndpointAliasUri); + + context.MtlsPushedAuthorizationEndpointAlias ??= OpenIddictHelpers.CreateAbsoluteUri( + context.BaseUri, context.Options.MtlsPushedAuthorizationEndpointAliasUri); + + context.MtlsRevocationEndpointAlias ??= OpenIddictHelpers.CreateAbsoluteUri( + context.BaseUri, context.Options.MtlsRevocationEndpointAliasUri); + + context.MtlsTokenEndpointAlias ??= OpenIddictHelpers.CreateAbsoluteUri( + context.BaseUri, context.Options.MtlsTokenEndpointAliasUri); + context.PushedAuthorizationEndpoint ??= OpenIddictHelpers.CreateAbsoluteUri( context.BaseUri, context.Options.PushedAuthorizationEndpointUris.FirstOrDefault()); diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Protection.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Protection.cs index 494428dc..351ac1fc 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Protection.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Protection.cs @@ -627,7 +627,7 @@ public static partial class OpenIddictServerHandlers // To achieve that, all the "scope" claims are combined into a single one containg all the values. // Visit https://datatracker.ietf.org/doc/html/rfc9068 for more information. var scopes = context.Principal.GetClaims(Claims.Scope); - if (scopes.Length > 1) + if (scopes.Length is > 1) { context.Principal.SetClaim(Claims.Scope, string.Join(" ", scopes)); } diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.cs index 58ffa3af..47c81773 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.cs @@ -8,7 +8,9 @@ using System.Collections.Immutable; using System.ComponentModel; using System.Diagnostics; using System.Globalization; +using System.Runtime.InteropServices; using System.Security.Claims; +using System.Security.Cryptography.X509Certificates; using System.Text; using System.Text.Json; using Microsoft.Extensions.DependencyInjection; @@ -36,13 +38,14 @@ public static partial class OpenIddictServerHandlers EvaluateValidatedTokens.Descriptor, ResolveValidatedTokens.Descriptor, ValidateRequiredTokens.Descriptor, - ValidateClientId.Descriptor, - ValidateClientType.Descriptor, - ValidateClientSecret.Descriptor, ValidateClientAssertion.Descriptor, ValidateClientAssertionWellknownClaims.Descriptor, ValidateClientAssertionIssuer.Descriptor, ValidateClientAssertionAudience.Descriptor, + ValidateClientId.Descriptor, + ValidateClientType.Descriptor, + ValidateClientSecret.Descriptor, + ValidateClientCertificate.Descriptor, ValidateRequestToken.Descriptor, ValidateRequestTokenType.Descriptor, ValidateAccessToken.Descriptor, @@ -1116,6 +1119,19 @@ public static partial class OpenIddictServerHandlers return; } + // Reject requests containing a TLS client certificate when the client is a public application. + if (context.ClientCertificate is not null) + { + context.Logger.LogInformation(6282, SR.GetResourceString(SR.ID6282), context.ClientId); + + context.Reject( + error: Errors.InvalidClient, + description: SR.GetResourceString(SR.ID2196), + uri: SR.FormatID8000(SR.ID2196)); + + return; + } + // Reject requests containing a client_assertion when the client is a public application. if (!string.IsNullOrEmpty(context.ClientAssertion)) { @@ -1145,15 +1161,16 @@ public static partial class OpenIddictServerHandlers return; } - // Confidential and hybrid applications MUST authenticate to protect them from impersonation attacks. - if (context.ClientAssertionPrincipal is null && string.IsNullOrEmpty(context.ClientSecret)) + // Confidential applications MUST authenticate to protect them from impersonation attacks. + if (context.ClientAssertionPrincipal is null && + context.ClientCertificate is null && string.IsNullOrEmpty(context.ClientSecret)) { context.Logger.LogInformation(6224, SR.GetResourceString(SR.ID6224), context.ClientId); context.Reject( error: Errors.InvalidClient, - description: SR.FormatID2054(Parameters.ClientSecret), - uri: SR.FormatID8000(SR.ID2054)); + description: SR.GetResourceString(SR.ID2198), + uri: SR.FormatID8000(SR.ID2198)); return; } @@ -1226,6 +1243,107 @@ public static partial class OpenIddictServerHandlers } } + /// + /// Contains the logic responsible for validating the TLS client certificate used for client authentication, if applicable. + /// + public sealed class ValidateClientCertificate : IOpenIddictServerHandler + { + private readonly IOpenIddictApplicationManager _applicationManager; + + public ValidateClientCertificate() => throw new InvalidOperationException(SR.GetResourceString(SR.ID0016)); + + public ValidateClientCertificate(IOpenIddictApplicationManager applicationManager) + => _applicationManager = applicationManager ?? throw new ArgumentNullException(nameof(applicationManager)); + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .AddFilter() + .AddFilter() + .AddFilter() + .UseScopedHandler() + .SetOrder(ValidateClientSecret.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public async ValueTask HandleAsync(ProcessAuthenticationContext context) + { + ArgumentNullException.ThrowIfNull(context); + + Debug.Assert(!string.IsNullOrEmpty(context.ClientId), SR.FormatID4000(Parameters.ClientId)); + Debug.Assert(context.ClientCertificate is not null, SR.GetResourceString(SR.ID4020)); + + // Don't validate the client secret on endpoints that don't support client authentication. + if (context.EndpointType is OpenIddictServerEndpointType.Authorization or + OpenIddictServerEndpointType.EndSession or + OpenIddictServerEndpointType.EndUserVerification or + OpenIddictServerEndpointType.UserInfo) + { + return; + } + + var application = await _applicationManager.FindByClientIdAsync(context.ClientId) ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0032)); + + // If the application is a public client, don't validate the client certificate. + if (await _applicationManager.HasClientTypeAsync(application, ClientTypes.Public)) + { + return; + } + + // Note: to avoid building and introspecting a X.509 certificate chain and reduce the cost + // of this check, a certificate is always assumed to be self-signed when it is self-issued. + // + // A second pass is internally performed by the default implementations of the + // ValidateSelfSignedClientCertificateAsync() and ValidateClientCertificateAsync() APIs + // once the chain is built to validate whether the certificate is self-signed or not. + if (OpenIddictHelpers.IsSelfIssuedCertificate(context.ClientCertificate)) + { + if (context.Options.SelfSignedClientCertificateChainPolicy is null) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0506)); + } + + var policy = await _applicationManager.GetSelfSignedClientCertificateChainPolicyAsync(application, context.Options.SelfSignedClientCertificateChainPolicy); + if (policy is null || !await _applicationManager.ValidateSelfSignedClientCertificateAsync(application, context.ClientCertificate, policy)) + { + context.Logger.LogInformation(6283, SR.GetResourceString(SR.ID6283), context.ClientId); + + context.Reject( + error: Errors.InvalidClient, + description: SR.GetResourceString(SR.ID2197), + uri: SR.FormatID8000(SR.ID2197)); + + return; + } + } + + else + { + if (context.Options.ClientCertificateChainPolicy is null) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0505)); + } + + var policy = await _applicationManager.GetClientCertificateChainPolicyAsync(application, context.Options.ClientCertificateChainPolicy); + if (policy is null || !await _applicationManager.ValidateClientCertificateAsync(application, context.ClientCertificate, policy)) + { + context.Logger.LogInformation(6284, SR.GetResourceString(SR.ID6284), context.ClientId); + + context.Reject( + error: Errors.InvalidClient, + description: SR.GetResourceString(SR.ID2197), + uri: SR.FormatID8000(SR.ID2197)); + + return; + } + } + } + } + /// /// Contains the logic responsible for validating the request token resolved from the context. /// @@ -1243,7 +1361,7 @@ public static partial class OpenIddictServerHandlers = OpenIddictServerHandlerDescriptor.CreateBuilder() .AddFilter() .UseScopedHandler() - .SetOrder(ValidateClientSecret.Descriptor.Order + 1_000) + .SetOrder(ValidateClientCertificate.Descriptor.Order + 1_000) .SetType(OpenIddictServerHandlerType.BuiltIn) .Build(); diff --git a/src/OpenIddict.Server/OpenIddictServerOptions.cs b/src/OpenIddict.Server/OpenIddictServerOptions.cs index 58f9aaad..768319e5 100644 --- a/src/OpenIddict.Server/OpenIddictServerOptions.cs +++ b/src/OpenIddict.Server/OpenIddictServerOptions.cs @@ -5,6 +5,7 @@ */ using System.ComponentModel; +using System.Security.Cryptography.X509Certificates; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; @@ -127,6 +128,56 @@ public sealed class OpenIddictServerOptions SetDefaultTimesOnTokenCreation = false }; + /// + /// Gets or sets the URI listed as the mTLS device authorization + /// endpoint alias in the server configuration metadata. + /// + /// + /// Note: this URI MUST be absolute and MUST point to a domain for + /// which TLS client authentication is enforced by the web server. + /// + public Uri? MtlsDeviceAuthorizationEndpointAliasUri { get; set; } + + /// + /// Gets or sets the URI listed as the mTLS introspection + /// endpoint alias in the server configuration metadata. + /// + /// + /// Note: this URI MUST be absolute and MUST point to a domain for + /// which TLS client authentication is enforced by the web server. + /// + public Uri? MtlsIntrospectionEndpointAliasUri { get; set; } + + /// + /// Gets or sets the URI listed as the mTLS pushed authorization + /// endpoint alias in the server configuration metadata. + /// + /// + /// Note: this URI MUST be absolute and MUST point to a domain for + /// which TLS client authentication is enforced by the web server. + /// + public Uri? MtlsPushedAuthorizationEndpointAliasUri { get; set; } + + /// + /// Gets or sets the URI listed as the mTLS revocation + /// endpoint alias in the server configuration metadata. + /// + /// + /// Note: this URI MUST be absolute and MUST point to a domain for + /// which TLS client authentication is enforced by the web server. + /// + public Uri? MtlsRevocationEndpointAliasUri { get; set; } + + /// + /// Gets or sets the URI listed as the mTLS token + /// endpoint alias in the server configuration metadata. + /// + /// + /// Note: this URI MUST be absolute and MUST point to a domain for + /// which TLS client authentication is enforced by the web server. + /// + public Uri? MtlsTokenEndpointAliasUri { get; set; } + /// /// Gets the token validation parameters used by the OpenIddict server services. /// @@ -605,4 +656,42 @@ public sealed class OpenIddictServerOptions /// If no service can be found, is used. /// public TimeProvider TimeProvider { get; set; } = default!; + + /// + /// Gets or sets the chain policy used when validating client certificates + /// used for client authentication (typically, via mTLS). + /// + /// + /// + /// + /// Note: this instance serves as a base policy and is merged with + /// the per-client policies resolved using the application manager. + /// + /// + /// Note: while it is possible to use a policy configured to use the + /// the system certificates store, doing is so is strongly discouraged. + /// + /// + /// + [EditorBrowsable(EditorBrowsableState.Advanced)] + public X509ChainPolicy? ClientCertificateChainPolicy { get; set; } + + /// + /// Gets or sets the chain policy used when validating self-signed client + /// certificates used for client authentication (typically, via mTLS). + /// + /// + /// + /// + /// Note: this instance serves as a base policy and is merged with + /// the per-client policies resolved using the application manager. + /// + /// + /// Note: while it is possible to use a policy configured to use the + /// the system certificates store, doing is so is strongly discouraged. + /// + /// + /// + [EditorBrowsable(EditorBrowsableState.Advanced)] + public X509ChainPolicy? SelfSignedClientCertificateChainPolicy { get; set; } } diff --git a/src/OpenIddict.Server/OpenIddictServerTransaction.cs b/src/OpenIddict.Server/OpenIddictServerTransaction.cs index b177e187..795fbd9b 100644 --- a/src/OpenIddict.Server/OpenIddictServerTransaction.cs +++ b/src/OpenIddict.Server/OpenIddictServerTransaction.cs @@ -5,6 +5,7 @@ */ using System.ComponentModel; +using System.Security.Cryptography.X509Certificates; using Microsoft.Extensions.Logging; namespace OpenIddict.Server; @@ -26,6 +27,11 @@ public sealed class OpenIddictServerTransaction /// public CancellationToken CancellationToken { get; set; } + /// + /// Gets or sets the X.509 client certificate, if available. + /// + public X509Certificate2? ClientCertificate { get; set; } + /// /// Gets or sets the type of the endpoint processing the current request. /// diff --git a/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreConfiguration.cs b/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreConfiguration.cs index 898a7073..d453e53b 100644 --- a/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreConfiguration.cs +++ b/src/OpenIddict.Validation.AspNetCore/OpenIddictValidationAspNetCoreConfiguration.cs @@ -63,7 +63,7 @@ public sealed class OpenIddictValidationAspNetCoreConfiguration : IConfigureOpti // on invalid endpoints. To opt out this undesirable behavior, a fake entry // is dynamically added if one of the default schemes properties is not set // and less than 2 handlers were registered in the authentication options. - if (options.SchemeMap.Count < 2 && string.IsNullOrEmpty(options.DefaultScheme) && + if (options.SchemeMap.Count is < 2 && string.IsNullOrEmpty(options.DefaultScheme) && (string.IsNullOrEmpty(options.DefaultSignInScheme) || string.IsNullOrEmpty(options.DefaultSignOutScheme))) { diff --git a/src/OpenIddict.Validation.SystemNetHttp/OpenIddictValidationSystemNetHttpConfiguration.cs b/src/OpenIddict.Validation.SystemNetHttp/OpenIddictValidationSystemNetHttpConfiguration.cs index ef8fcdfc..ba7b1120 100644 --- a/src/OpenIddict.Validation.SystemNetHttp/OpenIddictValidationSystemNetHttpConfiguration.cs +++ b/src/OpenIddict.Validation.SystemNetHttp/OpenIddictValidationSystemNetHttpConfiguration.cs @@ -253,10 +253,12 @@ public sealed class OpenIddictValidationSystemNetHttpConfiguration : IConfigureO foreach (var credentials in _provider.GetRequiredService>() .CurrentValue.SigningCredentials) { + // Note: to avoid building and introspecting a X.509 certificate chain and reduce the cost + // of this check, a certificate is always assumed to be self-signed when it is self-issued. if (credentials.Key is X509SecurityKey { Certificate: X509Certificate2 certificate } && - certificate.Version is >= 3 && IsSelfIssuedCertificate(certificate) && - HasDigitalSignatureKeyUsage(certificate) && - HasClientAuthenticationExtendedKeyUsage(certificate)) + certificate.Version is >= 3 && OpenIddictHelpers.IsSelfIssuedCertificate(certificate) && + OpenIddictHelpers.HasKeyUsage(certificate, X509KeyUsageFlags.DigitalSignature) && + OpenIddictHelpers.HasExtendedKeyUsage(certificate, ObjectIdentifiers.ExtendedKeyUsages.ClientAuthentication)) { return certificate; } @@ -270,10 +272,12 @@ public sealed class OpenIddictValidationSystemNetHttpConfiguration : IConfigureO foreach (var credentials in _provider.GetRequiredService>() .CurrentValue.SigningCredentials) { + // Note: to avoid building and introspecting a X.509 certificate chain and reduce the cost + // of this check, a certificate is always assumed to be self-signed when it is self-issued. if (credentials.Key is X509SecurityKey { Certificate: X509Certificate2 certificate } && - certificate.Version is >= 3 && !IsSelfIssuedCertificate(certificate) && - HasDigitalSignatureKeyUsage(certificate) && - HasClientAuthenticationExtendedKeyUsage(certificate)) + certificate.Version is >= 3 && !OpenIddictHelpers.IsSelfIssuedCertificate(certificate) && + OpenIddictHelpers.HasKeyUsage(certificate, X509KeyUsageFlags.DigitalSignature) && + OpenIddictHelpers.HasExtendedKeyUsage(certificate, ObjectIdentifiers.ExtendedKeyUsages.ClientAuthentication)) { return certificate; } @@ -281,51 +285,5 @@ public sealed class OpenIddictValidationSystemNetHttpConfiguration : IConfigureO return null; }; - - static bool HasClientAuthenticationExtendedKeyUsage(X509Certificate2 certificate) - { - for (var index = 0; index < certificate.Extensions.Count; index++) - { - if (certificate.Extensions[index] is X509EnhancedKeyUsageExtension extension && - HasOid(extension.EnhancedKeyUsages, "1.3.6.1.5.5.7.3.2")) - { - return true; - } - } - - return false; - - static bool HasOid(OidCollection collection, string value) - { - for (var index = 0; index < collection.Count; index++) - { - if (collection[index] is Oid oid && string.Equals(oid.Value, value, StringComparison.Ordinal)) - { - return true; - } - } - - return false; - } - } - - static bool HasDigitalSignatureKeyUsage(X509Certificate2 certificate) - { - for (var index = 0; index < certificate.Extensions.Count; index++) - { - if (certificate.Extensions[index] is X509KeyUsageExtension extension && - extension.KeyUsages.HasFlag(X509KeyUsageFlags.DigitalSignature)) - { - return true; - } - } - - return false; - } - - // Note: to avoid building and introspecting a X.509 certificate chain and reduce the cost - // of this check, a certificate is always assumed to be self-signed when it is self-issued. - static bool IsSelfIssuedCertificate(X509Certificate2 certificate) - => certificate.SubjectName.RawData.AsSpan().SequenceEqual(certificate.IssuerName.RawData); } } diff --git a/src/OpenIddict.Validation/OpenIddictValidationBuilder.cs b/src/OpenIddict.Validation/OpenIddictValidationBuilder.cs index 3d1e7ea8..f99e26f7 100644 --- a/src/OpenIddict.Validation/OpenIddictValidationBuilder.cs +++ b/src/OpenIddict.Validation/OpenIddictValidationBuilder.cs @@ -134,8 +134,7 @@ public sealed class OpenIddictValidationBuilder ArgumentNullException.ThrowIfNull(key); // If the encryption key is an asymmetric security key, ensure it has a private key. - if (key is AsymmetricSecurityKey asymmetricSecurityKey && - asymmetricSecurityKey.PrivateKeyStatus is PrivateKeyStatus.DoesNotExist) + if (key is AsymmetricSecurityKey { PrivateKeyStatus: PrivateKeyStatus.DoesNotExist }) { throw new InvalidOperationException(SR.GetResourceString(SR.ID0055)); } @@ -178,7 +177,7 @@ public sealed class OpenIddictValidationBuilder // If the certificate is a X.509v3 certificate that specifies at least one // key usage, ensure that the certificate key can be used for key encryption. - if (certificate.Version >= 3) + if (certificate.Version is >= 3) { var extensions = certificate.Extensions.OfType().ToList(); if (extensions.Count is not 0 && !extensions.Exists(static extension => @@ -299,7 +298,7 @@ public sealed class OpenIddictValidationBuilder store.Open(OpenFlags.ReadOnly); return store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, validOnly: false) - .OfType() + .Cast() .SingleOrDefault(); } } @@ -321,7 +320,7 @@ public sealed class OpenIddictValidationBuilder return AddEncryptionCertificate( store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, validOnly: false) - .OfType() + .Cast() .SingleOrDefault() ?? throw new InvalidOperationException(SR.GetResourceString(SR.ID0066))); } @@ -359,8 +358,7 @@ public sealed class OpenIddictValidationBuilder ArgumentNullException.ThrowIfNull(key); // If the signing key is an asymmetric security key, ensure it has a private key. - if (key is AsymmetricSecurityKey asymmetricSecurityKey && - asymmetricSecurityKey.PrivateKeyStatus is PrivateKeyStatus.DoesNotExist) + if (key is AsymmetricSecurityKey { PrivateKeyStatus: PrivateKeyStatus.DoesNotExist }) { throw new InvalidOperationException(SR.GetResourceString(SR.ID0067)); } @@ -426,7 +424,7 @@ public sealed class OpenIddictValidationBuilder // If the certificate is a X.509v3 certificate that specifies at least // one key usage, ensure that the certificate key can be used for signing. - if (certificate.Version >= 3) + if (certificate.Version is >= 3) { var extensions = certificate.Extensions.OfType().ToList(); if (extensions.Count is not 0 && !extensions.Exists(static extension => @@ -545,7 +543,7 @@ public sealed class OpenIddictValidationBuilder store.Open(OpenFlags.ReadOnly); return store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, validOnly: false) - .OfType() + .Cast() .SingleOrDefault(); } } @@ -566,7 +564,7 @@ public sealed class OpenIddictValidationBuilder return AddSigningCertificate( store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, validOnly: false) - .OfType() + .Cast() .SingleOrDefault() ?? throw new InvalidOperationException(SR.GetResourceString(SR.ID0066))); } diff --git a/src/OpenIddict.Validation/OpenIddictValidationConfiguration.cs b/src/OpenIddict.Validation/OpenIddictValidationConfiguration.cs index 8e4c73fa..4e984058 100644 --- a/src/OpenIddict.Validation/OpenIddictValidationConfiguration.cs +++ b/src/OpenIddict.Validation/OpenIddictValidationConfiguration.cs @@ -4,6 +4,7 @@ * the license and the contributors participating to this project. */ +using System.Security.Cryptography.X509Certificates; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Protocols; @@ -97,9 +98,9 @@ public sealed class OpenIddictValidationConfiguration : IPostConfigureOptions credentials.Key is X509SecurityKey x509SecurityKey && - (x509SecurityKey.Certificate.NotBefore > now || x509SecurityKey.Certificate.NotAfter < now))) + if (options.EncryptionCredentials.Count is not 0 && options.EncryptionCredentials.TrueForAll(credentials => + credentials.Key is X509SecurityKey { Certificate: X509Certificate2 certificate } && + (certificate.NotBefore > now || certificate.NotAfter < now))) { throw new InvalidOperationException(SR.GetResourceString(SR.ID0087)); } diff --git a/src/OpenIddict.Validation/OpenIddictValidationHandlers.Protection.cs b/src/OpenIddict.Validation/OpenIddictValidationHandlers.Protection.cs index 77590d8c..55d241bf 100644 --- a/src/OpenIddict.Validation/OpenIddictValidationHandlers.Protection.cs +++ b/src/OpenIddict.Validation/OpenIddictValidationHandlers.Protection.cs @@ -398,7 +398,7 @@ public static partial class OpenIddictValidationHandlers // To achieve that, all the "scope" claims are combined into a single one containg all the values. // Visit https://datatracker.ietf.org/doc/html/rfc9068 for more information. var scopes = context.Principal.GetClaims(Claims.Scope); - if (scopes.Length > 1) + if (scopes.Length is > 1) { context.Principal.SetClaim(Claims.Scope, string.Join(" ", scopes)); } diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Authentication.cs b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Authentication.cs index e27ac704..f2f780c9 100644 --- a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Authentication.cs +++ b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Authentication.cs @@ -3214,130 +3214,6 @@ public abstract partial class OpenIddictServerIntegrationTests Assert.Equal(SR.FormatID8000(SR.ID2029), response.ErrorUri); } - [Fact] - public async Task ValidatePushedAuthorizationRequest_ClientSecretCannotBeUsedByPublicClients() - { - // Arrange - var application = new OpenIddictApplication(); - - var manager = CreateApplicationManager(mock => - { - mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) - .ReturnsAsync(application); - - mock.Setup(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny())) - .ReturnsAsync(true); - }); - - await using var server = await CreateServerAsync(options => - { - options.Services.AddSingleton(manager); - }); - - await using var client = await server.CreateClientAsync(); - - // Act - var response = await client.PostAsync("/connect/par", new OpenIddictRequest - { - ClientId = "Fabrikam", - ClientSecret = "7Fjfp0ZBr1KtDRbnfVdmIw", - RedirectUri = "http://www.fabrikam.com/path", - ResponseType = ResponseTypes.Code - }); - - // Assert - Assert.Equal(Errors.InvalidClient, response.Error); - Assert.Equal(SR.FormatID2053(Parameters.ClientSecret), response.ErrorDescription); - Assert.Equal(SR.FormatID8000(SR.ID2053), response.ErrorUri); - - Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); - Mock.Get(manager).Verify(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny()), Times.Once()); - } - - [Fact] - public async Task ValidatePushedAuthorizationRequest_ClientSecretIsRequiredForNonPublicClients() - { - // Arrange - var application = new OpenIddictApplication(); - - var manager = CreateApplicationManager(mock => - { - mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) - .ReturnsAsync(application); - - mock.Setup(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny())) - .ReturnsAsync(false); - }); - - await using var server = await CreateServerAsync(options => - { - options.Services.AddSingleton(manager); - }); - - await using var client = await server.CreateClientAsync(); - - // Act - var response = await client.PostAsync("/connect/par", new OpenIddictRequest - { - ClientId = "Fabrikam", - ClientSecret = null, - RedirectUri = "http://www.fabrikam.com/path", - ResponseType = ResponseTypes.Code - }); - - // Assert - Assert.Equal(Errors.InvalidClient, response.Error); - Assert.Equal(SR.FormatID2054(Parameters.ClientSecret), response.ErrorDescription); - Assert.Equal(SR.FormatID8000(SR.ID2054), response.ErrorUri); - - Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); - Mock.Get(manager).Verify(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny()), Times.Once()); - } - - [Fact] - public async Task ValidatePushedAuthorizationRequest_RequestIsRejectedWhenClientCredentialsAreInvalid() - { - // Arrange - var application = new OpenIddictApplication(); - - var manager = CreateApplicationManager(mock => - { - mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) - .ReturnsAsync(application); - - mock.Setup(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny())) - .ReturnsAsync(false); - - mock.Setup(manager => manager.ValidateClientSecretAsync(application, "7Fjfp0ZBr1KtDRbnfVdmIw", It.IsAny())) - .ReturnsAsync(false); - }); - - await using var server = await CreateServerAsync(options => - { - options.Services.AddSingleton(manager); - }); - - await using var client = await server.CreateClientAsync(); - - // Act - var response = await client.PostAsync("/connect/par", new OpenIddictRequest - { - ClientId = "Fabrikam", - ClientSecret = "7Fjfp0ZBr1KtDRbnfVdmIw", - RedirectUri = "http://www.fabrikam.com/path", - ResponseType = ResponseTypes.Code - }); - - // Assert - Assert.Equal(Errors.InvalidClient, response.Error); - Assert.Equal(SR.GetResourceString(SR.ID2055), response.ErrorDescription); - Assert.Equal(SR.FormatID8000(SR.ID2055), response.ErrorUri); - - Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); - Mock.Get(manager).Verify(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny()), Times.AtLeastOnce()); - Mock.Get(manager).Verify(manager => manager.ValidateClientSecretAsync(application, "7Fjfp0ZBr1KtDRbnfVdmIw", It.IsAny()), Times.Once()); - } - [Fact] public async Task ValidatePushedAuthorizationRequest_MissingRedirectUriCausesAnErrorForOpenIdRequests() { diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Device.cs b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Device.cs index 3d3b404d..2e3bfeef 100644 --- a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Device.cs +++ b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Device.cs @@ -491,124 +491,6 @@ public abstract partial class OpenIddictServerIntegrationTests Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); } - [Fact] - public async Task ValidateDeviceAuthorizationRequest_ClientSecretCannotBeUsedByPublicClients() - { - // Arrange - var application = new OpenIddictApplication(); - - var manager = CreateApplicationManager(mock => - { - mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) - .ReturnsAsync(application); - - mock.Setup(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny())) - .ReturnsAsync(true); - }); - - await using var server = await CreateServerAsync(options => - { - options.Services.AddSingleton(manager); - }); - - await using var client = await server.CreateClientAsync(); - - // Act - var response = await client.PostAsync("/connect/device", new OpenIddictRequest - { - ClientId = "Fabrikam", - ClientSecret = "7Fjfp0ZBr1KtDRbnfVdmIw" - }); - - // Assert - Assert.Equal(Errors.InvalidClient, response.Error); - Assert.Equal(SR.FormatID2053(Parameters.ClientSecret), response.ErrorDescription); - Assert.Equal(SR.FormatID8000(SR.ID2053), response.ErrorUri); - - Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); - Mock.Get(manager).Verify(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny()), Times.Once()); - } - - [Fact] - public async Task ValidateDeviceAuthorizationRequest_ClientSecretIsRequiredForNonPublicClients() - { - // Arrange - var application = new OpenIddictApplication(); - - var manager = CreateApplicationManager(mock => - { - mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) - .ReturnsAsync(application); - - mock.Setup(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny())) - .ReturnsAsync(false); - }); - - await using var server = await CreateServerAsync(options => - { - options.Services.AddSingleton(manager); - }); - - await using var client = await server.CreateClientAsync(); - - // Act - var response = await client.PostAsync("/connect/device", new OpenIddictRequest - { - ClientId = "Fabrikam", - ClientSecret = null - }); - - // Assert - Assert.Equal(Errors.InvalidClient, response.Error); - Assert.Equal(SR.FormatID2054(Parameters.ClientSecret), response.ErrorDescription); - Assert.Equal(SR.FormatID8000(SR.ID2054), response.ErrorUri); - - Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); - Mock.Get(manager).Verify(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny()), Times.Once()); - } - - [Fact] - public async Task ValidateDeviceAuthorizationRequest_RequestIsRejectedWhenClientCredentialsAreInvalid() - { - // Arrange - var application = new OpenIddictApplication(); - - var manager = CreateApplicationManager(mock => - { - mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) - .ReturnsAsync(application); - - mock.Setup(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny())) - .ReturnsAsync(false); - - mock.Setup(manager => manager.ValidateClientSecretAsync(application, "7Fjfp0ZBr1KtDRbnfVdmIw", It.IsAny())) - .ReturnsAsync(false); - }); - - await using var server = await CreateServerAsync(options => - { - options.Services.AddSingleton(manager); - }); - - await using var client = await server.CreateClientAsync(); - - // Act - var response = await client.PostAsync("/connect/device", new OpenIddictRequest - { - ClientId = "Fabrikam", - ClientSecret = "7Fjfp0ZBr1KtDRbnfVdmIw" - }); - - // Assert - Assert.Equal(Errors.InvalidClient, response.Error); - Assert.Equal(SR.GetResourceString(SR.ID2055), response.ErrorDescription); - Assert.Equal(SR.FormatID8000(SR.ID2055), response.ErrorUri); - - Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); - Mock.Get(manager).Verify(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny()), Times.AtLeastOnce()); - Mock.Get(manager).Verify(manager => manager.ValidateClientSecretAsync(application, "7Fjfp0ZBr1KtDRbnfVdmIw", It.IsAny()), Times.Once()); - } - [Fact] public async Task ValidateDeviceAuthorizationRequest_RequestIsRejectedWhenEndpointPermissionIsNotGranted() { diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Discovery.cs b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Discovery.cs index 59f2f1ca..1ceea847 100644 --- a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Discovery.cs +++ b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Discovery.cs @@ -265,9 +265,10 @@ public abstract partial class OpenIddictServerIntegrationTests { options.SetAuthorizationEndpointUris("https://www.fabrikam.com/path/authorization_endpoint") .SetJsonWebKeySetEndpointUris("https://www.fabrikam.com/path/cryptography_endpoint") - .SetDeviceAuthorizationEndpointUris("https://www.fabrikam.com/path/device_endpoint") + .SetDeviceAuthorizationEndpointUris("https://www.fabrikam.com/path/device_authorization_endpoint") .SetIntrospectionEndpointUris("https://www.fabrikam.com/path/introspection_endpoint") - .SetEndSessionEndpointUris("https://www.fabrikam.com/path/logout_endpoint") + .SetEndSessionEndpointUris("https://www.fabrikam.com/path/end_session_endpoint") + .SetPushedAuthorizationEndpointUris("https://www.fabrikam.com/path/pushed_authorization_endpoint") .SetRevocationEndpointUris("https://www.fabrikam.com/path/revocation_endpoint") .SetTokenEndpointUris("https://www.fabrikam.com/path/token_endpoint") .SetUserInfoEndpointUris("https://www.fabrikam.com/path/userinfo_endpoint"); @@ -288,15 +289,18 @@ public abstract partial class OpenIddictServerIntegrationTests Assert.Equal("https://www.fabrikam.com/path/authorization_endpoint", (string?) response[Metadata.AuthorizationEndpoint]); - Assert.Equal("https://www.fabrikam.com/path/device_endpoint", + Assert.Equal("https://www.fabrikam.com/path/device_authorization_endpoint", (string?) response[Metadata.DeviceAuthorizationEndpoint]); Assert.Equal("https://www.fabrikam.com/path/introspection_endpoint", (string?) response[Metadata.IntrospectionEndpoint]); - Assert.Equal("https://www.fabrikam.com/path/logout_endpoint", + Assert.Equal("https://www.fabrikam.com/path/end_session_endpoint", (string?) response[Metadata.EndSessionEndpoint]); + Assert.Equal("https://www.fabrikam.com/path/pushed_authorization_endpoint", + (string?) response[Metadata.PushedAuthorizationRequestEndpoint]); + Assert.Equal("https://www.fabrikam.com/path/revocation_endpoint", (string?) response[Metadata.RevocationEndpoint]); @@ -315,9 +319,9 @@ public abstract partial class OpenIddictServerIntegrationTests { options.SetAuthorizationEndpointUris("path/authorization_endpoint") .SetJsonWebKeySetEndpointUris("path/cryptography_endpoint") - .SetDeviceAuthorizationEndpointUris("path/device_endpoint") + .SetDeviceAuthorizationEndpointUris("path/device_authorization_endpoint") .SetIntrospectionEndpointUris("path/introspection_endpoint") - .SetEndSessionEndpointUris("path/logout_endpoint") + .SetEndSessionEndpointUris("path/end_session_endpoint") .SetPushedAuthorizationEndpointUris("path/pushed_authorization_endpoint") .SetRevocationEndpointUris("path/revocation_endpoint") .SetTokenEndpointUris("path/token_endpoint") @@ -332,9 +336,9 @@ public abstract partial class OpenIddictServerIntegrationTests // Assert Assert.Equal("http://localhost/path/authorization_endpoint", (string?) response[Metadata.AuthorizationEndpoint]); Assert.Equal("http://localhost/path/cryptography_endpoint", (string?) response[Metadata.JwksUri]); - Assert.Equal("http://localhost/path/device_endpoint", (string?) response[Metadata.DeviceAuthorizationEndpoint]); + Assert.Equal("http://localhost/path/device_authorization_endpoint", (string?) response[Metadata.DeviceAuthorizationEndpoint]); Assert.Equal("http://localhost/path/introspection_endpoint", (string?) response[Metadata.IntrospectionEndpoint]); - Assert.Equal("http://localhost/path/logout_endpoint", (string?) response[Metadata.EndSessionEndpoint]); + Assert.Equal("http://localhost/path/end_session_endpoint", (string?) response[Metadata.EndSessionEndpoint]); Assert.Equal("http://localhost/path/pushed_authorization_endpoint", (string?) response[Metadata.PushedAuthorizationRequestEndpoint]); Assert.Equal("http://localhost/path/revocation_endpoint", (string?) response[Metadata.RevocationEndpoint]); Assert.Equal("http://localhost/path/token_endpoint", (string?) response[Metadata.TokenEndpoint]); @@ -349,9 +353,9 @@ public abstract partial class OpenIddictServerIntegrationTests { options.SetAuthorizationEndpointUris("path/authorization_endpoint") .SetJsonWebKeySetEndpointUris("path/cryptography_endpoint") - .SetDeviceAuthorizationEndpointUris("path/device_endpoint") + .SetDeviceAuthorizationEndpointUris("path/device_authorization_endpoint") .SetIntrospectionEndpointUris("path/introspection_endpoint") - .SetEndSessionEndpointUris("path/logout_endpoint") + .SetEndSessionEndpointUris("path/end_session_endpoint") .SetPushedAuthorizationEndpointUris("path/pushed_authorization_endpoint") .SetRevocationEndpointUris("path/revocation_endpoint") .SetTokenEndpointUris("path/token_endpoint") @@ -378,9 +382,9 @@ public abstract partial class OpenIddictServerIntegrationTests // Assert Assert.Equal("https://contoso.com/issuer/path/authorization_endpoint", (string?) response[Metadata.AuthorizationEndpoint]); Assert.Equal("https://contoso.com/issuer/path/cryptography_endpoint", (string?) response[Metadata.JwksUri]); - Assert.Equal("https://contoso.com/issuer/path/device_endpoint", (string?) response[Metadata.DeviceAuthorizationEndpoint]); + Assert.Equal("https://contoso.com/issuer/path/device_authorization_endpoint", (string?) response[Metadata.DeviceAuthorizationEndpoint]); Assert.Equal("https://contoso.com/issuer/path/introspection_endpoint", (string?) response[Metadata.IntrospectionEndpoint]); - Assert.Equal("https://contoso.com/issuer/path/logout_endpoint", (string?) response[Metadata.EndSessionEndpoint]); + Assert.Equal("https://contoso.com/issuer/path/end_session_endpoint", (string?) response[Metadata.EndSessionEndpoint]); Assert.Equal("https://contoso.com/issuer/path/pushed_authorization_endpoint", (string?) response[Metadata.PushedAuthorizationRequestEndpoint]); Assert.Equal("https://contoso.com/issuer/path/revocation_endpoint", (string?) response[Metadata.RevocationEndpoint]); Assert.Equal("https://contoso.com/issuer/path/token_endpoint", (string?) response[Metadata.TokenEndpoint]); @@ -977,6 +981,43 @@ public abstract partial class OpenIddictServerIntegrationTests Assert.False((bool?) response[Metadata.RequestUriParameterSupported]); } + [Fact] + public async Task HandleConfigurationRequest_MtlsAliasesAreReturned() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.SetIssuer("https://mtls.fabrikam.com/"); + + options.SetMtlsDeviceAuthorizationEndpointAliasUri("https://mtls.fabrikam.com/path/device_authorization_endpoint") + .SetMtlsIntrospectionEndpointAliasUri("https://mtls.fabrikam.com/path/introspection_endpoint") + .SetMtlsPushedAuthorizationEndpointAliasUri("https://mtls.fabrikam.com/path/pushed_authorization_endpoint") + .SetMtlsRevocationEndpointAliasUri("https://mtls.fabrikam.com/path/revocation_endpoint") + .SetMtlsTokenEndpointAliasUri("https://mtls.fabrikam.com/path/token_endpoint"); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.GetAsync("/.well-known/openid-configuration"); + + // Assert + Assert.Equal("https://mtls.fabrikam.com/path/device_authorization_endpoint", + (string?) response[Metadata.MtlsEndpointAliases]?[Metadata.DeviceAuthorizationEndpoint]); + + Assert.Equal("https://mtls.fabrikam.com/path/introspection_endpoint", + (string?) response[Metadata.MtlsEndpointAliases]?[Metadata.IntrospectionEndpoint]); + + Assert.Equal("https://mtls.fabrikam.com/path/pushed_authorization_endpoint", + (string?) response[Metadata.MtlsEndpointAliases]?[Metadata.PushedAuthorizationRequestEndpoint]); + + Assert.Equal("https://mtls.fabrikam.com/path/revocation_endpoint", + (string?) response[Metadata.MtlsEndpointAliases]?[Metadata.RevocationEndpoint]); + + Assert.Equal("https://mtls.fabrikam.com/path/token_endpoint", + (string?) response[Metadata.MtlsEndpointAliases]?[Metadata.TokenEndpoint]); + } + [Theory] [InlineData(true)] [InlineData(false)] diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Exchange.cs b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Exchange.cs index 59fc926d..6307bda1 100644 --- a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Exchange.cs +++ b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Exchange.cs @@ -2533,133 +2533,6 @@ public abstract partial class OpenIddictServerIntegrationTests Mock.Get(manager).Verify(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny()), Times.Once()); } - [Fact] - public async Task ValidateTokenRequest_ClientSecretCannotBeUsedByPublicClients() - { - // Arrange - var application = new OpenIddictApplication(); - - var manager = CreateApplicationManager(mock => - { - mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) - .ReturnsAsync(application); - - mock.Setup(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny())) - .ReturnsAsync(true); - }); - - await using var server = await CreateServerAsync(options => - { - options.Services.AddSingleton(manager); - }); - - await using var client = await server.CreateClientAsync(); - - // Act - var response = await client.PostAsync("/connect/token", new OpenIddictRequest - { - ClientId = "Fabrikam", - ClientSecret = "7Fjfp0ZBr1KtDRbnfVdmIw", - GrantType = GrantTypes.Password, - Username = "johndoe", - Password = "A3ddj3w" - }); - - // Assert - Assert.Equal(Errors.InvalidClient, response.Error); - Assert.Equal(SR.FormatID2053(Parameters.ClientSecret), response.ErrorDescription); - Assert.Equal(SR.FormatID8000(SR.ID2053), response.ErrorUri); - - Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); - Mock.Get(manager).Verify(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny()), Times.Once()); - } - - [Fact] - public async Task ValidateTokenRequest_ClientSecretIsRequiredForNonPublicClients() - { - // Arrange - var application = new OpenIddictApplication(); - - var manager = CreateApplicationManager(mock => - { - mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) - .ReturnsAsync(application); - - mock.Setup(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny())) - .ReturnsAsync(false); - }); - - await using var server = await CreateServerAsync(options => - { - options.Services.AddSingleton(manager); - }); - - await using var client = await server.CreateClientAsync(); - - // Act - var response = await client.PostAsync("/connect/token", new OpenIddictRequest - { - ClientId = "Fabrikam", - ClientSecret = null, - GrantType = GrantTypes.Password, - Username = "johndoe", - Password = "A3ddj3w" - }); - - // Assert - Assert.Equal(Errors.InvalidClient, response.Error); - Assert.Equal(SR.FormatID2054(Parameters.ClientSecret), response.ErrorDescription); - Assert.Equal(SR.FormatID8000(SR.ID2054), response.ErrorUri); - - Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); - Mock.Get(manager).Verify(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny()), Times.Once()); - } - - [Fact] - public async Task ValidateTokenRequest_RequestIsRejectedWhenClientCredentialsAreInvalid() - { - // Arrange - var application = new OpenIddictApplication(); - - var manager = CreateApplicationManager(mock => - { - mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) - .ReturnsAsync(application); - - mock.Setup(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny())) - .ReturnsAsync(false); - - mock.Setup(manager => manager.ValidateClientSecretAsync(application, "7Fjfp0ZBr1KtDRbnfVdmIw", It.IsAny())) - .ReturnsAsync(false); - }); - - await using var server = await CreateServerAsync(options => - { - options.Services.AddSingleton(manager); - }); - - await using var client = await server.CreateClientAsync(); - - // Act - var response = await client.PostAsync("/connect/token", new OpenIddictRequest - { - ClientId = "Fabrikam", - ClientSecret = "7Fjfp0ZBr1KtDRbnfVdmIw", - GrantType = GrantTypes.Password, - Username = "johndoe", - Password = "A3ddj3w" - }); - - // Assert - Assert.Equal(Errors.InvalidClient, response.Error); - Assert.Equal(SR.GetResourceString(SR.ID2055), response.ErrorDescription); - Assert.Equal(SR.FormatID8000(SR.ID2055), response.ErrorUri); - - Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); - Mock.Get(manager).Verify(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny()), Times.AtLeastOnce()); - Mock.Get(manager).Verify(manager => manager.ValidateClientSecretAsync(application, "7Fjfp0ZBr1KtDRbnfVdmIw", It.IsAny()), Times.Once()); - } - [Fact] public async Task ValidateTokenRequest_RequestIsRejectedWhenEndpointPermissionIsNotGranted() { diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Introspection.cs b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Introspection.cs index ed616186..de8480bf 100644 --- a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Introspection.cs +++ b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Introspection.cs @@ -364,124 +364,6 @@ public abstract partial class OpenIddictServerIntegrationTests Permissions.Endpoints.Introspection, It.IsAny()), Times.Once()); } - [Fact] - public async Task ValidateIntrospectionRequest_ClientSecretCannotBeUsedByPublicClients() - { - // Arrange - var application = new OpenIddictApplication(); - - var manager = CreateApplicationManager(mock => - { - mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) - .ReturnsAsync(application); - - mock.Setup(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny())) - .ReturnsAsync(true); - }); - - await using var server = await CreateServerAsync(options => - { - options.Services.AddSingleton(manager); - }); - - await using var client = await server.CreateClientAsync(); - - // Act - var response = await client.PostAsync("/connect/introspect", new OpenIddictRequest - { - ClientId = "Fabrikam", - ClientSecret = "7Fjfp0ZBr1KtDRbnfVdmIw", - Token = "2YotnFZFEjr1zCsicMWpAA" - }); - - // Assert - Assert.Equal(Errors.InvalidClient, response.Error); - Assert.Equal(SR.FormatID2053(Parameters.ClientSecret), response.ErrorDescription); - Assert.Equal(SR.FormatID8000(SR.ID2053), response.ErrorUri); - - Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); - Mock.Get(manager).Verify(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny()), Times.Once()); - } - - [Fact] - public async Task ValidateIntrospectionRequest_ClientSecretIsRequiredForNonPublicClients() - { - // Arrange - var application = new OpenIddictApplication(); - - var manager = CreateApplicationManager(mock => - { - mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) - .ReturnsAsync(application); - - mock.Setup(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny())) - .ReturnsAsync(false); - }); - - await using var server = await CreateServerAsync(options => - { - options.Services.AddSingleton(manager); - }); - - await using var client = await server.CreateClientAsync(); - - // Act - var response = await client.PostAsync("/connect/introspect", new OpenIddictRequest - { - ClientId = "Fabrikam", - ClientSecret = null, - Token = "2YotnFZFEjr1zCsicMWpAA" - }); - - // Assert - Assert.Equal(Errors.InvalidClient, response.Error); - Assert.Equal(SR.FormatID2054(Parameters.ClientSecret), response.ErrorDescription); - Assert.Equal(SR.FormatID8000(SR.ID2054), response.ErrorUri); - - Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); - Mock.Get(manager).Verify(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny()), Times.Once()); - } - - [Fact] - public async Task ValidateIntrospectionRequest_RequestIsRejectedWhenClientCredentialsAreInvalid() - { - // Arrange - var application = new OpenIddictApplication(); - - var manager = CreateApplicationManager(mock => - { - mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) - .ReturnsAsync(application); - - mock.Setup(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny())) - .ReturnsAsync(false); - - mock.Setup(manager => manager.ValidateClientSecretAsync(application, "7Fjfp0ZBr1KtDRbnfVdmIw", It.IsAny())) - .ReturnsAsync(false); - }); - - await using var server = await CreateServerAsync(options => - { - options.Services.AddSingleton(manager); - }); - - await using var client = await server.CreateClientAsync(); - - // Act - var response = await client.PostAsync("/connect/introspect", new OpenIddictRequest - { - ClientId = "Fabrikam", - ClientSecret = "7Fjfp0ZBr1KtDRbnfVdmIw", - Token = "2YotnFZFEjr1zCsicMWpAA" - }); - - // Assert - - Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); - Mock.Get(manager).Verify(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny()), Times.AtLeastOnce()); - Mock.Get(manager).Verify(manager => manager.ValidateClientSecretAsync(application, "7Fjfp0ZBr1KtDRbnfVdmIw", It.IsAny()), Times.Once()); - } - [Fact] public async Task ValidateIntrospectionRequest_InvalidTokenCausesAnError() { diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Revocation.cs b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Revocation.cs index 02934f9a..8dce3ff0 100644 --- a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Revocation.cs +++ b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Revocation.cs @@ -365,130 +365,6 @@ public abstract partial class OpenIddictServerIntegrationTests Permissions.Endpoints.Revocation, It.IsAny()), Times.Once()); } - [Fact] - public async Task ValidateRevocationRequest_ClientSecretCannotBeUsedByPublicClients() - { - // Arrange - var application = new OpenIddictApplication(); - - var manager = CreateApplicationManager(mock => - { - mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) - .ReturnsAsync(application); - - mock.Setup(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny())) - .ReturnsAsync(true); - }); - - await using var server = await CreateServerAsync(options => - { - options.Services.AddSingleton(manager); - }); - - await using var client = await server.CreateClientAsync(); - - // Act - var response = await client.PostAsync("/connect/revoke", new OpenIddictRequest - { - ClientId = "Fabrikam", - ClientSecret = "7Fjfp0ZBr1KtDRbnfVdmIw", - Token = "SlAV32hkKG", - TokenTypeHint = TokenTypeHints.RefreshToken - }); - - // Assert - Assert.Equal(Errors.InvalidClient, response.Error); - Assert.Equal(SR.FormatID2053(Parameters.ClientSecret), response.ErrorDescription); - Assert.Equal(SR.FormatID8000(SR.ID2053), response.ErrorUri); - - Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); - Mock.Get(manager).Verify(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny()), Times.Once()); - } - - [Fact] - public async Task ValidateRevocationRequest_ClientSecretIsRequiredForNonPublicClients() - { - // Arrange - var application = new OpenIddictApplication(); - - var manager = CreateApplicationManager(mock => - { - mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) - .ReturnsAsync(application); - - mock.Setup(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny())) - .ReturnsAsync(false); - }); - - await using var server = await CreateServerAsync(options => - { - options.Services.AddSingleton(manager); - }); - - await using var client = await server.CreateClientAsync(); - - // Act - var response = await client.PostAsync("/connect/revoke", new OpenIddictRequest - { - ClientId = "Fabrikam", - ClientSecret = null, - Token = "SlAV32hkKG", - TokenTypeHint = TokenTypeHints.RefreshToken - }); - - // Assert - Assert.Equal(Errors.InvalidClient, response.Error); - Assert.Equal(SR.FormatID2054(Parameters.ClientSecret), response.ErrorDescription); - Assert.Equal(SR.FormatID8000(SR.ID2054), response.ErrorUri); - - Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); - Mock.Get(manager).Verify(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny()), Times.Once()); - } - - [Fact] - public async Task ValidateRevocationRequest_RequestIsRejectedWhenClientCredentialsAreInvalid() - { - // Arrange - var application = new OpenIddictApplication(); - - var manager = CreateApplicationManager(mock => - { - mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) - .ReturnsAsync(application); - - mock.Setup(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny())) - .ReturnsAsync(false); - - mock.Setup(manager => manager.ValidateClientSecretAsync(application, "7Fjfp0ZBr1KtDRbnfVdmIw", It.IsAny())) - .ReturnsAsync(false); - }); - - await using var server = await CreateServerAsync(builder => - { - builder.Services.AddSingleton(manager); - }); - - await using var client = await server.CreateClientAsync(); - - // Act - var response = await client.PostAsync("/connect/revoke", new OpenIddictRequest - { - ClientId = "Fabrikam", - ClientSecret = "7Fjfp0ZBr1KtDRbnfVdmIw", - Token = "SlAV32hkKG", - TokenTypeHint = TokenTypeHints.RefreshToken - }); - - // Assert - Assert.Equal(Errors.InvalidClient, response.Error); - Assert.Equal(SR.GetResourceString(SR.ID2055), response.ErrorDescription); - Assert.Equal(SR.FormatID8000(SR.ID2055), response.ErrorUri); - - Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); - Mock.Get(manager).Verify(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny()), Times.AtLeastOnce()); - Mock.Get(manager).Verify(manager => manager.ValidateClientSecretAsync(application, "7Fjfp0ZBr1KtDRbnfVdmIw", It.IsAny()), Times.Once()); - } - [Theory] [InlineData(TokenTypeIdentifiers.Private.AuthorizationCode)] [InlineData(TokenTypeIdentifiers.Private.DeviceCode)] diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs index b8484798..b6ef0a3e 100644 --- a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs +++ b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs @@ -9,6 +9,7 @@ using System.Collections.Immutable; using System.Globalization; using System.Security.Claims; +using System.Security.Cryptography.X509Certificates; using System.Text.Json; using System.Text.Json.Nodes; using Microsoft.Extensions.DependencyInjection; @@ -836,6 +837,1167 @@ public abstract partial class OpenIddictServerIntegrationTests Assert.Equal("Bob le Magnifique", (string?) response[Claims.Subject]); } + [Theory] + [InlineData(OpenIddictServerEndpointType.DeviceAuthorization)] + [InlineData(OpenIddictServerEndpointType.Introspection)] + [InlineData(OpenIddictServerEndpointType.PushedAuthorization)] + [InlineData(OpenIddictServerEndpointType.Revocation)] + [InlineData(OpenIddictServerEndpointType.Token)] + public async Task ProcessAuthentication_ClientSecretCannotBeUsedByPublicClients(OpenIddictServerEndpointType type) + { + // Arrange + var application = new OpenIddictApplication(); + + var manager = CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + mock.Setup(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny())) + .ReturnsAsync(true); + }); + + await using var server = await CreateServerAsync(options => + { + options.Services.AddSingleton(manager); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = type switch + { + OpenIddictServerEndpointType.Introspection => await client.PostAsync("/connect/introspect", new OpenIddictRequest + { + ClientId = "Fabrikam", + ClientSecret = "7Fjfp0ZBr1KtDRbnfVdmIw", + Token = "2YotnFZFEjr1zCsicMWpAA" + }), + + OpenIddictServerEndpointType.DeviceAuthorization => await client.PostAsync("/connect/device", new OpenIddictRequest + { + ClientId = "Fabrikam", + ClientSecret = "7Fjfp0ZBr1KtDRbnfVdmIw" + }), + + OpenIddictServerEndpointType.PushedAuthorization => await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + ClientSecret = "7Fjfp0ZBr1KtDRbnfVdmIw", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code + }), + + OpenIddictServerEndpointType.Revocation => await client.PostAsync("/connect/revoke", new OpenIddictRequest + { + ClientId = "Fabrikam", + ClientSecret = "7Fjfp0ZBr1KtDRbnfVdmIw", + Token = "SlAV32hkKG", + TokenTypeHint = TokenTypeHints.RefreshToken + }), + + OpenIddictServerEndpointType.Token => await client.PostAsync("/connect/token", new OpenIddictRequest + { + ClientId = "Fabrikam", + ClientSecret = "7Fjfp0ZBr1KtDRbnfVdmIw", + GrantType = GrantTypes.Password, + Username = "johndoe", + Password = "A3ddj3w" + }), + + _ => throw new NotSupportedException() + }; + + // Assert + Assert.Equal(Errors.InvalidClient, response.Error); + Assert.Equal(SR.FormatID2053(Parameters.ClientSecret), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2053), response.ErrorUri); + + Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); + Mock.Get(manager).Verify(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny()), Times.Once()); + } + + [Theory] + [InlineData(OpenIddictServerEndpointType.DeviceAuthorization)] + [InlineData(OpenIddictServerEndpointType.Introspection)] + [InlineData(OpenIddictServerEndpointType.PushedAuthorization)] + [InlineData(OpenIddictServerEndpointType.Revocation)] + [InlineData(OpenIddictServerEndpointType.Token)] + public async Task ProcessAuthentication_ClientAssertionCannotBeUsedByPublicClients(OpenIddictServerEndpointType type) + { + // Arrange + var application = new OpenIddictApplication(); + + var manager = CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + mock.Setup(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny())) + .ReturnsAsync(true); + }); + + await using var server = await CreateServerAsync(options => + { + options.AddEventHandler(builder => + { + builder.UseInlineHandler(context => + { + Assert.Equal("2YotnFZFEjr1zCsicMWpAA", context.Token); + + context.Principal = new ClaimsPrincipal(new ClaimsIdentity("Bearer")) + .SetTokenType(TokenTypeIdentifiers.Private.ClientAssertion) + .SetClaim(Claims.Issuer, "Fabrikam") + .SetClaim(Claims.Subject, "Fabrikam") + .SetClaim(Claims.Audience, "http://localhost/") + .SetClaim(Claims.ExpiresAt, 4099766400); + + return ValueTask.CompletedTask; + }); + + builder.SetOrder(ValidateIdentityModelToken.Descriptor.Order - 500); + }); + + options.Services.AddSingleton(manager); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = type switch + { + OpenIddictServerEndpointType.Introspection => await client.PostAsync("/connect/introspect", new OpenIddictRequest + { + ClientAssertion = "2YotnFZFEjr1zCsicMWpAA", + ClientAssertionType = ClientAssertionTypes.JwtBearer, + ClientId = "Fabrikam", + Token = "2YotnFZFEjr1zCsicMWpAA" + }), + + OpenIddictServerEndpointType.DeviceAuthorization => await client.PostAsync("/connect/device", new OpenIddictRequest + { + ClientAssertion = "2YotnFZFEjr1zCsicMWpAA", + ClientAssertionType = ClientAssertionTypes.JwtBearer, + ClientId = "Fabrikam" + }), + + OpenIddictServerEndpointType.PushedAuthorization => await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientAssertion = "2YotnFZFEjr1zCsicMWpAA", + ClientAssertionType = ClientAssertionTypes.JwtBearer, + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code + }), + + OpenIddictServerEndpointType.Revocation => await client.PostAsync("/connect/revoke", new OpenIddictRequest + { + ClientAssertion = "2YotnFZFEjr1zCsicMWpAA", + ClientAssertionType = ClientAssertionTypes.JwtBearer, + ClientId = "Fabrikam", + Token = "SlAV32hkKG", + TokenTypeHint = TokenTypeHints.RefreshToken + }), + + OpenIddictServerEndpointType.Token => await client.PostAsync("/connect/token", new OpenIddictRequest + { + ClientAssertion = "2YotnFZFEjr1zCsicMWpAA", + ClientAssertionType = ClientAssertionTypes.JwtBearer, + ClientId = "Fabrikam", + GrantType = GrantTypes.Password, + Username = "johndoe", + Password = "A3ddj3w" + }), + + _ => throw new NotSupportedException() + }; + + // Assert + Assert.Equal(Errors.InvalidClient, response.Error); + Assert.Equal(SR.FormatID2053(Parameters.ClientAssertion), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2053), response.ErrorUri); + + Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); + Mock.Get(manager).Verify(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny()), Times.Once()); + } + +#if SUPPORTS_X509_CHAIN_POLICY_CUSTOM_TRUST_STORE + [Theory] + [InlineData(OpenIddictServerEndpointType.DeviceAuthorization)] + [InlineData(OpenIddictServerEndpointType.Introspection)] + [InlineData(OpenIddictServerEndpointType.PushedAuthorization)] + [InlineData(OpenIddictServerEndpointType.Revocation)] + [InlineData(OpenIddictServerEndpointType.Token)] + public async Task ProcessAuthentication_ClientCertificateCannotBeUsedByPublicClients(OpenIddictServerEndpointType type) + { + // Arrange + var application = new OpenIddictApplication(); + + var manager = CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + mock.Setup(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny())) + .ReturnsAsync(true); + }); + + await using var server = await CreateServerAsync(options => + { + options.AddEventHandler(builder => + { + builder.UseInlineHandler(context => + { + context.ClientCertificate = X509Certificate2.CreateFromPem($""" + -----BEGIN CERTIFICATE----- + MIIC8jCCAdqgAwIBAgIIYfcknj8KXN0wDQYJKoZIhvcNAQELBQAwIjEgMB4GA1UE + AxMXU2VsZi1zaWduZWQgY2VydGlmaWNhdGUwIBcNMjYwMjAxMTc1MjI2WhgPMjEy + NjAyMDExNzUyMjZaMCIxIDAeBgNVBAMTF1NlbGYtc2lnbmVkIGNlcnRpZmljYXRl + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA45uKd5cdlLmEBGLDEB75 + o9e3/kMmQjVhhMeBsy4m2t7zw5jZo7OPcahXiXZHttom9tJm7BWPWvYx7p0N9+ss + h/E5lzKyV7ZXg+mM+KeECtVhiy+82BuIPelCshrpaV3lIg93y47FYLIWXxdggjt6 + 6VUaxzlTeo+IpuMz8IssL7VpJnjCT5NmqPNVkv1VR1uuetVqP7546ZFw31RiGl/0 + I1uUlb7SwLwhLUK1iyLmGNA3VDB0m0DvLmlIEY3ZE5zxQp/Rxq6DfjbXm2LWJyu6 + NO7k7JixXOorEl+6HdJZHTWNFK5jCo2ZZAwWn+uUuzgmLILPJFLDVutXjuEZpsym + uQIDAQABoyowKDAOBgNVHQ8BAf8EBAMCB4AwFgYDVR0lAQH/BAwwCgYIKwYBBQUH + AwIwDQYJKoZIhvcNAQELBQADggEBAGHH3f/bkfViTvPE7yXJkB0bs88mYxajluMA + hgihEN5joPT6zHxMLBND2sitIozCMeeaj0rg+OaT/zDBgOLup/BM92UaPpYcgDCy + 3tHqZLOOJOR4aYnHhIQUnx+NRtKEM4q/hL/xLHeliKmV7TQXISEZlTbb0gOU7TFp + nJlP60Vo9F/WD6xcKNxBgV5aB/+2FjiTTw2pF0VUmvcZdQAN5ysfrmKNXbvv1oCp + AohiwRiPrwe3mJ8iCqzEY/qQqImEiIT8WC2Fty+UYyBwfXMObi1AO++QkaMUbUJl + 0aBuRoD85FLotjHIHXkFHERjOolheYdKt5nrGCCz/PmXBfsSCTo= + -----END CERTIFICATE----- + """); + + return ValueTask.CompletedTask; + }); + + builder.SetOrder(ValidateClientType.Descriptor.Order - 500); + }); + + options.Services.AddSingleton(manager); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = type switch + { + OpenIddictServerEndpointType.Introspection => await client.PostAsync("/connect/introspect", new OpenIddictRequest + { + ClientId = "Fabrikam", + Token = "2YotnFZFEjr1zCsicMWpAA" + }), + + OpenIddictServerEndpointType.DeviceAuthorization => await client.PostAsync("/connect/device", new OpenIddictRequest + { + ClientId = "Fabrikam" + }), + + OpenIddictServerEndpointType.PushedAuthorization => await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code + }), + + OpenIddictServerEndpointType.Revocation => await client.PostAsync("/connect/revoke", new OpenIddictRequest + { + ClientId = "Fabrikam", + Token = "SlAV32hkKG", + TokenTypeHint = TokenTypeHints.RefreshToken + }), + + OpenIddictServerEndpointType.Token => await client.PostAsync("/connect/token", new OpenIddictRequest + { + ClientId = "Fabrikam", + GrantType = GrantTypes.Password, + Username = "johndoe", + Password = "A3ddj3w" + }), + + _ => throw new NotSupportedException() + }; + + // Assert + Assert.Equal(Errors.InvalidClient, response.Error); + Assert.Equal(SR.GetResourceString(SR.ID2196), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2196), response.ErrorUri); + + Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); + Mock.Get(manager).Verify(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny()), Times.Once()); + } +#endif + + [Theory] + [InlineData(OpenIddictServerEndpointType.DeviceAuthorization)] + [InlineData(OpenIddictServerEndpointType.Introspection)] + [InlineData(OpenIddictServerEndpointType.PushedAuthorization)] + [InlineData(OpenIddictServerEndpointType.Revocation)] + [InlineData(OpenIddictServerEndpointType.Token)] + public async Task ProcessAuthentication_ClientAuthenticationIsRequiredForNonPublicClients(OpenIddictServerEndpointType type) + { + // Arrange + var application = new OpenIddictApplication(); + + var manager = CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + mock.Setup(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny())) + .ReturnsAsync(false); + }); + + await using var server = await CreateServerAsync(options => + { + options.Services.AddSingleton(manager); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = type switch + { + OpenIddictServerEndpointType.Introspection => await client.PostAsync("/connect/introspect", new OpenIddictRequest + { + ClientAssertion = null, + ClientAssertionType = null, + ClientId = "Fabrikam", + ClientSecret = null, + Token = "2YotnFZFEjr1zCsicMWpAA" + }), + + OpenIddictServerEndpointType.DeviceAuthorization => await client.PostAsync("/connect/device", new OpenIddictRequest + { + ClientAssertion = null, + ClientAssertionType = null, + ClientId = "Fabrikam", + ClientSecret = null + }), + + OpenIddictServerEndpointType.PushedAuthorization => await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientAssertion = null, + ClientAssertionType = null, + ClientId = "Fabrikam", + ClientSecret = null, + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code + }), + + OpenIddictServerEndpointType.Revocation => await client.PostAsync("/connect/revoke", new OpenIddictRequest + { + ClientAssertion = null, + ClientAssertionType = null, + ClientId = "Fabrikam", + ClientSecret = null, + Token = "SlAV32hkKG", + TokenTypeHint = TokenTypeHints.RefreshToken + }), + + OpenIddictServerEndpointType.Token => await client.PostAsync("/connect/token", new OpenIddictRequest + { + ClientAssertion = null, + ClientAssertionType = null, + ClientId = "Fabrikam", + ClientSecret = null, + GrantType = GrantTypes.Password, + Username = "johndoe", + Password = "A3ddj3w" + }), + + _ => throw new NotSupportedException() + }; + + // Assert + Assert.Equal(Errors.InvalidClient, response.Error); + Assert.Equal(SR.GetResourceString(SR.ID2198), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2198), response.ErrorUri); + + Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); + Mock.Get(manager).Verify(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny()), Times.Once()); + } + + [Theory] + [InlineData(OpenIddictServerEndpointType.Introspection)] + [InlineData(OpenIddictServerEndpointType.PushedAuthorization)] + [InlineData(OpenIddictServerEndpointType.Revocation)] + [InlineData(OpenIddictServerEndpointType.Token)] + public async Task ProcessAuthentication_RequestIsRejectedWhenClientSecretIsInvalid(OpenIddictServerEndpointType type) + { + // Arrange + var application = new OpenIddictApplication(); + + var manager = CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + mock.Setup(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny())) + .ReturnsAsync(false); + + mock.Setup(manager => manager.ValidateClientSecretAsync(application, "7Fjfp0ZBr1KtDRbnfVdmIw", It.IsAny())) + .ReturnsAsync(false); + }); + + await using var server = await CreateServerAsync(options => + { + options.Services.AddSingleton(manager); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = type switch + { + OpenIddictServerEndpointType.Introspection => await client.PostAsync("/connect/introspect", new OpenIddictRequest + { + ClientId = "Fabrikam", + ClientSecret = "7Fjfp0ZBr1KtDRbnfVdmIw", + Token = "2YotnFZFEjr1zCsicMWpAA" + }), + + OpenIddictServerEndpointType.PushedAuthorization => await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + ClientSecret = "7Fjfp0ZBr1KtDRbnfVdmIw", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code + }), + + OpenIddictServerEndpointType.Revocation => await client.PostAsync("/connect/revoke", new OpenIddictRequest + { + ClientId = "Fabrikam", + ClientSecret = "7Fjfp0ZBr1KtDRbnfVdmIw", + Token = "SlAV32hkKG", + TokenTypeHint = TokenTypeHints.RefreshToken + }), + + OpenIddictServerEndpointType.Token => await client.PostAsync("/connect/token", new OpenIddictRequest + { + ClientId = "Fabrikam", + ClientSecret = "7Fjfp0ZBr1KtDRbnfVdmIw", + GrantType = GrantTypes.Password, + Username = "johndoe", + Password = "A3ddj3w" + }), + + _ => throw new NotSupportedException() + }; + + // Assert + Assert.Equal(Errors.InvalidClient, response.Error); + Assert.Equal(SR.GetResourceString(SR.ID2055), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2055), response.ErrorUri); + + Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); + Mock.Get(manager).Verify(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny()), Times.AtLeastOnce()); + Mock.Get(manager).Verify(manager => manager.ValidateClientSecretAsync(application, "7Fjfp0ZBr1KtDRbnfVdmIw", It.IsAny()), Times.Once()); + } + +#if SUPPORTS_X509_CHAIN_POLICY_CUSTOM_TRUST_STORE + [Theory] + [InlineData(OpenIddictServerEndpointType.DeviceAuthorization)] + [InlineData(OpenIddictServerEndpointType.Introspection)] + [InlineData(OpenIddictServerEndpointType.PushedAuthorization)] + [InlineData(OpenIddictServerEndpointType.Revocation)] + [InlineData(OpenIddictServerEndpointType.Token)] + public async Task ProcessAuthentication_ThrowsAnExceptionWhenGlobalSelfSignedChainPolicyIsNull(OpenIddictServerEndpointType type) + { + // Arrange + var application = new OpenIddictApplication(); + + var manager = CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + mock.Setup(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny())) + .ReturnsAsync(false); + }); + + await using var server = await CreateServerAsync(options => + { + options.AddEventHandler(builder => + { + builder.UseInlineHandler(context => + { + context.ClientCertificate = X509Certificate2.CreateFromPem($""" + -----BEGIN CERTIFICATE----- + MIIC8jCCAdqgAwIBAgIIYfcknj8KXN0wDQYJKoZIhvcNAQELBQAwIjEgMB4GA1UE + AxMXU2VsZi1zaWduZWQgY2VydGlmaWNhdGUwIBcNMjYwMjAxMTc1MjI2WhgPMjEy + NjAyMDExNzUyMjZaMCIxIDAeBgNVBAMTF1NlbGYtc2lnbmVkIGNlcnRpZmljYXRl + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA45uKd5cdlLmEBGLDEB75 + o9e3/kMmQjVhhMeBsy4m2t7zw5jZo7OPcahXiXZHttom9tJm7BWPWvYx7p0N9+ss + h/E5lzKyV7ZXg+mM+KeECtVhiy+82BuIPelCshrpaV3lIg93y47FYLIWXxdggjt6 + 6VUaxzlTeo+IpuMz8IssL7VpJnjCT5NmqPNVkv1VR1uuetVqP7546ZFw31RiGl/0 + I1uUlb7SwLwhLUK1iyLmGNA3VDB0m0DvLmlIEY3ZE5zxQp/Rxq6DfjbXm2LWJyu6 + NO7k7JixXOorEl+6HdJZHTWNFK5jCo2ZZAwWn+uUuzgmLILPJFLDVutXjuEZpsym + uQIDAQABoyowKDAOBgNVHQ8BAf8EBAMCB4AwFgYDVR0lAQH/BAwwCgYIKwYBBQUH + AwIwDQYJKoZIhvcNAQELBQADggEBAGHH3f/bkfViTvPE7yXJkB0bs88mYxajluMA + hgihEN5joPT6zHxMLBND2sitIozCMeeaj0rg+OaT/zDBgOLup/BM92UaPpYcgDCy + 3tHqZLOOJOR4aYnHhIQUnx+NRtKEM4q/hL/xLHeliKmV7TQXISEZlTbb0gOU7TFp + nJlP60Vo9F/WD6xcKNxBgV5aB/+2FjiTTw2pF0VUmvcZdQAN5ysfrmKNXbvv1oCp + AohiwRiPrwe3mJ8iCqzEY/qQqImEiIT8WC2Fty+UYyBwfXMObi1AO++QkaMUbUJl + 0aBuRoD85FLotjHIHXkFHERjOolheYdKt5nrGCCz/PmXBfsSCTo= + -----END CERTIFICATE----- + """); + + return ValueTask.CompletedTask; + }); + + builder.SetOrder(ValidateClientType.Descriptor.Order - 500); + }); + + options.Services.AddSingleton(manager); + }); + + await using var client = await server.CreateClientAsync(); + + // Act and assert + var exception = type switch + { + OpenIddictServerEndpointType.Introspection => + await Assert.ThrowsAsync(() => client.PostAsync("/connect/introspect", new OpenIddictRequest + { + ClientId = "Fabrikam", + Token = "2YotnFZFEjr1zCsicMWpAA" + })), + + OpenIddictServerEndpointType.DeviceAuthorization => + await Assert.ThrowsAsync(() => client.PostAsync("/connect/device", new OpenIddictRequest + { + ClientId = "Fabrikam" + })), + + OpenIddictServerEndpointType.PushedAuthorization => + await Assert.ThrowsAsync(() => client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code + })), + + OpenIddictServerEndpointType.Revocation => + await Assert.ThrowsAsync(() => client.PostAsync("/connect/revoke", new OpenIddictRequest + { + ClientId = "Fabrikam", + Token = "SlAV32hkKG", + TokenTypeHint = TokenTypeHints.RefreshToken + })), + + OpenIddictServerEndpointType.Token => + await Assert.ThrowsAsync(() => client.PostAsync("/connect/token", new OpenIddictRequest + { + ClientId = "Fabrikam", + GrantType = GrantTypes.Password, + Username = "johndoe", + Password = "A3ddj3w" + })), + + _ => throw new NotSupportedException() + }; + + Assert.Equal(SR.GetResourceString(SR.ID0506), exception.Message); + + Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); + Mock.Get(manager).Verify(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny()), Times.AtLeastOnce()); + } + + [Theory] + [InlineData(OpenIddictServerEndpointType.DeviceAuthorization)] + [InlineData(OpenIddictServerEndpointType.Introspection)] + [InlineData(OpenIddictServerEndpointType.PushedAuthorization)] + [InlineData(OpenIddictServerEndpointType.Revocation)] + [InlineData(OpenIddictServerEndpointType.Token)] + public async Task ProcessAuthentication_SelfSignedClientCertificateIsRejectedWhenChainPolicyIsNull(OpenIddictServerEndpointType type) + { + // Arrange + var application = new OpenIddictApplication(); + + var manager = CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + mock.Setup(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny())) + .ReturnsAsync(false); + + mock.Setup(manager => manager.GetSelfSignedClientCertificateChainPolicyAsync(application, It.IsAny(), It.IsAny())) + .ReturnsAsync(value: null); + }); + + await using var server = await CreateServerAsync(options => + { + options.Configure(options => options.SelfSignedClientCertificateChainPolicy = new X509ChainPolicy()); + + options.AddEventHandler(builder => + { + builder.UseInlineHandler(context => + { + context.ClientCertificate = X509Certificate2.CreateFromPem($""" + -----BEGIN CERTIFICATE----- + MIIC8jCCAdqgAwIBAgIIYfcknj8KXN0wDQYJKoZIhvcNAQELBQAwIjEgMB4GA1UE + AxMXU2VsZi1zaWduZWQgY2VydGlmaWNhdGUwIBcNMjYwMjAxMTc1MjI2WhgPMjEy + NjAyMDExNzUyMjZaMCIxIDAeBgNVBAMTF1NlbGYtc2lnbmVkIGNlcnRpZmljYXRl + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA45uKd5cdlLmEBGLDEB75 + o9e3/kMmQjVhhMeBsy4m2t7zw5jZo7OPcahXiXZHttom9tJm7BWPWvYx7p0N9+ss + h/E5lzKyV7ZXg+mM+KeECtVhiy+82BuIPelCshrpaV3lIg93y47FYLIWXxdggjt6 + 6VUaxzlTeo+IpuMz8IssL7VpJnjCT5NmqPNVkv1VR1uuetVqP7546ZFw31RiGl/0 + I1uUlb7SwLwhLUK1iyLmGNA3VDB0m0DvLmlIEY3ZE5zxQp/Rxq6DfjbXm2LWJyu6 + NO7k7JixXOorEl+6HdJZHTWNFK5jCo2ZZAwWn+uUuzgmLILPJFLDVutXjuEZpsym + uQIDAQABoyowKDAOBgNVHQ8BAf8EBAMCB4AwFgYDVR0lAQH/BAwwCgYIKwYBBQUH + AwIwDQYJKoZIhvcNAQELBQADggEBAGHH3f/bkfViTvPE7yXJkB0bs88mYxajluMA + hgihEN5joPT6zHxMLBND2sitIozCMeeaj0rg+OaT/zDBgOLup/BM92UaPpYcgDCy + 3tHqZLOOJOR4aYnHhIQUnx+NRtKEM4q/hL/xLHeliKmV7TQXISEZlTbb0gOU7TFp + nJlP60Vo9F/WD6xcKNxBgV5aB/+2FjiTTw2pF0VUmvcZdQAN5ysfrmKNXbvv1oCp + AohiwRiPrwe3mJ8iCqzEY/qQqImEiIT8WC2Fty+UYyBwfXMObi1AO++QkaMUbUJl + 0aBuRoD85FLotjHIHXkFHERjOolheYdKt5nrGCCz/PmXBfsSCTo= + -----END CERTIFICATE----- + """); + + return ValueTask.CompletedTask; + }); + + builder.SetOrder(ValidateClientType.Descriptor.Order - 500); + }); + + options.Services.AddSingleton(manager); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = type switch + { + OpenIddictServerEndpointType.Introspection => await client.PostAsync("/connect/introspect", new OpenIddictRequest + { + ClientId = "Fabrikam", + Token = "2YotnFZFEjr1zCsicMWpAA" + }), + + OpenIddictServerEndpointType.DeviceAuthorization => await client.PostAsync("/connect/device", new OpenIddictRequest + { + ClientId = "Fabrikam" + }), + + OpenIddictServerEndpointType.PushedAuthorization => await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code + }), + + OpenIddictServerEndpointType.Revocation => await client.PostAsync("/connect/revoke", new OpenIddictRequest + { + ClientId = "Fabrikam", + Token = "SlAV32hkKG", + TokenTypeHint = TokenTypeHints.RefreshToken + }), + + OpenIddictServerEndpointType.Token => await client.PostAsync("/connect/token", new OpenIddictRequest + { + ClientId = "Fabrikam", + GrantType = GrantTypes.Password, + Username = "johndoe", + Password = "A3ddj3w" + }), + + _ => throw new NotSupportedException() + }; + + // Assert + Assert.Equal(Errors.InvalidClient, response.Error); + Assert.Equal(SR.GetResourceString(SR.ID2197), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2197), response.ErrorUri); + + Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); + Mock.Get(manager).Verify(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny()), Times.AtLeastOnce()); + Mock.Get(manager).Verify(manager => manager.GetSelfSignedClientCertificateChainPolicyAsync(application, It.IsAny(), It.IsAny()), Times.Once()); + } + + [Theory] + [InlineData(OpenIddictServerEndpointType.DeviceAuthorization)] + [InlineData(OpenIddictServerEndpointType.Introspection)] + [InlineData(OpenIddictServerEndpointType.PushedAuthorization)] + [InlineData(OpenIddictServerEndpointType.Revocation)] + [InlineData(OpenIddictServerEndpointType.Token)] + public async Task ProcessAuthentication_SelfSignedClientCertificateIsRejectedWhenInvalid(OpenIddictServerEndpointType type) + { + // Arrange + var application = new OpenIddictApplication(); + var policy = new X509ChainPolicy(); + var certificate = X509Certificate2.CreateFromPem($""" + -----BEGIN CERTIFICATE----- + MIIC8jCCAdqgAwIBAgIIYfcknj8KXN0wDQYJKoZIhvcNAQELBQAwIjEgMB4GA1UE + AxMXU2VsZi1zaWduZWQgY2VydGlmaWNhdGUwIBcNMjYwMjAxMTc1MjI2WhgPMjEy + NjAyMDExNzUyMjZaMCIxIDAeBgNVBAMTF1NlbGYtc2lnbmVkIGNlcnRpZmljYXRl + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA45uKd5cdlLmEBGLDEB75 + o9e3/kMmQjVhhMeBsy4m2t7zw5jZo7OPcahXiXZHttom9tJm7BWPWvYx7p0N9+ss + h/E5lzKyV7ZXg+mM+KeECtVhiy+82BuIPelCshrpaV3lIg93y47FYLIWXxdggjt6 + 6VUaxzlTeo+IpuMz8IssL7VpJnjCT5NmqPNVkv1VR1uuetVqP7546ZFw31RiGl/0 + I1uUlb7SwLwhLUK1iyLmGNA3VDB0m0DvLmlIEY3ZE5zxQp/Rxq6DfjbXm2LWJyu6 + NO7k7JixXOorEl+6HdJZHTWNFK5jCo2ZZAwWn+uUuzgmLILPJFLDVutXjuEZpsym + uQIDAQABoyowKDAOBgNVHQ8BAf8EBAMCB4AwFgYDVR0lAQH/BAwwCgYIKwYBBQUH + AwIwDQYJKoZIhvcNAQELBQADggEBAGHH3f/bkfViTvPE7yXJkB0bs88mYxajluMA + hgihEN5joPT6zHxMLBND2sitIozCMeeaj0rg+OaT/zDBgOLup/BM92UaPpYcgDCy + 3tHqZLOOJOR4aYnHhIQUnx+NRtKEM4q/hL/xLHeliKmV7TQXISEZlTbb0gOU7TFp + nJlP60Vo9F/WD6xcKNxBgV5aB/+2FjiTTw2pF0VUmvcZdQAN5ysfrmKNXbvv1oCp + AohiwRiPrwe3mJ8iCqzEY/qQqImEiIT8WC2Fty+UYyBwfXMObi1AO++QkaMUbUJl + 0aBuRoD85FLotjHIHXkFHERjOolheYdKt5nrGCCz/PmXBfsSCTo= + -----END CERTIFICATE----- + """); + + var manager = CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + mock.Setup(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny())) + .ReturnsAsync(false); + + mock.Setup(manager => manager.GetSelfSignedClientCertificateChainPolicyAsync(application, It.IsAny(), It.IsAny())) + .ReturnsAsync(policy); + + mock.Setup(manager => manager.ValidateSelfSignedClientCertificateAsync(application, certificate, policy, It.IsAny())) + .ReturnsAsync(false); + }); + + await using var server = await CreateServerAsync(options => + { + options.Configure(options => options.SelfSignedClientCertificateChainPolicy = new X509ChainPolicy()); + + options.AddEventHandler(builder => + { + builder.UseInlineHandler(context => + { + context.ClientCertificate = certificate; + + return ValueTask.CompletedTask; + }); + + builder.SetOrder(ValidateClientType.Descriptor.Order - 500); + }); + + options.Services.AddSingleton(manager); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = type switch + { + OpenIddictServerEndpointType.Introspection => await client.PostAsync("/connect/introspect", new OpenIddictRequest + { + ClientId = "Fabrikam", + Token = "2YotnFZFEjr1zCsicMWpAA" + }), + + OpenIddictServerEndpointType.DeviceAuthorization => await client.PostAsync("/connect/device", new OpenIddictRequest + { + ClientId = "Fabrikam" + }), + + OpenIddictServerEndpointType.PushedAuthorization => await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code + }), + + OpenIddictServerEndpointType.Revocation => await client.PostAsync("/connect/revoke", new OpenIddictRequest + { + ClientId = "Fabrikam", + Token = "SlAV32hkKG", + TokenTypeHint = TokenTypeHints.RefreshToken + }), + + OpenIddictServerEndpointType.Token => await client.PostAsync("/connect/token", new OpenIddictRequest + { + ClientId = "Fabrikam", + GrantType = GrantTypes.Password, + Username = "johndoe", + Password = "A3ddj3w" + }), + + _ => throw new NotSupportedException() + }; + + // Assert + Assert.Equal(Errors.InvalidClient, response.Error); + Assert.Equal(SR.GetResourceString(SR.ID2197), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2197), response.ErrorUri); + + Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); + Mock.Get(manager).Verify(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny()), Times.AtLeastOnce()); + Mock.Get(manager).Verify(manager => manager.GetSelfSignedClientCertificateChainPolicyAsync(application, It.IsAny(), It.IsAny()), Times.Once()); + Mock.Get(manager).Verify(manager => manager.ValidateSelfSignedClientCertificateAsync(application, certificate, policy, It.IsAny()), Times.Once()); + } + + [Theory] + [InlineData(OpenIddictServerEndpointType.DeviceAuthorization)] + [InlineData(OpenIddictServerEndpointType.Introspection)] + [InlineData(OpenIddictServerEndpointType.PushedAuthorization)] + [InlineData(OpenIddictServerEndpointType.Revocation)] + [InlineData(OpenIddictServerEndpointType.Token)] + public async Task ProcessAuthentication_ThrowsAnExceptionWhenGlobalPkiChainPolicyIsNull(OpenIddictServerEndpointType type) + { + // Arrange + var application = new OpenIddictApplication(); + + var manager = CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + mock.Setup(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny())) + .ReturnsAsync(false); + }); + + await using var server = await CreateServerAsync(options => + { + options.AddEventHandler(builder => + { + builder.UseInlineHandler(context => + { + context.ClientCertificate = X509Certificate2.CreateFromPem($""" + -----BEGIN CERTIFICATE----- + MIIEejCCAmKgAwIBAgIQTmWjGbWFVbvtnm57bOVMZDANBgkqhkiG9w0BAQsFADAa + MRgwFgYDVQQDEw9JbnRlcm1lZGlhdGUgQ0EwIBcNMjYwMjAxMDMwNTIwWhgPMjEy + NjAyMDIwMzA1MjBaMBoxGDAWBgNVBAMTD0VuZCBjZXJ0aWZpY2F0ZTCCASIwDQYJ + KoZIhvcNAQEBBQADggEPADCCAQoCggEBAJZ++ilBjvvOBIkZehV2T5dIDTpggJPV + s+6/R2/6zMa0ykzEqaIbuvhhCaY0tDzebTYqUu9omlZkvx4jhxyA44lhwJsElqMx + ANxsfSNUucSiJdOWXaVCQs8hLWn9ATfflE+qJNFx8zZq4nqmfkj8DMQwsej3+Ilo + +FwdV1/2gyifMR0TOb/iZsgh+d386B4hIK94REZbyZ4Diod13VkDPY7I9LD8hBy7 + 8jU5SflnHdFmG4I1IQmZcSGWfrCl0PIFymHkooeXwUm5sBebZl970908DYNB95g6 + Fj4wBxNdrhm8Ty4DHZSikOyt/kmbrdcc8OEVYSFlaSF7QDw6X73pEYUCAwEAAaOB + uTCBtjAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8EDDAK + BggrBgEFBQcDAjAdBgNVHQ4EFgQU/b5TO/v+uy1JhF2wYpYNrTBQwqgwSQYDVR0j + BEIwQIAUQG7qZEGkePGiBl7n9npI/BZ1MN6hFqQUMBIxEDAOBgNVBAMTB1Jvb3Qg + Q0GCEEZVlMw4Ppnm5GIPGl2I1nwwFAYDVR0RBA0wC4IJbG9jYWxob3N0MA0GCSqG + SIb3DQEBCwUAA4ICAQB/GDA+YullFclspzw/9d3jFgorKJdISB1BKZyWD5tuQ5fP + WX4MOsgnjylbK8wGfG1wW2NJfKc/lWYzoxWtQDDCq9kVgUvd4JBlqR3w6reowDeE + jn/KGsArtjMMv0xvVnP8Wux2GLL2RYRUq6EpcQycN9/uoVyp+JRnxT6vK0y4QzYH + G7C6z/JfoAp7UnS61Be9VlcW1I2H5WiHuzuMG4IrMPdTGJDftSJYfXaBMzAnXdSY + 4BP80LsPbj1Jfuz+7tvrIO3gPmBJEprN1g0dKbcWPMRA867xLkQIQTiSnrVFADvr + UxO2G8KX/Yn7n5c68MfEHhFi9ndeijQfe7awG0aQjWX/XbaPpbaOAXxcozpzPIT/ + UlciCDCofpr62BdOWJJ6XQLyx5lRg9XcB6TwRsvx9zRW434iGBmGdLMlqHVN7I2v + /kKNWEzOOa4hphG9OCyg3ZOcArCslUiwwfUGe8cOMKf8O63+NY6UUyU5S50EzsdN + 5nAK3WkMijGiMbReB/5oCLbU/B9hgEghKcbd3X2QY21MBg+GCB1z9aPduKtleAQm + WExEzLEnb3Kwfr5+O84J3DXisQLG8CO3T9c9uz4Tp95LIdUD4v386dGO/nYnHcZ8 + 8Fpjqr/MfTyPgOnMusa3yKlAMypPRmtkhkxOk4olCaT6WDRQtoKk/RisREU8nw== + -----END CERTIFICATE----- + """); + + return ValueTask.CompletedTask; + }); + + builder.SetOrder(ValidateClientType.Descriptor.Order - 500); + }); + + options.Services.AddSingleton(manager); + }); + + await using var client = await server.CreateClientAsync(); + + // Act and assert + var exception = type switch + { + OpenIddictServerEndpointType.Introspection => + await Assert.ThrowsAsync(() => client.PostAsync("/connect/introspect", new OpenIddictRequest + { + ClientId = "Fabrikam", + Token = "2YotnFZFEjr1zCsicMWpAA" + })), + + OpenIddictServerEndpointType.DeviceAuthorization => + await Assert.ThrowsAsync(() => client.PostAsync("/connect/device", new OpenIddictRequest + { + ClientId = "Fabrikam" + })), + + OpenIddictServerEndpointType.PushedAuthorization => + await Assert.ThrowsAsync(() => client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code + })), + + OpenIddictServerEndpointType.Revocation => + await Assert.ThrowsAsync(() => client.PostAsync("/connect/revoke", new OpenIddictRequest + { + ClientId = "Fabrikam", + Token = "SlAV32hkKG", + TokenTypeHint = TokenTypeHints.RefreshToken + })), + + OpenIddictServerEndpointType.Token => + await Assert.ThrowsAsync(() => client.PostAsync("/connect/token", new OpenIddictRequest + { + ClientId = "Fabrikam", + GrantType = GrantTypes.Password, + Username = "johndoe", + Password = "A3ddj3w" + })), + + _ => throw new NotSupportedException() + }; + + Assert.Equal(SR.GetResourceString(SR.ID0505), exception.Message); + + Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); + Mock.Get(manager).Verify(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny()), Times.AtLeastOnce()); + } + + [Theory] + [InlineData(OpenIddictServerEndpointType.DeviceAuthorization)] + [InlineData(OpenIddictServerEndpointType.Introspection)] + [InlineData(OpenIddictServerEndpointType.PushedAuthorization)] + [InlineData(OpenIddictServerEndpointType.Revocation)] + [InlineData(OpenIddictServerEndpointType.Token)] + public async Task ProcessAuthentication_PkiClientCertificateIsRejectedWhenChainPolicyIsNull(OpenIddictServerEndpointType type) + { + // Arrange + var application = new OpenIddictApplication(); + + var manager = CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + mock.Setup(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny())) + .ReturnsAsync(false); + + mock.Setup(manager => manager.GetClientCertificateChainPolicyAsync(application, It.IsAny(), It.IsAny())) + .ReturnsAsync(value: null); + }); + + await using var server = await CreateServerAsync(options => + { + options.Configure(options => options.ClientCertificateChainPolicy = new X509ChainPolicy()); + + options.AddEventHandler(builder => + { + builder.UseInlineHandler(context => + { + context.ClientCertificate = X509Certificate2.CreateFromPem($""" + -----BEGIN CERTIFICATE----- + MIIEejCCAmKgAwIBAgIQTmWjGbWFVbvtnm57bOVMZDANBgkqhkiG9w0BAQsFADAa + MRgwFgYDVQQDEw9JbnRlcm1lZGlhdGUgQ0EwIBcNMjYwMjAxMDMwNTIwWhgPMjEy + NjAyMDIwMzA1MjBaMBoxGDAWBgNVBAMTD0VuZCBjZXJ0aWZpY2F0ZTCCASIwDQYJ + KoZIhvcNAQEBBQADggEPADCCAQoCggEBAJZ++ilBjvvOBIkZehV2T5dIDTpggJPV + s+6/R2/6zMa0ykzEqaIbuvhhCaY0tDzebTYqUu9omlZkvx4jhxyA44lhwJsElqMx + ANxsfSNUucSiJdOWXaVCQs8hLWn9ATfflE+qJNFx8zZq4nqmfkj8DMQwsej3+Ilo + +FwdV1/2gyifMR0TOb/iZsgh+d386B4hIK94REZbyZ4Diod13VkDPY7I9LD8hBy7 + 8jU5SflnHdFmG4I1IQmZcSGWfrCl0PIFymHkooeXwUm5sBebZl970908DYNB95g6 + Fj4wBxNdrhm8Ty4DHZSikOyt/kmbrdcc8OEVYSFlaSF7QDw6X73pEYUCAwEAAaOB + uTCBtjAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8EDDAK + BggrBgEFBQcDAjAdBgNVHQ4EFgQU/b5TO/v+uy1JhF2wYpYNrTBQwqgwSQYDVR0j + BEIwQIAUQG7qZEGkePGiBl7n9npI/BZ1MN6hFqQUMBIxEDAOBgNVBAMTB1Jvb3Qg + Q0GCEEZVlMw4Ppnm5GIPGl2I1nwwFAYDVR0RBA0wC4IJbG9jYWxob3N0MA0GCSqG + SIb3DQEBCwUAA4ICAQB/GDA+YullFclspzw/9d3jFgorKJdISB1BKZyWD5tuQ5fP + WX4MOsgnjylbK8wGfG1wW2NJfKc/lWYzoxWtQDDCq9kVgUvd4JBlqR3w6reowDeE + jn/KGsArtjMMv0xvVnP8Wux2GLL2RYRUq6EpcQycN9/uoVyp+JRnxT6vK0y4QzYH + G7C6z/JfoAp7UnS61Be9VlcW1I2H5WiHuzuMG4IrMPdTGJDftSJYfXaBMzAnXdSY + 4BP80LsPbj1Jfuz+7tvrIO3gPmBJEprN1g0dKbcWPMRA867xLkQIQTiSnrVFADvr + UxO2G8KX/Yn7n5c68MfEHhFi9ndeijQfe7awG0aQjWX/XbaPpbaOAXxcozpzPIT/ + UlciCDCofpr62BdOWJJ6XQLyx5lRg9XcB6TwRsvx9zRW434iGBmGdLMlqHVN7I2v + /kKNWEzOOa4hphG9OCyg3ZOcArCslUiwwfUGe8cOMKf8O63+NY6UUyU5S50EzsdN + 5nAK3WkMijGiMbReB/5oCLbU/B9hgEghKcbd3X2QY21MBg+GCB1z9aPduKtleAQm + WExEzLEnb3Kwfr5+O84J3DXisQLG8CO3T9c9uz4Tp95LIdUD4v386dGO/nYnHcZ8 + 8Fpjqr/MfTyPgOnMusa3yKlAMypPRmtkhkxOk4olCaT6WDRQtoKk/RisREU8nw== + -----END CERTIFICATE----- + """); + + return ValueTask.CompletedTask; + }); + + builder.SetOrder(ValidateClientType.Descriptor.Order - 500); + }); + + options.Services.AddSingleton(manager); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = type switch + { + OpenIddictServerEndpointType.Introspection => await client.PostAsync("/connect/introspect", new OpenIddictRequest + { + ClientId = "Fabrikam", + Token = "2YotnFZFEjr1zCsicMWpAA" + }), + + OpenIddictServerEndpointType.DeviceAuthorization => await client.PostAsync("/connect/device", new OpenIddictRequest + { + ClientId = "Fabrikam" + }), + + OpenIddictServerEndpointType.PushedAuthorization => await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code + }), + + OpenIddictServerEndpointType.Revocation => await client.PostAsync("/connect/revoke", new OpenIddictRequest + { + ClientId = "Fabrikam", + Token = "SlAV32hkKG", + TokenTypeHint = TokenTypeHints.RefreshToken + }), + + OpenIddictServerEndpointType.Token => await client.PostAsync("/connect/token", new OpenIddictRequest + { + ClientId = "Fabrikam", + GrantType = GrantTypes.Password, + Username = "johndoe", + Password = "A3ddj3w" + }), + + _ => throw new NotSupportedException() + }; + + // Assert + Assert.Equal(Errors.InvalidClient, response.Error); + Assert.Equal(SR.GetResourceString(SR.ID2197), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2197), response.ErrorUri); + + Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); + Mock.Get(manager).Verify(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny()), Times.AtLeastOnce()); + Mock.Get(manager).Verify(manager => manager.GetClientCertificateChainPolicyAsync(application, It.IsAny(), It.IsAny()), Times.Once()); + } + + [Theory] + [InlineData(OpenIddictServerEndpointType.DeviceAuthorization)] + [InlineData(OpenIddictServerEndpointType.Introspection)] + [InlineData(OpenIddictServerEndpointType.PushedAuthorization)] + [InlineData(OpenIddictServerEndpointType.Revocation)] + [InlineData(OpenIddictServerEndpointType.Token)] + public async Task ProcessAuthentication_PkiClientCertificateIsRejectedWhenInvalid(OpenIddictServerEndpointType type) + { + // Arrange + var application = new OpenIddictApplication(); + var policy = new X509ChainPolicy(); + var certificate = X509Certificate2.CreateFromPem($""" + -----BEGIN CERTIFICATE----- + MIIEejCCAmKgAwIBAgIQTmWjGbWFVbvtnm57bOVMZDANBgkqhkiG9w0BAQsFADAa + MRgwFgYDVQQDEw9JbnRlcm1lZGlhdGUgQ0EwIBcNMjYwMjAxMDMwNTIwWhgPMjEy + NjAyMDIwMzA1MjBaMBoxGDAWBgNVBAMTD0VuZCBjZXJ0aWZpY2F0ZTCCASIwDQYJ + KoZIhvcNAQEBBQADggEPADCCAQoCggEBAJZ++ilBjvvOBIkZehV2T5dIDTpggJPV + s+6/R2/6zMa0ykzEqaIbuvhhCaY0tDzebTYqUu9omlZkvx4jhxyA44lhwJsElqMx + ANxsfSNUucSiJdOWXaVCQs8hLWn9ATfflE+qJNFx8zZq4nqmfkj8DMQwsej3+Ilo + +FwdV1/2gyifMR0TOb/iZsgh+d386B4hIK94REZbyZ4Diod13VkDPY7I9LD8hBy7 + 8jU5SflnHdFmG4I1IQmZcSGWfrCl0PIFymHkooeXwUm5sBebZl970908DYNB95g6 + Fj4wBxNdrhm8Ty4DHZSikOyt/kmbrdcc8OEVYSFlaSF7QDw6X73pEYUCAwEAAaOB + uTCBtjAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8EDDAK + BggrBgEFBQcDAjAdBgNVHQ4EFgQU/b5TO/v+uy1JhF2wYpYNrTBQwqgwSQYDVR0j + BEIwQIAUQG7qZEGkePGiBl7n9npI/BZ1MN6hFqQUMBIxEDAOBgNVBAMTB1Jvb3Qg + Q0GCEEZVlMw4Ppnm5GIPGl2I1nwwFAYDVR0RBA0wC4IJbG9jYWxob3N0MA0GCSqG + SIb3DQEBCwUAA4ICAQB/GDA+YullFclspzw/9d3jFgorKJdISB1BKZyWD5tuQ5fP + WX4MOsgnjylbK8wGfG1wW2NJfKc/lWYzoxWtQDDCq9kVgUvd4JBlqR3w6reowDeE + jn/KGsArtjMMv0xvVnP8Wux2GLL2RYRUq6EpcQycN9/uoVyp+JRnxT6vK0y4QzYH + G7C6z/JfoAp7UnS61Be9VlcW1I2H5WiHuzuMG4IrMPdTGJDftSJYfXaBMzAnXdSY + 4BP80LsPbj1Jfuz+7tvrIO3gPmBJEprN1g0dKbcWPMRA867xLkQIQTiSnrVFADvr + UxO2G8KX/Yn7n5c68MfEHhFi9ndeijQfe7awG0aQjWX/XbaPpbaOAXxcozpzPIT/ + UlciCDCofpr62BdOWJJ6XQLyx5lRg9XcB6TwRsvx9zRW434iGBmGdLMlqHVN7I2v + /kKNWEzOOa4hphG9OCyg3ZOcArCslUiwwfUGe8cOMKf8O63+NY6UUyU5S50EzsdN + 5nAK3WkMijGiMbReB/5oCLbU/B9hgEghKcbd3X2QY21MBg+GCB1z9aPduKtleAQm + WExEzLEnb3Kwfr5+O84J3DXisQLG8CO3T9c9uz4Tp95LIdUD4v386dGO/nYnHcZ8 + 8Fpjqr/MfTyPgOnMusa3yKlAMypPRmtkhkxOk4olCaT6WDRQtoKk/RisREU8nw== + -----END CERTIFICATE----- + """); + + var manager = CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + mock.Setup(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny())) + .ReturnsAsync(false); + + mock.Setup(manager => manager.GetClientCertificateChainPolicyAsync(application, It.IsAny(), It.IsAny())) + .ReturnsAsync(policy); + + mock.Setup(manager => manager.ValidateClientCertificateAsync(application, certificate, policy, It.IsAny())) + .ReturnsAsync(false); + }); + + await using var server = await CreateServerAsync(options => + { + options.Configure(options => options.ClientCertificateChainPolicy = new X509ChainPolicy()); + + options.AddEventHandler(builder => + { + builder.UseInlineHandler(context => + { + context.ClientCertificate = certificate; + + return ValueTask.CompletedTask; + }); + + builder.SetOrder(ValidateClientType.Descriptor.Order - 500); + }); + + options.Services.AddSingleton(manager); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = type switch + { + OpenIddictServerEndpointType.Introspection => await client.PostAsync("/connect/introspect", new OpenIddictRequest + { + ClientId = "Fabrikam", + Token = "2YotnFZFEjr1zCsicMWpAA" + }), + + OpenIddictServerEndpointType.DeviceAuthorization => await client.PostAsync("/connect/device", new OpenIddictRequest + { + ClientId = "Fabrikam" + }), + + OpenIddictServerEndpointType.PushedAuthorization => await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code + }), + + OpenIddictServerEndpointType.Revocation => await client.PostAsync("/connect/revoke", new OpenIddictRequest + { + ClientId = "Fabrikam", + Token = "SlAV32hkKG", + TokenTypeHint = TokenTypeHints.RefreshToken + }), + + OpenIddictServerEndpointType.Token => await client.PostAsync("/connect/token", new OpenIddictRequest + { + ClientId = "Fabrikam", + GrantType = GrantTypes.Password, + Username = "johndoe", + Password = "A3ddj3w" + }), + + _ => throw new NotSupportedException() + }; + + // Assert + Assert.Equal(Errors.InvalidClient, response.Error); + Assert.Equal(SR.GetResourceString(SR.ID2197), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2197), response.ErrorUri); + + Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); + Mock.Get(manager).Verify(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny()), Times.AtLeastOnce()); + Mock.Get(manager).Verify(manager => manager.GetClientCertificateChainPolicyAsync(application, It.IsAny(), It.IsAny()), Times.Once()); + Mock.Get(manager).Verify(manager => manager.ValidateClientCertificateAsync(application, certificate, policy, It.IsAny()), Times.Once()); + } +#endif + [Fact] public async Task ProcessAuthentication_RequestTokenPrincipalIsNotPopulatedWhenRequestTokenIsMissing() { diff --git a/test/OpenIddict.Server.Tests/OpenIddictServerBuilderTests.cs b/test/OpenIddict.Server.Tests/OpenIddictServerBuilderTests.cs index e84a8482..6000100c 100644 --- a/test/OpenIddict.Server.Tests/OpenIddictServerBuilderTests.cs +++ b/test/OpenIddict.Server.Tests/OpenIddictServerBuilderTests.cs @@ -1,4 +1,6 @@ using System.Reflection; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; @@ -309,9 +311,9 @@ public class OpenIddictServerBuilderTests builder.AddDevelopmentEncryptionCertificate( subject: new X500DistinguishedName("CN=" + Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture))); - var serviceProvider = services.BuildServiceProvider(); + var provider = services.BuildServiceProvider(); - var options = serviceProvider.GetRequiredService>(); + var options = provider.GetRequiredService>(); // Act and assert var exception = Assert.Throws(() => options.Value); @@ -365,9 +367,9 @@ public class OpenIddictServerBuilderTests builder.AddDevelopmentSigningCertificate( subject: new X500DistinguishedName("CN=" + Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture))); - var serviceProvider = services.BuildServiceProvider(); + var provider = services.BuildServiceProvider(); - var options = serviceProvider.GetRequiredService>(); + var options = provider.GetRequiredService>(); // Act and assert var exception = Assert.Throws(() => options.Value); @@ -784,6 +786,412 @@ public class OpenIddictServerBuilderTests Assert.True(options.DisableTokenStorage); } + [Fact] + public void EnablePublicKeyInfrastructureClientCertificateAuthentication_ThrowsAnExceptionForNullCertificates() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act and assert + var exception = Assert.Throws(() => + builder.EnablePublicKeyInfrastructureClientCertificateAuthentication(certificates: null!)); + + Assert.Equal("certificates", exception.ParamName); + } + +#if SUPPORTS_X509_CHAIN_POLICY_CUSTOM_TRUST_STORE + [Fact] + public void EnablePublicKeyInfrastructureClientCertificateAuthentication_ThrowsAnExceptionWhenNoRootCertificateProvided() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + var certificates = new X509Certificate2Collection + { + // Intermediate certificate: + X509Certificate2.CreateFromPem($""" + -----BEGIN CERTIFICATE----- + MIIFRDCCAyygAwIBAgIRALpTKvDtz6lGPaqNaK8aULowDQYJKoZIhvcNAQELBQAw + EjEQMA4GA1UEAxMHUm9vdCBDQTAgFw0yNjAxMzAxNzM4NTVaGA8yMTI2MDEzMTE3 + Mzg1NVowGjEYMBYGA1UEAxMPSW50ZXJtZWRpYXRlIENBMIICIjANBgkqhkiG9w0B + AQEFAAOCAg8AMIICCgKCAgEAvHiS4aNz7vL5mOJNjbpybcK75RhH1sXifLwKW8Zg + nHm+KjdRENf3X9yp7c+xNrtpHhG4/gp8M++0G1Cz4Yvq8idZu8IpMiqk9/KT447b + VocaRPCFC4NIC9U6g4s3rwHLUr2wMCAWiM9yjWcbXcvIlnSuA/i/lSAfAUPjrn8X + LLDgqlEkInmWRvYvDmdmw7vdqfDobFTDh0YRWB/y/LuDvkPFBDg3cfY8+AyrDkha + y3m1Ot3NTsg0O/HOL6MXMN9HRd4vX37XBV88kZtFE+vyHdYDs2NzGjAbfz4JZ6xz + 4+weUjklOc9ucAEgfAnwijH9w4KFBJEHAqtOMsbrIy74MvPTFj3LeayLo5nhLeqp + GbqvJcEX1UM83vFt+JUaDVbXDUG2ECHMDe6W5r5eYQtZW1ErKkRYNTJu++I0vDZr + EeZdYDYp15dbksMXUDyzhJ0WS0N23b7s57S6YAbok97UD/d+aGMtY3kJ/wIiftYY + Sel/MO/QXrgNchnVtUbShgE2oFvAJUYRvlZarG9/egp3Jb3B4WNjwIyzaT6SFtnG + Tg+IEXYPE2s5x4YZ8GINygWKrDbV7UuANRjKvoBlGmcrW/iz2Aaa+H5696p1HLVA + k7gXTw7WlJxzP6JPs2ZjWu27k88oAUV8HJjzFzGUsRPIjkf8KcJuxAqwLPoqFelh + uNECAwEAAaOBijCBhzASBgNVHRMBAf8ECDAGAQH/AgEAMA4GA1UdDwEB/wQEAwIB + BjAdBgNVHQ4EFgQUq7mtVl4BCJyLWTHEpSCqokobTCcwQgYDVR0jBDswOYAUKv1o + 4gZNED0vspeWdqb0WeS3N06hFqQUMBIxEDAOBgNVBAMTB1Jvb3QgQ0GCCQDNRQQ8 + F7il/DANBgkqhkiG9w0BAQsFAAOCAgEArlp0WSTwHgv8wgI+XT/QNxUQBiVyrHql + SHIMCBA7rDPPsl2RURWzQDE7zqovA3r7fnrYMfVXAAdgzXhDLQwL15RdaeoZUsjH + xN4y5Mtn0zv1yp7PPtZUc0mZ4Q0xWo4MPve82IfhiqWXretUxvcZ4NKY3sni0s8W + hViZdHH77vVIWWcWK414cpRwvsDtaKkgS4h8yHiUOtlgKgTViyUd0ovphR0boLtF + Ddw+jmLGM9c5keIs87RCTqCcHD4nP81kHHUaE60NDMtHH5UONSA5ecsHo11tC1am + 9U2TRs5+zwyBnwy4oOE/EZxXslcz27XyAX7MOhZppue+xtEDyex4gjiS27Nl8Va1 + R1I1vkI5A209OQQ4JXzJZcAtgWep/ez0hu8TOkdtn0l/6aGkj2l3iwVG8edjiwSz + nVSPaBFKRtrHPuk9uEqu1xtP2klMeJEs7a5bVOyBOzZksafDwVTSPdRnDJDxo/Rx + bGzSWWYqKNsDxyV9aVMZ1iABW2O7qh6eXbioICzWAWQyplLeihnZ1d0o0h9gk/Kt + dPuLATo/WSXBA3oDfdEjjEWhmEdj6mR92KrOTQUF7JkOM74ZGs/lCxXsCOva7Z0N + kZmmpiU3Dewr11+SUxGx8g/4Ba1FhW9pXI9jSYguzY1KY210nF2P5YLsz/HgIM2r + SVA+QXhvcj0= + -----END CERTIFICATE----- + """) + }; + + // Act and assert + var exception = Assert.Throws(() => + builder.EnablePublicKeyInfrastructureClientCertificateAuthentication(certificates)); + + Assert.Equal("certificates", exception.ParamName); + } + + [Fact] + public void EnablePublicKeyInfrastructureClientCertificateAuthentication_ThrowsAnExceptionWhenEndCertificateProvided() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + var certificates = new X509Certificate2Collection + { + // Root certificate: + X509Certificate2.CreateFromPem($""" + -----BEGIN CERTIFICATE----- + MIIE7jCCAtagAwIBAgIJAM1FBDwXuKX8MA0GCSqGSIb3DQEBCwUAMBIxEDAOBgNV + BAMTB1Jvb3QgQ0EwIBcNMjYwMTMwMTczODU1WhgPMjEyNjAxMzExNzM4NTVaMBIx + EDAOBgNVBAMTB1Jvb3QgQ0EwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoIC + AQDt/Nz3V5bKbYrJoITYrk3dL9CF+rDcMM6VyJ1pj33feXMTCcRJXkydKDc25sX+ + 2C/yrx75zxcUBRoWzsg++YdHJffcZI0+6Q7XBOndNKflCL8lBihvT/EUO9MQYRWV + 495ie3deXoVGebrl6XbGAfm755Ml8KikYFryU6WOxApfQxjcKxnQLNLCTkIyn3WE + lzce9awtECPgdQfjDzCmE32xXMQcn/0HQUDxiyGvBQbBf8ZM9h3iJz4VGGVQkE7m + Ix15CwjAyrPc7jGAnUx00QGOCGzfT4bQaHOAZMgEJ8/KJhmx0Fmd6KR1fNJDqDvx + JYW683y1P512QZgN16e2ZEOdg9fsuXV/PaS6NmHOh7s/hwZsoIf3CJ5dX/M1h1BA + 4buxlvRfeZdANQHJLuQFPC4DQ8SWgbxXhL8KCo0jS9rUPTaxikL7+prFK/t39YFt + LzowUL8d+sMvUrn3v9yXb363wBB7fja1ZG1EOl7r2YO1uGleRR1ztymfRVziQ/Np + wRjDeBb9rWL9srPPilvo+5VsJe2a/XtfZuxMoH6vNEl04W6/iyYE4cVizwRC8GTW + hSHdhk2vT2/eyGSK3Cj5U74x+orHD+3XS6xHd63qB1oo+hJl2Ln/7pBAm9qFnNed + u6Wn/++Oi7M7nMU/ngEkbPKUfwrR/fEKQuweJaXTgiqj3QIDAQABo0UwQzASBgNV + HRMBAf8ECDAGAQH/AgEBMA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUKv1o4gZN + ED0vspeWdqb0WeS3N04wDQYJKoZIhvcNAQELBQADggIBACLp5z1zaemqoFPtQ5Sr + Ii2ijs03Gc52Y/Pbg1V83xg1nMa+vI4aQYc90FZnNOv7I4VAmR6I3cI5bA3tnrzB + /yCMOkdxiFt6W0OQPMlmlVdCbPtUqXWM3tLilRn90XYEEWZB8I1sOrk2WH7oEHmu + W7BC2I3igjhUDug2bl7VwdBXzRJrWFgYdhVsjRU9rx3AbZqbD/3pC9B3PcwZxTGz + k8wRMP/9cF49VvUFVWhp01Bol1StwgX3r6IbaamDdIJd0MvHp0ctgqL6PLRzgRRY + adzWX8SPlgARVCixSOkXmAGNeuNWT/Ulo0W1xaNVTvOssfx58v3lwnjoaMLi7UFA + tCPrLZtZYvScB0+0AjUwfIfKrHqRdP5OqJZi7PhahR39tTh9pQX63INQvKYKO583 + iidiNDau4HU+e+ujnXR5xJdgNWtuehKRdZFlLBH0lKKXrrnPyW9YtIjCVf1zhc+L + VSyo/aajWKBGqyTTbM1zetGe1rah97i7/0snoh97sWHlenmSX3BQUnajLkp9ZMh0 + vY4koR1fxIlxHnOQ7SebZ23QiWUjdmJoOXW6Bub6VhWMDY96EpMr17QiGtexbUHD + 1nm2Z0xGwyWeyg2QSnNEAIZ6F2EeaZ6jmD/5ASXujcutNGyPwsSGkog4/Ir5Ol15 + +3OP4DTt75BHxzMUxB6XmbYN + -----END CERTIFICATE----- + """), + + // Intermediate certificate: + X509Certificate2.CreateFromPem($""" + -----BEGIN CERTIFICATE----- + MIIFRDCCAyygAwIBAgIRALpTKvDtz6lGPaqNaK8aULowDQYJKoZIhvcNAQELBQAw + EjEQMA4GA1UEAxMHUm9vdCBDQTAgFw0yNjAxMzAxNzM4NTVaGA8yMTI2MDEzMTE3 + Mzg1NVowGjEYMBYGA1UEAxMPSW50ZXJtZWRpYXRlIENBMIICIjANBgkqhkiG9w0B + AQEFAAOCAg8AMIICCgKCAgEAvHiS4aNz7vL5mOJNjbpybcK75RhH1sXifLwKW8Zg + nHm+KjdRENf3X9yp7c+xNrtpHhG4/gp8M++0G1Cz4Yvq8idZu8IpMiqk9/KT447b + VocaRPCFC4NIC9U6g4s3rwHLUr2wMCAWiM9yjWcbXcvIlnSuA/i/lSAfAUPjrn8X + LLDgqlEkInmWRvYvDmdmw7vdqfDobFTDh0YRWB/y/LuDvkPFBDg3cfY8+AyrDkha + y3m1Ot3NTsg0O/HOL6MXMN9HRd4vX37XBV88kZtFE+vyHdYDs2NzGjAbfz4JZ6xz + 4+weUjklOc9ucAEgfAnwijH9w4KFBJEHAqtOMsbrIy74MvPTFj3LeayLo5nhLeqp + GbqvJcEX1UM83vFt+JUaDVbXDUG2ECHMDe6W5r5eYQtZW1ErKkRYNTJu++I0vDZr + EeZdYDYp15dbksMXUDyzhJ0WS0N23b7s57S6YAbok97UD/d+aGMtY3kJ/wIiftYY + Sel/MO/QXrgNchnVtUbShgE2oFvAJUYRvlZarG9/egp3Jb3B4WNjwIyzaT6SFtnG + Tg+IEXYPE2s5x4YZ8GINygWKrDbV7UuANRjKvoBlGmcrW/iz2Aaa+H5696p1HLVA + k7gXTw7WlJxzP6JPs2ZjWu27k88oAUV8HJjzFzGUsRPIjkf8KcJuxAqwLPoqFelh + uNECAwEAAaOBijCBhzASBgNVHRMBAf8ECDAGAQH/AgEAMA4GA1UdDwEB/wQEAwIB + BjAdBgNVHQ4EFgQUq7mtVl4BCJyLWTHEpSCqokobTCcwQgYDVR0jBDswOYAUKv1o + 4gZNED0vspeWdqb0WeS3N06hFqQUMBIxEDAOBgNVBAMTB1Jvb3QgQ0GCCQDNRQQ8 + F7il/DANBgkqhkiG9w0BAQsFAAOCAgEArlp0WSTwHgv8wgI+XT/QNxUQBiVyrHql + SHIMCBA7rDPPsl2RURWzQDE7zqovA3r7fnrYMfVXAAdgzXhDLQwL15RdaeoZUsjH + xN4y5Mtn0zv1yp7PPtZUc0mZ4Q0xWo4MPve82IfhiqWXretUxvcZ4NKY3sni0s8W + hViZdHH77vVIWWcWK414cpRwvsDtaKkgS4h8yHiUOtlgKgTViyUd0ovphR0boLtF + Ddw+jmLGM9c5keIs87RCTqCcHD4nP81kHHUaE60NDMtHH5UONSA5ecsHo11tC1am + 9U2TRs5+zwyBnwy4oOE/EZxXslcz27XyAX7MOhZppue+xtEDyex4gjiS27Nl8Va1 + R1I1vkI5A209OQQ4JXzJZcAtgWep/ez0hu8TOkdtn0l/6aGkj2l3iwVG8edjiwSz + nVSPaBFKRtrHPuk9uEqu1xtP2klMeJEs7a5bVOyBOzZksafDwVTSPdRnDJDxo/Rx + bGzSWWYqKNsDxyV9aVMZ1iABW2O7qh6eXbioICzWAWQyplLeihnZ1d0o0h9gk/Kt + dPuLATo/WSXBA3oDfdEjjEWhmEdj6mR92KrOTQUF7JkOM74ZGs/lCxXsCOva7Z0N + kZmmpiU3Dewr11+SUxGx8g/4Ba1FhW9pXI9jSYguzY1KY210nF2P5YLsz/HgIM2r + SVA+QXhvcj0= + -----END CERTIFICATE----- + """), + + // End certificate: + X509Certificate2.CreateFromPem($""" + -----BEGIN CERTIFICATE----- + MIIEfDCCAmSgAwIBAgIRAMrgbbME5gBGSu3XfBUTMZAwDQYJKoZIhvcNAQELBQAw + GjEYMBYGA1UEAxMPSW50ZXJtZWRpYXRlIENBMCAXDTI2MDEzMDE3Mzg1NVoYDzIx + MjYwMTMxMTczODU1WjAaMRgwFgYDVQQDEw9FbmQgY2VydGlmaWNhdGUwggEiMA0G + CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDY5UyMDT2evGgOXTzuH6adZmLcjLbv + 9u1cdpfYc2jgdA+jXm0hvQmhwmQMosu0KDFtMX0okZ4xM0H5XEDbxmKBY7WwAdFF + Morz7tu/UzRLYXUJp/SRk6/zXKb4qkvtWaEcKBujlLA7jUEDcrCmTaoyU0Zz9ZzG + zrJc59UceXSH3gAoAkoQesNKUBsdgdk8Na3h+U8nLl4rHXWkSYi/VwKkROaDfnT2 + mgpsJfBVyQtuAJDXsLFRhtmsPnR3KoutJUh69WnlC7mRrTVI2rhUPKFny1gGxk1W + rsI9vzkIIiLkc+tsAR76DNnoVkXhuqEQ9SMnl5CxV2GFWEX/TOLhteVVAgMBAAGj + gbowgbcwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwFgYDVR0lAQH/BAww + CgYIKwYBBQUHAwIwHQYDVR0OBBYEFCRF8sJJqhgyr/A8RnsP/JUE06dGMEoGA1Ud + IwRDMEGAFKu5rVZeAQici1kxxKUgqqJKG0wnoRakFDASMRAwDgYDVQQDEwdSb290 + IENBghEAulMq8O3PqUY9qo1orxpQujAUBgNVHREEDTALgglsb2NhbGhvc3QwDQYJ + KoZIhvcNAQELBQADggIBACW7bCe7KpuAVZO4jIjtMLwKJ+1QZ7551FIsvBLDCFF+ + fX2zND0ne+8hv1qemHdzDBED6WeurOQqGtgI+adj7HrXAfAXKihBaCjq2U6U3sR5 + fqyYY5of1gvJhvJK+TEY3Fb55PvA+j38GwI52hKSkRdunPpCjRI1d/+Jvb+voUOa + kzLsARgOaMhJtYQeMebKr7uSLezFoOaRfc5rqpiVTA7xXO8dHkz2p49bxfIRj1Tq + 2Lgx9uAE4omwzxSB/cwZiG1tNgpVVvn2Tb20SgCIBGl7Oqave+LRm1Eztl0Q8e8C + iz6bLMiCcesRRFxU8TE2mhmNOeNUsBIP730+ZOnP2rgX3Zs93gTFX/omPuQS8Kj3 + ly5+v+PZkuN3xZ56mLXURlDRmWI1gqNRgNYl1jwYyQ0ll05yk3JFIyUvxdg3klRK + 9/+MoKw8PVGbSKntzoHiqVUHnrB1lemJqZ91Dx+h8K58eaRs3/aL0lUEli7NpNXR + 5Q6Tl7cCVe5ZjtR7Z56IQLmswq/TNJVXQSBjLLwLCkaJvTXWwojkG5ETU/t/ikCG + c2bH6ICUizzz2gdJNdcIP8wc7SsxONdpPmK3ED3KQBlTVDRUJ/w9Q4HYztXEFiyg + xkNzboe3SYq92mqVilBTiY51SNZuOr8zRZp9gcMKoQgmxgfl7j6JiRVpZzoxb54Y + -----END CERTIFICATE----- + """) + }; + + // Act and assert + var exception = Assert.Throws(() => + builder.EnablePublicKeyInfrastructureClientCertificateAuthentication(certificates)); + + Assert.Equal("certificates", exception.ParamName); + } + + [Fact] + public void EnablePublicKeyInfrastructureClientCertificateAuthentication_PolicyIsCorrectlyConfigured() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + var certificates = new X509Certificate2Collection + { + // Root certificate: + X509Certificate2.CreateFromPem($""" + -----BEGIN CERTIFICATE----- + MIIE7jCCAtagAwIBAgIJAM1FBDwXuKX8MA0GCSqGSIb3DQEBCwUAMBIxEDAOBgNV + BAMTB1Jvb3QgQ0EwIBcNMjYwMTMwMTczODU1WhgPMjEyNjAxMzExNzM4NTVaMBIx + EDAOBgNVBAMTB1Jvb3QgQ0EwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoIC + AQDt/Nz3V5bKbYrJoITYrk3dL9CF+rDcMM6VyJ1pj33feXMTCcRJXkydKDc25sX+ + 2C/yrx75zxcUBRoWzsg++YdHJffcZI0+6Q7XBOndNKflCL8lBihvT/EUO9MQYRWV + 495ie3deXoVGebrl6XbGAfm755Ml8KikYFryU6WOxApfQxjcKxnQLNLCTkIyn3WE + lzce9awtECPgdQfjDzCmE32xXMQcn/0HQUDxiyGvBQbBf8ZM9h3iJz4VGGVQkE7m + Ix15CwjAyrPc7jGAnUx00QGOCGzfT4bQaHOAZMgEJ8/KJhmx0Fmd6KR1fNJDqDvx + JYW683y1P512QZgN16e2ZEOdg9fsuXV/PaS6NmHOh7s/hwZsoIf3CJ5dX/M1h1BA + 4buxlvRfeZdANQHJLuQFPC4DQ8SWgbxXhL8KCo0jS9rUPTaxikL7+prFK/t39YFt + LzowUL8d+sMvUrn3v9yXb363wBB7fja1ZG1EOl7r2YO1uGleRR1ztymfRVziQ/Np + wRjDeBb9rWL9srPPilvo+5VsJe2a/XtfZuxMoH6vNEl04W6/iyYE4cVizwRC8GTW + hSHdhk2vT2/eyGSK3Cj5U74x+orHD+3XS6xHd63qB1oo+hJl2Ln/7pBAm9qFnNed + u6Wn/++Oi7M7nMU/ngEkbPKUfwrR/fEKQuweJaXTgiqj3QIDAQABo0UwQzASBgNV + HRMBAf8ECDAGAQH/AgEBMA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUKv1o4gZN + ED0vspeWdqb0WeS3N04wDQYJKoZIhvcNAQELBQADggIBACLp5z1zaemqoFPtQ5Sr + Ii2ijs03Gc52Y/Pbg1V83xg1nMa+vI4aQYc90FZnNOv7I4VAmR6I3cI5bA3tnrzB + /yCMOkdxiFt6W0OQPMlmlVdCbPtUqXWM3tLilRn90XYEEWZB8I1sOrk2WH7oEHmu + W7BC2I3igjhUDug2bl7VwdBXzRJrWFgYdhVsjRU9rx3AbZqbD/3pC9B3PcwZxTGz + k8wRMP/9cF49VvUFVWhp01Bol1StwgX3r6IbaamDdIJd0MvHp0ctgqL6PLRzgRRY + adzWX8SPlgARVCixSOkXmAGNeuNWT/Ulo0W1xaNVTvOssfx58v3lwnjoaMLi7UFA + tCPrLZtZYvScB0+0AjUwfIfKrHqRdP5OqJZi7PhahR39tTh9pQX63INQvKYKO583 + iidiNDau4HU+e+ujnXR5xJdgNWtuehKRdZFlLBH0lKKXrrnPyW9YtIjCVf1zhc+L + VSyo/aajWKBGqyTTbM1zetGe1rah97i7/0snoh97sWHlenmSX3BQUnajLkp9ZMh0 + vY4koR1fxIlxHnOQ7SebZ23QiWUjdmJoOXW6Bub6VhWMDY96EpMr17QiGtexbUHD + 1nm2Z0xGwyWeyg2QSnNEAIZ6F2EeaZ6jmD/5ASXujcutNGyPwsSGkog4/Ir5Ol15 + +3OP4DTt75BHxzMUxB6XmbYN + -----END CERTIFICATE----- + """), + + // Intermediate certificate: + X509Certificate2.CreateFromPem($""" + -----BEGIN CERTIFICATE----- + MIIFRDCCAyygAwIBAgIRALpTKvDtz6lGPaqNaK8aULowDQYJKoZIhvcNAQELBQAw + EjEQMA4GA1UEAxMHUm9vdCBDQTAgFw0yNjAxMzAxNzM4NTVaGA8yMTI2MDEzMTE3 + Mzg1NVowGjEYMBYGA1UEAxMPSW50ZXJtZWRpYXRlIENBMIICIjANBgkqhkiG9w0B + AQEFAAOCAg8AMIICCgKCAgEAvHiS4aNz7vL5mOJNjbpybcK75RhH1sXifLwKW8Zg + nHm+KjdRENf3X9yp7c+xNrtpHhG4/gp8M++0G1Cz4Yvq8idZu8IpMiqk9/KT447b + VocaRPCFC4NIC9U6g4s3rwHLUr2wMCAWiM9yjWcbXcvIlnSuA/i/lSAfAUPjrn8X + LLDgqlEkInmWRvYvDmdmw7vdqfDobFTDh0YRWB/y/LuDvkPFBDg3cfY8+AyrDkha + y3m1Ot3NTsg0O/HOL6MXMN9HRd4vX37XBV88kZtFE+vyHdYDs2NzGjAbfz4JZ6xz + 4+weUjklOc9ucAEgfAnwijH9w4KFBJEHAqtOMsbrIy74MvPTFj3LeayLo5nhLeqp + GbqvJcEX1UM83vFt+JUaDVbXDUG2ECHMDe6W5r5eYQtZW1ErKkRYNTJu++I0vDZr + EeZdYDYp15dbksMXUDyzhJ0WS0N23b7s57S6YAbok97UD/d+aGMtY3kJ/wIiftYY + Sel/MO/QXrgNchnVtUbShgE2oFvAJUYRvlZarG9/egp3Jb3B4WNjwIyzaT6SFtnG + Tg+IEXYPE2s5x4YZ8GINygWKrDbV7UuANRjKvoBlGmcrW/iz2Aaa+H5696p1HLVA + k7gXTw7WlJxzP6JPs2ZjWu27k88oAUV8HJjzFzGUsRPIjkf8KcJuxAqwLPoqFelh + uNECAwEAAaOBijCBhzASBgNVHRMBAf8ECDAGAQH/AgEAMA4GA1UdDwEB/wQEAwIB + BjAdBgNVHQ4EFgQUq7mtVl4BCJyLWTHEpSCqokobTCcwQgYDVR0jBDswOYAUKv1o + 4gZNED0vspeWdqb0WeS3N06hFqQUMBIxEDAOBgNVBAMTB1Jvb3QgQ0GCCQDNRQQ8 + F7il/DANBgkqhkiG9w0BAQsFAAOCAgEArlp0WSTwHgv8wgI+XT/QNxUQBiVyrHql + SHIMCBA7rDPPsl2RURWzQDE7zqovA3r7fnrYMfVXAAdgzXhDLQwL15RdaeoZUsjH + xN4y5Mtn0zv1yp7PPtZUc0mZ4Q0xWo4MPve82IfhiqWXretUxvcZ4NKY3sni0s8W + hViZdHH77vVIWWcWK414cpRwvsDtaKkgS4h8yHiUOtlgKgTViyUd0ovphR0boLtF + Ddw+jmLGM9c5keIs87RCTqCcHD4nP81kHHUaE60NDMtHH5UONSA5ecsHo11tC1am + 9U2TRs5+zwyBnwy4oOE/EZxXslcz27XyAX7MOhZppue+xtEDyex4gjiS27Nl8Va1 + R1I1vkI5A209OQQ4JXzJZcAtgWep/ez0hu8TOkdtn0l/6aGkj2l3iwVG8edjiwSz + nVSPaBFKRtrHPuk9uEqu1xtP2klMeJEs7a5bVOyBOzZksafDwVTSPdRnDJDxo/Rx + bGzSWWYqKNsDxyV9aVMZ1iABW2O7qh6eXbioICzWAWQyplLeihnZ1d0o0h9gk/Kt + dPuLATo/WSXBA3oDfdEjjEWhmEdj6mR92KrOTQUF7JkOM74ZGs/lCxXsCOva7Z0N + kZmmpiU3Dewr11+SUxGx8g/4Ba1FhW9pXI9jSYguzY1KY210nF2P5YLsz/HgIM2r + SVA+QXhvcj0= + -----END CERTIFICATE----- + """) + }; + + // Act + builder.EnablePublicKeyInfrastructureClientCertificateAuthentication(certificates); + + var options = GetOptions(services); + + // Assert + Assert.NotNull(options.ClientCertificateChainPolicy); + Assert.Equal(X509ChainTrustMode.CustomRootTrust, options.ClientCertificateChainPolicy.TrustMode); + Assert.Contains(options.ClientCertificateChainPolicy.ApplicationPolicy.Cast(), + oid => oid.Value == ObjectIdentifiers.ExtendedKeyUsages.ClientAuthentication); + } + + [Fact] + public void EnablePublicKeyInfrastructureClientCertificateAuthentication_ThrowsAnExceptionWhenTrustModeIsChanged() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + var certificates = new X509Certificate2Collection + { + // Root certificate: + X509Certificate2.CreateFromPem($""" + -----BEGIN CERTIFICATE----- + MIIE7jCCAtagAwIBAgIJAM1FBDwXuKX8MA0GCSqGSIb3DQEBCwUAMBIxEDAOBgNV + BAMTB1Jvb3QgQ0EwIBcNMjYwMTMwMTczODU1WhgPMjEyNjAxMzExNzM4NTVaMBIx + EDAOBgNVBAMTB1Jvb3QgQ0EwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoIC + AQDt/Nz3V5bKbYrJoITYrk3dL9CF+rDcMM6VyJ1pj33feXMTCcRJXkydKDc25sX+ + 2C/yrx75zxcUBRoWzsg++YdHJffcZI0+6Q7XBOndNKflCL8lBihvT/EUO9MQYRWV + 495ie3deXoVGebrl6XbGAfm755Ml8KikYFryU6WOxApfQxjcKxnQLNLCTkIyn3WE + lzce9awtECPgdQfjDzCmE32xXMQcn/0HQUDxiyGvBQbBf8ZM9h3iJz4VGGVQkE7m + Ix15CwjAyrPc7jGAnUx00QGOCGzfT4bQaHOAZMgEJ8/KJhmx0Fmd6KR1fNJDqDvx + JYW683y1P512QZgN16e2ZEOdg9fsuXV/PaS6NmHOh7s/hwZsoIf3CJ5dX/M1h1BA + 4buxlvRfeZdANQHJLuQFPC4DQ8SWgbxXhL8KCo0jS9rUPTaxikL7+prFK/t39YFt + LzowUL8d+sMvUrn3v9yXb363wBB7fja1ZG1EOl7r2YO1uGleRR1ztymfRVziQ/Np + wRjDeBb9rWL9srPPilvo+5VsJe2a/XtfZuxMoH6vNEl04W6/iyYE4cVizwRC8GTW + hSHdhk2vT2/eyGSK3Cj5U74x+orHD+3XS6xHd63qB1oo+hJl2Ln/7pBAm9qFnNed + u6Wn/++Oi7M7nMU/ngEkbPKUfwrR/fEKQuweJaXTgiqj3QIDAQABo0UwQzASBgNV + HRMBAf8ECDAGAQH/AgEBMA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUKv1o4gZN + ED0vspeWdqb0WeS3N04wDQYJKoZIhvcNAQELBQADggIBACLp5z1zaemqoFPtQ5Sr + Ii2ijs03Gc52Y/Pbg1V83xg1nMa+vI4aQYc90FZnNOv7I4VAmR6I3cI5bA3tnrzB + /yCMOkdxiFt6W0OQPMlmlVdCbPtUqXWM3tLilRn90XYEEWZB8I1sOrk2WH7oEHmu + W7BC2I3igjhUDug2bl7VwdBXzRJrWFgYdhVsjRU9rx3AbZqbD/3pC9B3PcwZxTGz + k8wRMP/9cF49VvUFVWhp01Bol1StwgX3r6IbaamDdIJd0MvHp0ctgqL6PLRzgRRY + adzWX8SPlgARVCixSOkXmAGNeuNWT/Ulo0W1xaNVTvOssfx58v3lwnjoaMLi7UFA + tCPrLZtZYvScB0+0AjUwfIfKrHqRdP5OqJZi7PhahR39tTh9pQX63INQvKYKO583 + iidiNDau4HU+e+ujnXR5xJdgNWtuehKRdZFlLBH0lKKXrrnPyW9YtIjCVf1zhc+L + VSyo/aajWKBGqyTTbM1zetGe1rah97i7/0snoh97sWHlenmSX3BQUnajLkp9ZMh0 + vY4koR1fxIlxHnOQ7SebZ23QiWUjdmJoOXW6Bub6VhWMDY96EpMr17QiGtexbUHD + 1nm2Z0xGwyWeyg2QSnNEAIZ6F2EeaZ6jmD/5ASXujcutNGyPwsSGkog4/Ir5Ol15 + +3OP4DTt75BHxzMUxB6XmbYN + -----END CERTIFICATE----- + """), + + // Intermediate certificate: + X509Certificate2.CreateFromPem($""" + -----BEGIN CERTIFICATE----- + MIIFRDCCAyygAwIBAgIRALpTKvDtz6lGPaqNaK8aULowDQYJKoZIhvcNAQELBQAw + EjEQMA4GA1UEAxMHUm9vdCBDQTAgFw0yNjAxMzAxNzM4NTVaGA8yMTI2MDEzMTE3 + Mzg1NVowGjEYMBYGA1UEAxMPSW50ZXJtZWRpYXRlIENBMIICIjANBgkqhkiG9w0B + AQEFAAOCAg8AMIICCgKCAgEAvHiS4aNz7vL5mOJNjbpybcK75RhH1sXifLwKW8Zg + nHm+KjdRENf3X9yp7c+xNrtpHhG4/gp8M++0G1Cz4Yvq8idZu8IpMiqk9/KT447b + VocaRPCFC4NIC9U6g4s3rwHLUr2wMCAWiM9yjWcbXcvIlnSuA/i/lSAfAUPjrn8X + LLDgqlEkInmWRvYvDmdmw7vdqfDobFTDh0YRWB/y/LuDvkPFBDg3cfY8+AyrDkha + y3m1Ot3NTsg0O/HOL6MXMN9HRd4vX37XBV88kZtFE+vyHdYDs2NzGjAbfz4JZ6xz + 4+weUjklOc9ucAEgfAnwijH9w4KFBJEHAqtOMsbrIy74MvPTFj3LeayLo5nhLeqp + GbqvJcEX1UM83vFt+JUaDVbXDUG2ECHMDe6W5r5eYQtZW1ErKkRYNTJu++I0vDZr + EeZdYDYp15dbksMXUDyzhJ0WS0N23b7s57S6YAbok97UD/d+aGMtY3kJ/wIiftYY + Sel/MO/QXrgNchnVtUbShgE2oFvAJUYRvlZarG9/egp3Jb3B4WNjwIyzaT6SFtnG + Tg+IEXYPE2s5x4YZ8GINygWKrDbV7UuANRjKvoBlGmcrW/iz2Aaa+H5696p1HLVA + k7gXTw7WlJxzP6JPs2ZjWu27k88oAUV8HJjzFzGUsRPIjkf8KcJuxAqwLPoqFelh + uNECAwEAAaOBijCBhzASBgNVHRMBAf8ECDAGAQH/AgEAMA4GA1UdDwEB/wQEAwIB + BjAdBgNVHQ4EFgQUq7mtVl4BCJyLWTHEpSCqokobTCcwQgYDVR0jBDswOYAUKv1o + 4gZNED0vspeWdqb0WeS3N06hFqQUMBIxEDAOBgNVBAMTB1Jvb3QgQ0GCCQDNRQQ8 + F7il/DANBgkqhkiG9w0BAQsFAAOCAgEArlp0WSTwHgv8wgI+XT/QNxUQBiVyrHql + SHIMCBA7rDPPsl2RURWzQDE7zqovA3r7fnrYMfVXAAdgzXhDLQwL15RdaeoZUsjH + xN4y5Mtn0zv1yp7PPtZUc0mZ4Q0xWo4MPve82IfhiqWXretUxvcZ4NKY3sni0s8W + hViZdHH77vVIWWcWK414cpRwvsDtaKkgS4h8yHiUOtlgKgTViyUd0ovphR0boLtF + Ddw+jmLGM9c5keIs87RCTqCcHD4nP81kHHUaE60NDMtHH5UONSA5ecsHo11tC1am + 9U2TRs5+zwyBnwy4oOE/EZxXslcz27XyAX7MOhZppue+xtEDyex4gjiS27Nl8Va1 + R1I1vkI5A209OQQ4JXzJZcAtgWep/ez0hu8TOkdtn0l/6aGkj2l3iwVG8edjiwSz + nVSPaBFKRtrHPuk9uEqu1xtP2klMeJEs7a5bVOyBOzZksafDwVTSPdRnDJDxo/Rx + bGzSWWYqKNsDxyV9aVMZ1iABW2O7qh6eXbioICzWAWQyplLeihnZ1d0o0h9gk/Kt + dPuLATo/WSXBA3oDfdEjjEWhmEdj6mR92KrOTQUF7JkOM74ZGs/lCxXsCOva7Z0N + kZmmpiU3Dewr11+SUxGx8g/4Ba1FhW9pXI9jSYguzY1KY210nF2P5YLsz/HgIM2r + SVA+QXhvcj0= + -----END CERTIFICATE----- + """) + }; + + // Act and assert + var exception = Assert.Throws(() => + builder.EnablePublicKeyInfrastructureClientCertificateAuthentication(certificates, + policy => policy.TrustMode = X509ChainTrustMode.System)); + + Assert.Equal(SR.GetResourceString(SR.ID0509), exception.Message); + } +#endif + + [Fact] + public void EnableSelfSignedClientCertificateAuthentication_ThrowsAnExceptionForNullConfiguration() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act and assert + var exception = Assert.Throws(() => + builder.EnableSelfSignedClientCertificateAuthentication(configuration: null!)); + + Assert.Equal("configuration", exception.ParamName); + } + +#if SUPPORTS_X509_CHAIN_POLICY_CUSTOM_TRUST_STORE + [Fact] + public void EnableSelfSignedClientCertificateAuthentication_PolicyIsCorrectlyConfigured() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act + builder.EnableSelfSignedClientCertificateAuthentication(); + + var options = GetOptions(services); + + // Assert + Assert.NotNull(options.SelfSignedClientCertificateChainPolicy); + Assert.Equal(X509ChainTrustMode.CustomRootTrust, options.SelfSignedClientCertificateChainPolicy.TrustMode); + Assert.Equal(X509RevocationMode.NoCheck, options.SelfSignedClientCertificateChainPolicy.RevocationMode); + Assert.Contains(options.SelfSignedClientCertificateChainPolicy.ApplicationPolicy.Cast(), + oid => oid.Value == ObjectIdentifiers.ExtendedKeyUsages.ClientAuthentication); + } + + [Fact] + public void EnableSelfSignedClientCertificateAuthentication_ThrowsAnExceptionWhenTrustModeIsChanged() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act and assert + var exception = Assert.Throws(() => + builder.EnableSelfSignedClientCertificateAuthentication( + policy => policy.TrustMode = X509ChainTrustMode.System)); + + Assert.Equal(SR.GetResourceString(SR.ID0509), exception.Message); + } +#endif + [Fact] public void IgnoreAudiencePermissions_AudiencePermissionsAreIgnored() { @@ -1300,6 +1708,346 @@ public class OpenIddictServerBuilderTests Assert.Contains(new Uri("http://localhost/endpoint-path"), options.EndSessionEndpointUris); } + [Fact] + public void SetMtlsDeviceAuthorizationEndpointAliasUri_ThrowsExceptionWhenUriIsNull() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act and assert + var exception = Assert.Throws(() => builder.SetMtlsDeviceAuthorizationEndpointAliasUri(uri: (null as Uri)!)); + Assert.Equal("uri", exception.ParamName); + } + + [Fact] + public void SetMtlsDeviceAuthorizationEndpointAliasUri_String_ThrowsExceptionWhenUriIsNull() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act and assert + var exception = Assert.Throws(() => builder.SetMtlsDeviceAuthorizationEndpointAliasUri(uri: (null as string)!)); + Assert.Equal("uri", exception.ParamName); + } + + [Theory] + [InlineData(@"C:\")] + public void SetMtlsDeviceAuthorizationEndpointAliasUri_ThrowsExceptionForMalformedUri(string uri) + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act and assert + var exception = Assert.Throws(() => builder.SetMtlsDeviceAuthorizationEndpointAliasUri(new Uri(uri))); + Assert.Equal("uri", exception.ParamName); + Assert.Contains(SR.GetResourceString(SR.ID0072), exception.Message); + } + + [Theory] + [InlineData("~/path")] + public void SetMtlsDeviceAuthorizationEndpointAliasUri_ThrowsExceptionForInvalidRelativeUri(string uri) + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act and assert + var exception = Assert.Throws(() => builder.SetMtlsDeviceAuthorizationEndpointAliasUri(new Uri(uri, UriKind.RelativeOrAbsolute))); + Assert.Equal("uri", exception.ParamName); + Assert.Contains(SR.FormatID0081("~"), exception.Message); + } + + [Fact] + public void SetMtlsDeviceAuthorizationEndpointAliasUri_AddsUri() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act + builder.SetMtlsDeviceAuthorizationEndpointAliasUri("http://localhost/endpoint-path"); + + var options = GetOptions(services); + + // Assert + Assert.Equal(new Uri("http://localhost/endpoint-path"), options.MtlsDeviceAuthorizationEndpointAliasUri); + } + + [Fact] + public void SetMtlsIntrospectionEndpointAliasUri_ThrowsExceptionWhenUriIsNull() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act and assert + var exception = Assert.Throws(() => builder.SetMtlsIntrospectionEndpointAliasUri(uri: (null as Uri)!)); + Assert.Equal("uri", exception.ParamName); + } + + [Fact] + public void SetMtlsIntrospectionEndpointAliasUri_String_ThrowsExceptionWhenUriIsNull() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act and assert + var exception = Assert.Throws(() => builder.SetMtlsIntrospectionEndpointAliasUri(uri: (null as string)!)); + Assert.Equal("uri", exception.ParamName); + } + + [Theory] + [InlineData(@"C:\")] + public void SetMtlsIntrospectionEndpointAliasUri_ThrowsExceptionForMalformedUri(string uri) + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act and assert + var exception = Assert.Throws(() => builder.SetMtlsIntrospectionEndpointAliasUri(new Uri(uri))); + Assert.Equal("uri", exception.ParamName); + Assert.Contains(SR.GetResourceString(SR.ID0072), exception.Message); + } + + [Theory] + [InlineData("~/path")] + public void SetMtlsIntrospectionEndpointAliasUri_ThrowsExceptionForInvalidRelativeUri(string uri) + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act and assert + var exception = Assert.Throws(() => builder.SetMtlsIntrospectionEndpointAliasUri(new Uri(uri, UriKind.RelativeOrAbsolute))); + Assert.Equal("uri", exception.ParamName); + Assert.Contains(SR.FormatID0081("~"), exception.Message); + } + + [Fact] + public void SetMtlsIntrospectionEndpointAliasUri_AddsUri() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act + builder.SetMtlsIntrospectionEndpointAliasUri("http://localhost/endpoint-path"); + + var options = GetOptions(services); + + // Assert + Assert.Equal(new Uri("http://localhost/endpoint-path"), options.MtlsIntrospectionEndpointAliasUri); + } + + [Fact] + public void SetMtlsPushedAuthorizationEndpointAliasUri_ThrowsExceptionWhenUriIsNull() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act and assert + var exception = Assert.Throws(() => builder.SetMtlsPushedAuthorizationEndpointAliasUri(uri: (null as Uri)!)); + Assert.Equal("uri", exception.ParamName); + } + + [Fact] + public void SetMtlsPushedAuthorizationEndpointAliasUri_String_ThrowsExceptionWhenUriIsNull() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act and assert + var exception = Assert.Throws(() => builder.SetMtlsPushedAuthorizationEndpointAliasUri(uri: (null as string)!)); + Assert.Equal("uri", exception.ParamName); + } + + [Theory] + [InlineData(@"C:\")] + public void SetMtlsPushedAuthorizationEndpointAliasUri_ThrowsExceptionForMalformedUri(string uri) + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act and assert + var exception = Assert.Throws(() => builder.SetMtlsPushedAuthorizationEndpointAliasUri(new Uri(uri))); + Assert.Equal("uri", exception.ParamName); + Assert.Contains(SR.GetResourceString(SR.ID0072), exception.Message); + } + + [Theory] + [InlineData("~/path")] + public void SetMtlsPushedAuthorizationEndpointAliasUri_ThrowsExceptionForInvalidRelativeUri(string uri) + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act and assert + var exception = Assert.Throws(() => builder.SetMtlsPushedAuthorizationEndpointAliasUri(new Uri(uri, UriKind.RelativeOrAbsolute))); + Assert.Equal("uri", exception.ParamName); + Assert.Contains(SR.FormatID0081("~"), exception.Message); + } + + [Fact] + public void SetMtlsPushedAuthorizationEndpointAliasUri_AddsUri() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act + builder.SetMtlsPushedAuthorizationEndpointAliasUri("http://localhost/endpoint-path"); + + var options = GetOptions(services); + + // Assert + Assert.Equal(new Uri("http://localhost/endpoint-path"), options.MtlsPushedAuthorizationEndpointAliasUri); + } + + [Fact] + public void SetMtlsRevocationEndpointAliasUri_ThrowsExceptionWhenUriIsNull() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act and assert + var exception = Assert.Throws(() => builder.SetMtlsRevocationEndpointAliasUri(uri: (null as Uri)!)); + Assert.Equal("uri", exception.ParamName); + } + + [Fact] + public void SetMtlsRevocationEndpointAliasUri_String_ThrowsExceptionWhenUriIsNull() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act and assert + var exception = Assert.Throws(() => builder.SetMtlsRevocationEndpointAliasUri(uri: (null as string)!)); + Assert.Equal("uri", exception.ParamName); + } + + [Theory] + [InlineData(@"C:\")] + public void SetMtlsRevocationEndpointAliasUri_ThrowsExceptionForMalformedUri(string uri) + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act and assert + var exception = Assert.Throws(() => builder.SetMtlsRevocationEndpointAliasUri(new Uri(uri))); + Assert.Equal("uri", exception.ParamName); + Assert.Contains(SR.GetResourceString(SR.ID0072), exception.Message); + } + + [Theory] + [InlineData("~/path")] + public void SetMtlsRevocationEndpointAliasUri_ThrowsExceptionForInvalidRelativeUri(string uri) + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act and assert + var exception = Assert.Throws(() => builder.SetMtlsRevocationEndpointAliasUri(new Uri(uri, UriKind.RelativeOrAbsolute))); + Assert.Equal("uri", exception.ParamName); + Assert.Contains(SR.FormatID0081("~"), exception.Message); + } + + [Fact] + public void SetMtlsRevocationEndpointAliasUri_AddsUri() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act + builder.SetMtlsRevocationEndpointAliasUri("http://localhost/endpoint-path"); + + var options = GetOptions(services); + + // Assert + Assert.Equal(new Uri("http://localhost/endpoint-path"), options.MtlsRevocationEndpointAliasUri); + } + + [Fact] + public void SetMtlsTokenEndpointAliasUri_ThrowsExceptionWhenUriIsNull() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act and assert + var exception = Assert.Throws(() => builder.SetMtlsTokenEndpointAliasUri(uri: (null as Uri)!)); + Assert.Equal("uri", exception.ParamName); + } + + [Fact] + public void SetMtlsTokenEndpointAliasUri_String_ThrowsExceptionWhenUriIsNull() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act and assert + var exception = Assert.Throws(() => builder.SetMtlsTokenEndpointAliasUri(uri: (null as string)!)); + Assert.Equal("uri", exception.ParamName); + } + + [Theory] + [InlineData(@"C:\")] + public void SetMtlsTokenEndpointAliasUri_ThrowsExceptionForMalformedUri(string uri) + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act and assert + var exception = Assert.Throws(() => builder.SetMtlsTokenEndpointAliasUri(new Uri(uri))); + Assert.Equal("uri", exception.ParamName); + Assert.Contains(SR.GetResourceString(SR.ID0072), exception.Message); + } + + [Theory] + [InlineData("~/path")] + public void SetMtlsTokenEndpointAliasUri_ThrowsExceptionForInvalidRelativeUri(string uri) + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act and assert + var exception = Assert.Throws(() => builder.SetMtlsTokenEndpointAliasUri(new Uri(uri, UriKind.RelativeOrAbsolute))); + Assert.Equal("uri", exception.ParamName); + Assert.Contains(SR.FormatID0081("~"), exception.Message); + } + + [Fact] + public void SetMtlsTokenEndpointAliasUri_AddsUri() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act + builder.SetMtlsTokenEndpointAliasUri("http://localhost/endpoint-path"); + + var options = GetOptions(services); + + // Assert + Assert.Equal(new Uri("http://localhost/endpoint-path"), options.MtlsTokenEndpointAliasUri); + } + [Fact] public void SetIntrospectionEndpointUris_ThrowsExceptionWhenUrisIsNull() {