diff --git a/gen/OpenIddict.Client.WebIntegration.Generators/OpenIddictClientWebIntegrationGenerator.cs b/gen/OpenIddict.Client.WebIntegration.Generators/OpenIddictClientWebIntegrationGenerator.cs index 92726f85..3da86db5 100644 --- a/gen/OpenIddict.Client.WebIntegration.Generators/OpenIddictClientWebIntegrationGenerator.cs +++ b/gen/OpenIddict.Client.WebIntegration.Generators/OpenIddictClientWebIntegrationGenerator.cs @@ -124,6 +124,23 @@ public sealed partial class OpenIddictClientWebIntegrationBuilder [EditorBrowsable(EditorBrowsableState.Never)] public OpenIddictClientRegistration Registration { get; } + /// + /// Adds one or more client authentication methods to the list of client authentication methods that can be negotiated for this provider. + /// + /// The client authentication methods. + /// Note: explicitly configuring the allowed client authentication methods is NOT recommended in most cases. + /// The instance. + [EditorBrowsable(EditorBrowsableState.Advanced)] + public {{ provider.name }} AddClientAuthenticationMethods(params string[] methods) + { + if (methods is null) + { + throw new ArgumentNullException(nameof(methods)); + } + + return Set(registration => registration.ClientAuthenticationMethods.UnionWith(methods)); + } + /// /// Adds one or more code challenge methods to the list of code challenge methods that can be negotiated for this provider. /// @@ -791,17 +808,23 @@ public sealed partial class OpenIddictClientWebIntegrationBuilder ClrType = (string) setting.Attribute("Type") switch { "Boolean" => "bool", - "EncryptionKey" when (string) setting.Element("EncryptionAlgorithm").Attribute("Value") - is "RS256" or "RS384" or "RS512" => "RsaSecurityKey", + "EncryptionKey" => (string?) setting.Element("EncryptionAlgorithm")?.Attribute("Value") switch + { + "RS256" or "RS384" or "RS512" => "RsaSecurityKey", - "SigningKey" when (string) setting.Element("SigningAlgorithm").Attribute("Value") - is "ES256" or "ES384" or "ES512" => "ECDsaSecurityKey", + _ => "SecurityKey" + }, - "SigningKey" when (string) setting.Element("SigningAlgorithm").Attribute("Value") - is "PS256" or "PS384" or "PS512" or - "RS256" or "RS384" or "RS512" => "RsaSecurityKey", + "SigningCertificate" => "X509Certificate2", + + "SigningKey" => (string?) setting.Element("SigningAlgorithm")?.Attribute("Value") switch + { + "ES256" or "ES384" or "ES512" => "ECDsaSecurityKey", + "PS256" or "PS384" or "PS512" or "RS256" or "RS384" or "RS512" => "RsaSecurityKey", + + _ => "SecurityKey" + }, - "Certificate" => "X509Certificate2", "String" => "string", "StringHashSet" => "HashSet", "Uri" => "Uri", @@ -1121,13 +1144,111 @@ public sealed partial class OpenIddictClientWebIntegrationConfiguration {{~ for setting in provider.settings ~}} {{~ if setting.type == 'EncryptionKey' ~}} - registration.EncryptionCredentials.Add(new EncryptingCredentials(settings.{{ setting.property_name }}, ""{{ setting.encryption_algorithm }}"", SecurityAlgorithms.Aes256CbcHmacSha512)); + if (settings.{{ setting.property_name }} is not null) + { + registration.EncryptionCredentials.Add(new EncryptingCredentials(settings.{{ setting.property_name }}, ""{{ setting.encryption_algorithm }}"", SecurityAlgorithms.Aes256CbcHmacSha512)); + } {{~ end ~}} {{~ end ~}} {{~ for setting in provider.settings ~}} + {{~ if setting.type == 'SigningCertificate' ~}} + if (settings.{{ setting.property_name }} is not null) + { + var key = new X509SecurityKey(settings.{{ setting.property_name }}); + if (key.IsSupportedAlgorithm(SecurityAlgorithms.RsaSha256)) + { + registration.SigningCredentials.Add(new SigningCredentials(key, SecurityAlgorithms.RsaSha256)); + } + + else if (key.IsSupportedAlgorithm(SecurityAlgorithms.HmacSha256)) + { + registration.SigningCredentials.Add(new SigningCredentials(key, SecurityAlgorithms.HmacSha256)); + } + +#if SUPPORTS_ECDSA + // Note: ECDSA algorithms are bound to specific curves and must be treated separately. + else if (key.IsSupportedAlgorithm(SecurityAlgorithms.EcdsaSha256)) + { + registration.SigningCredentials.Add(new SigningCredentials(key, SecurityAlgorithms.EcdsaSha256)); + } + + else if (key.IsSupportedAlgorithm(SecurityAlgorithms.EcdsaSha384)) + { + registration.SigningCredentials.Add(new SigningCredentials(key, SecurityAlgorithms.EcdsaSha384)); + } + + else if (key.IsSupportedAlgorithm(SecurityAlgorithms.EcdsaSha512)) + { + registration.SigningCredentials.Add(new SigningCredentials(key, SecurityAlgorithms.EcdsaSha512)); + } +#else + else if (key.IsSupportedAlgorithm(SecurityAlgorithms.EcdsaSha256) || + key.IsSupportedAlgorithm(SecurityAlgorithms.EcdsaSha384) || + key.IsSupportedAlgorithm(SecurityAlgorithms.EcdsaSha512)) + { + throw new PlatformNotSupportedException(SR.GetResourceString(SR.ID0069)); + } +#endif + else + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0068)); + } + } + {{~ end ~}} {{~ if setting.type == 'SigningKey' ~}} - registration.SigningCredentials.Add(new SigningCredentials(settings.{{ setting.property_name }}, ""{{ setting.signing_algorithm }}"")); + 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) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0067)); + } + + {{~ if setting.signing_algorithm ~}} + registration.SigningCredentials.Add(new SigningCredentials(settings.{{ setting.property_name }}, ""{{ setting.signing_algorithm }}"")); + {{~ else ~}} + if (settings.{{ setting.property_name }}.IsSupportedAlgorithm(SecurityAlgorithms.RsaSha256)) + { + registration.SigningCredentials.Add(new SigningCredentials(settings.{{ setting.property_name }}, SecurityAlgorithms.RsaSha256)); + } + + else if (settings.{{ setting.property_name }}.IsSupportedAlgorithm(SecurityAlgorithms.HmacSha256)) + { + registration.SigningCredentials.Add(new SigningCredentials(settings.{{ setting.property_name }}, SecurityAlgorithms.HmacSha256)); + } + +#if SUPPORTS_ECDSA + // Note: ECDSA algorithms are bound to specific curves and must be treated separately. + else if (settings.{{ setting.property_name }}.IsSupportedAlgorithm(SecurityAlgorithms.EcdsaSha256)) + { + registration.SigningCredentials.Add(new SigningCredentials(settings.{{ setting.property_name }}, SecurityAlgorithms.EcdsaSha256)); + } + + else if (settings.{{ setting.property_name }}.IsSupportedAlgorithm(SecurityAlgorithms.EcdsaSha384)) + { + registration.SigningCredentials.Add(new SigningCredentials(settings.{{ setting.property_name }}, SecurityAlgorithms.EcdsaSha384)); + } + + else if (settings.{{ setting.property_name }}.IsSupportedAlgorithm(SecurityAlgorithms.EcdsaSha512)) + { + registration.SigningCredentials.Add(new SigningCredentials(settings.{{ setting.property_name }}, SecurityAlgorithms.EcdsaSha512)); + } +#else + else if (settings.{{ setting.property_name }}.IsSupportedAlgorithm(SecurityAlgorithms.EcdsaSha256) || + settings.{{ setting.property_name }}.IsSupportedAlgorithm(SecurityAlgorithms.EcdsaSha384) || + settings.{{ setting.property_name }}.IsSupportedAlgorithm(SecurityAlgorithms.EcdsaSha512)) + { + throw new PlatformNotSupportedException(SR.GetResourceString(SR.ID0069)); + } +#endif + else + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0068)); + } + {{~ end ~}} + } {{~ end ~}} {{~ end ~}} } @@ -1379,17 +1500,23 @@ public sealed partial class OpenIddictClientWebIntegrationSettings ClrType = (string) setting.Attribute("Type") switch { "Boolean" => "bool", - "EncryptionKey" when (string) setting.Element("EncryptionAlgorithm").Attribute("Value") - is "RS256" or "RS384" or "RS512" => "RsaSecurityKey", + "EncryptionKey" => (string?) setting.Element("EncryptionAlgorithm")?.Attribute("Value") switch + { + "RS256" or "RS384" or "RS512" => "RsaSecurityKey", - "SigningKey" when (string) setting.Element("SigningAlgorithm").Attribute("Value") - is "ES256" or "ES384" or "ES512" => "ECDsaSecurityKey", + _ => "SecurityKey" + }, - "SigningKey" when (string) setting.Element("SigningAlgorithm").Attribute("Value") - is "PS256" or "PS384" or "PS512" or - "RS256" or "RS384" or "RS512" => "RsaSecurityKey", + "SigningCertificate" => "X509Certificate2", + + "SigningKey" => (string?) setting.Element("SigningAlgorithm")?.Attribute("Value") switch + { + "ES256" or "ES384" or "ES512" => "ECDsaSecurityKey", + "PS256" or "PS384" or "PS512" or "RS256" or "RS384" or "RS512" => "RsaSecurityKey", + + _ => "SecurityKey" + }, - "Certificate" => "X509Certificate2", "String" => "string", "StringHashSet" => "HashSet", "Uri" => "Uri", diff --git a/src/OpenIddict.Abstractions/OpenIddictConstants.cs b/src/OpenIddict.Abstractions/OpenIddictConstants.cs index 728a42bf..d53e17e4 100644 --- a/src/OpenIddict.Abstractions/OpenIddictConstants.cs +++ b/src/OpenIddict.Abstractions/OpenIddictConstants.cs @@ -178,6 +178,8 @@ public static class OpenIddictConstants public const string ClientSecretPost = "client_secret_post"; public const string None = "none"; public const string PrivateKeyJwt = "private_key_jwt"; + public const string SelfSignedTlsClientAuth = "self_signed_tls_client_auth"; + public const string TlsClientAuth = "tls_client_auth"; } public static class ClientTypes @@ -290,6 +292,7 @@ public static class OpenIddictConstants public const string IntrospectionEndpointAuthSigningAlgValuesSupported = "introspection_endpoint_auth_signing_alg_values_supported"; public const string Issuer = "issuer"; public const string JwksUri = "jwks_uri"; + public const string MtlsEndpointAliases = "mtls_endpoint_aliases"; public const string OpPolicyUri = "op_policy_uri"; public const string OpTosUri = "op_tos_uri"; public const string RequestObjectEncryptionAlgValuesSupported = "request_object_encryption_alg_values_supported"; @@ -306,6 +309,7 @@ public static class OpenIddictConstants public const string ScopesSupported = "scopes_supported"; public const string ServiceDocumentation = "service_documentation"; public const string SubjectTypesSupported = "subject_types_supported"; + public const string TlsClientCertificateBoundAccessTokens = "tls_client_certificate_bound_access_tokens"; public const string TokenEndpoint = "token_endpoint"; public const string TokenEndpointAuthMethodsSupported = "token_endpoint_auth_methods_supported"; public const string TokenEndpointAuthSigningAlgValuesSupported = "token_endpoint_auth_signing_alg_values_supported"; @@ -531,6 +535,12 @@ public static class OpenIddictConstants public const string Public = "public"; } + public static class TokenBindingMethods + { + public const string SelfSignedTlsClientCertificate = "self_signed_tls_client_certificate"; + public const string TlsClientCertificate = "tls_client_certificate"; + } + public static class TokenFormats { public const string Jwt = "urn:ietf:params:oauth:token-type:jwt"; diff --git a/src/OpenIddict.Abstractions/OpenIddictResources.resx b/src/OpenIddict.Abstractions/OpenIddictResources.resx index 901b978e..3fa78aca 100644 --- a/src/OpenIddict.Abstractions/OpenIddictResources.resx +++ b/src/OpenIddict.Abstractions/OpenIddictResources.resx @@ -1698,6 +1698,12 @@ To apply post-logout redirection responses, create a class implementing 'IOpenId The format of the specified certificate is not supported. + + Registration identifiers cannot contain U+001E or U+001F characters. + + + The specified client authentication method/token binding methods combination is not valid. + The security token is missing. diff --git a/src/OpenIddict.Abstractions/Primitives/OpenIddictConfiguration.cs b/src/OpenIddict.Abstractions/Primitives/OpenIddictConfiguration.cs index 35ea9b0a..3b357df8 100644 --- a/src/OpenIddict.Abstractions/Primitives/OpenIddictConfiguration.cs +++ b/src/OpenIddict.Abstractions/Primitives/OpenIddictConfiguration.cs @@ -76,6 +76,31 @@ public sealed class OpenIddictConfiguration /// public Uri? JwksUri { get; set; } + /// + /// Gets or sets the URI of the mTLS-enabled device authorization endpoint. + /// + public Uri? MtlsDeviceAuthorizationEndpoint { get; set; } + + /// + /// Gets or sets the URI of the mTLS-enabled introspection endpoint. + /// + public Uri? MtlsIntrospectionEndpoint { get; set; } + + /// + /// Gets or sets the URI of the mTLS-enabled revocation endpoint. + /// + public Uri? MtlsRevocationEndpoint { get; set; } + + /// + /// Gets or sets the URI of the mTLS-enabled token endpoint. + /// + public Uri? MtlsTokenEndpoint { get; set; } + + /// + /// Gets or sets the URI of the mTLS-enabled userinfo endpoint. + /// + public Uri? MtlsUserInfoEndpoint { get; set; } + /// /// Gets the additional properties. /// @@ -111,6 +136,12 @@ public sealed class OpenIddictConfiguration /// public List SigningKeys { get; } = []; + /// + /// Gets or sets a boolean indicating whether access tokens issued by the + /// authorization server are bound to the client certificate when using mTLS. + /// + public bool? TlsClientCertificateBoundAccessTokens { get; set; } + /// /// Gets or sets the URI of the token endpoint. /// diff --git a/src/OpenIddict.Client.SystemIntegration/OpenIddictClientSystemIntegrationConfiguration.cs b/src/OpenIddict.Client.SystemIntegration/OpenIddictClientSystemIntegrationConfiguration.cs index 48a8ddfe..247beddf 100644 --- a/src/OpenIddict.Client.SystemIntegration/OpenIddictClientSystemIntegrationConfiguration.cs +++ b/src/OpenIddict.Client.SystemIntegration/OpenIddictClientSystemIntegrationConfiguration.cs @@ -50,6 +50,9 @@ public sealed class OpenIddictClientSystemIntegrationConfiguration : IConfigureO // Register the built-in event handlers used by the OpenIddict client system integration components. options.Handlers.AddRange(OpenIddictClientSystemIntegrationHandlers.DefaultHandlers); + + // Enable response_mode=fragment support by default. + options.ResponseModes.Add(ResponseModes.Fragment); } /// diff --git a/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpBuilder.cs b/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpBuilder.cs index f9ac960d..8aa39b44 100644 --- a/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpBuilder.cs +++ b/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpBuilder.cs @@ -9,6 +9,7 @@ using System.Net.Http; using System.Net.Http.Headers; using System.Net.Mail; using System.Reflection; +using System.Security.Cryptography.X509Certificates; using OpenIddict.Client; using OpenIddict.Client.SystemNetHttp; using Polly; @@ -339,6 +340,54 @@ public sealed class OpenIddictClientSystemNetHttpBuilder productVersion: assembly.GetName().Version!.ToString())); } + /// + /// Sets the delegate called by OpenIddict when trying to resolve the self-signed + /// TLS client authentication certificate that will be used for OAuth 2.0 + /// mTLS-based client authentication (self_signed_tls_client_auth), if applicable. + /// + /// The selector delegate. + /// + /// If no value is explicitly set, OpenIddict automatically tries to resolve the + /// X.509 certificate from the signing credentials attached to the client registration + /// (in this case, the X.509 certificate MUST include the digital signature and + /// client authentication key usages to be automatically selected by OpenIddict). + /// + /// The instance. + public OpenIddictClientSystemNetHttpBuilder SetSelfSignedTlsClientAuthenticationCertificateSelector( + Func selector) + { + if (selector is null) + { + throw new ArgumentNullException(nameof(selector)); + } + + return Configure(options => options.SelfSignedTlsClientAuthenticationCertificateSelector = selector); + } + + /// + /// Sets the delegate called by OpenIddict when trying to resolve the + /// TLS client authentication certificate that will be used for OAuth 2.0 + /// mTLS-based client authentication (tls_client_auth), if applicable. + /// + /// The selector delegate. + /// + /// If no value is explicitly set, OpenIddict automatically tries to resolve the + /// X.509 certificate from the signing credentials attached to the client registration + /// (in this case, the X.509 certificate MUST include the digital signature and + /// client authentication key usages to be automatically selected by OpenIddict). + /// + /// The instance. + public OpenIddictClientSystemNetHttpBuilder SetTlsClientAuthenticationCertificateSelector( + Func selector) + { + if (selector is null) + { + throw new ArgumentNullException(nameof(selector)); + } + + return Configure(options => options.TlsClientAuthenticationCertificateSelector = selector); + } + /// [EditorBrowsable(EditorBrowsableState.Never)] public override bool Equals(object? obj) => base.Equals(obj); diff --git a/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpConfiguration.cs b/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpConfiguration.cs index 78cf46a3..781f3317 100644 --- a/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpConfiguration.cs +++ b/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpConfiguration.cs @@ -5,12 +5,14 @@ */ using System.ComponentModel; -using System.Diagnostics.CodeAnalysis; using System.Net; using System.Net.Http; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Http; using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; using Polly; #if SUPPORTS_HTTP_CLIENT_RESILIENCE @@ -25,7 +27,8 @@ namespace OpenIddict.Client.SystemNetHttp; [EditorBrowsable(EditorBrowsableState.Advanced)] public sealed class OpenIddictClientSystemNetHttpConfiguration : IConfigureOptions, IConfigureNamedOptions, - IPostConfigureOptions + IPostConfigureOptions, + IPostConfigureOptions { private readonly IServiceProvider _provider; @@ -46,6 +49,11 @@ public sealed class OpenIddictClientSystemNetHttpConfiguration : IConfigureOptio // Register the built-in event handlers used by the OpenIddict System.Net.Http client components. options.Handlers.AddRange(OpenIddictClientSystemNetHttpHandlers.DefaultHandlers); + + // Enable client_secret_basic, self_signed_tls_client_auth and tls_client_auth support by default. + options.ClientAuthenticationMethods.Add(ClientAuthenticationMethods.ClientSecretBasic); + options.ClientAuthenticationMethods.Add(ClientAuthenticationMethods.SelfSignedTlsClientAuth); + options.ClientAuthenticationMethods.Add(ClientAuthenticationMethods.TlsClientAuth); } /// @@ -59,8 +67,29 @@ public sealed class OpenIddictClientSystemNetHttpConfiguration : IConfigureOptio throw new ArgumentNullException(nameof(options)); } + var assembly = typeof(OpenIddictClientSystemNetHttpOptions).Assembly.GetName(); + // Only amend the HTTP client factory options if the instance is managed by OpenIddict. - if (string.IsNullOrEmpty(name) || !TryResolveRegistrationId(name, out string? identifier)) + if (string.IsNullOrEmpty(name) || !name.StartsWith(assembly.Name!, StringComparison.Ordinal)) + { + return; + } + + // Note: HttpClientFactory doesn't support flowing a list of properties that can be + // accessed from the HttpClientAction or HttpMessageHandlerBuilderAction delegates + // to dynamically amend the resulting HttpClient or HttpClientHandler instance. + // + // To work around this limitation, the OpenIddict System.Net.Http integration uses + // dynamic client names and supports appending a list of key-value pairs to the client + // name to flow per-instance properties (e.g the negotiated client authentication method). + var properties = name.Length >= assembly.Name!.Length + 1 && name[assembly.Name.Length] is ':' ? + name[(assembly.Name.Length + 1)..] + .Split(['\u001f'], StringSplitOptions.RemoveEmptyEntries) + .Select(static property => property.Split(['\u001e'], StringSplitOptions.RemoveEmptyEntries)) + .Where(static values => values is [{ Length: > 0 }, { Length: > 0 }]) + .ToDictionary(static values => values[0], static values => values[1]) : []; + + if (!properties.TryGetValue("RegistrationId", out string? identifier) || string.IsNullOrEmpty(identifier)) { return; } @@ -113,6 +142,32 @@ public sealed class OpenIddictClientSystemNetHttpConfiguration : IConfigureOptio #pragma warning restore EXTEXP0001 } #endif + if (builder.PrimaryHandler is not HttpClientHandler handler) + { + throw new InvalidOperationException(SR.FormatID0373(typeof(HttpClientHandler).FullName)); + } + + handler.ClientCertificateOptions = ClientCertificateOption.Manual; + + if (properties.TryGetValue("AttachTlsClientCertificate", out string? value) && + bool.TryParse(value, out bool result) && result) + { + var certificate = options.CurrentValue.TlsClientAuthenticationCertificateSelector(registration); + if (certificate is not null) + { + handler.ClientCertificates.Add(certificate); + } + } + + else if (properties.TryGetValue("AttachSelfSignedTlsClientCertificate", out value) && + bool.TryParse(value, out result) && result) + { + var certificate = options.CurrentValue.SelfSignedTlsClientAuthenticationCertificateSelector(registration); + if (certificate is not null) + { + handler.ClientCertificates.Add(certificate); + } + } }); // Register the user-defined HTTP client handler actions. @@ -132,8 +187,29 @@ public sealed class OpenIddictClientSystemNetHttpConfiguration : IConfigureOptio throw new ArgumentNullException(nameof(options)); } + var assembly = typeof(OpenIddictClientSystemNetHttpOptions).Assembly.GetName(); + // Only amend the HTTP client factory options if the instance is managed by OpenIddict. - if (string.IsNullOrEmpty(name) || !TryResolveRegistrationId(name, out string? identifier)) + if (string.IsNullOrEmpty(name) || !name.StartsWith(assembly.Name!, StringComparison.Ordinal)) + { + return; + } + + // Note: HttpClientFactory doesn't support flowing a list of properties that can be + // accessed from the HttpClientAction or HttpMessageHandlerBuilderAction delegates + // to dynamically amend the resulting HttpClient or HttpClientHandler instance. + // + // To work around this limitation, the OpenIddict System.Net.Http integration uses dynamic + // client names and supports appending a list of key-value pairs to the client name to flow + // per-instance properties (e.g a flag indicating whether a client certificate should be used). + var properties = name.Length >= assembly.Name!.Length + 1 && name[assembly.Name.Length] is ':' ? + name[(assembly.Name.Length + 1)..] + .Split(['\u001f'], StringSplitOptions.RemoveEmptyEntries) + .Select(static property => property.Split(['\u001e'], StringSplitOptions.RemoveEmptyEntries)) + .Where(static values => values is [{ Length: > 0 }, { Length: > 0 }]) + .ToDictionary(static values => values[0], static values => values[1]) : []; + + if (!properties.TryGetValue("RegistrationId", out string? identifier) || string.IsNullOrEmpty(identifier)) { return; } @@ -194,19 +270,94 @@ public sealed class OpenIddictClientSystemNetHttpConfiguration : IConfigureOptio }); } - static bool TryResolveRegistrationId(string name, [NotNullWhen(true)] out string? value) + /// + public void PostConfigure(string? name, OpenIddictClientSystemNetHttpOptions options) { - var assembly = typeof(OpenIddictClientSystemNetHttpOptions).Assembly.GetName(); + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + // If no client authentication certificate selector was provided, use fallback delegates that + // automatically use the first X.509 signing certificate attached to the client registration + // that is suitable for both digital signature and client authentication. + + options.SelfSignedTlsClientAuthenticationCertificateSelector ??= static registration => + { + foreach (var credentials in registration.SigningCredentials) + { + if (credentials.Key is X509SecurityKey { Certificate: X509Certificate2 certificate } && + certificate.Version is >= 3 && IsSelfIssuedCertificate(certificate) && + HasDigitalSignatureKeyUsage(certificate) && + HasClientAuthenticationExtendedKeyUsage(certificate)) + { + return certificate; + } + } + + return null; + }; + + options.TlsClientAuthenticationCertificateSelector ??= static registration => + { + foreach (var credentials in registration.SigningCredentials) + { + if (credentials.Key is X509SecurityKey { Certificate: X509Certificate2 certificate } && + certificate.Version is >= 3 && !IsSelfIssuedCertificate(certificate) && + HasDigitalSignatureKeyUsage(certificate) && + HasClientAuthenticationExtendedKeyUsage(certificate)) + { + return certificate; + } + } + + return null; + }; - if (!name.StartsWith(assembly.Name!, StringComparison.Ordinal) || - name.Length < assembly.Name!.Length + 1 || - name[assembly.Name.Length] is not ':') + static bool HasClientAuthenticationExtendedKeyUsage(X509Certificate2 certificate) { - value = null; + 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; } - value = name[(assembly.Name.Length + 1)..]; - return true; + // 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.SystemNetHttp/OpenIddictClientSystemNetHttpExtensions.cs b/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpExtensions.cs index 544053ec..2273ba1a 100644 --- a/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpExtensions.cs +++ b/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpExtensions.cs @@ -49,6 +49,9 @@ public static class OpenIddictClientSystemNetHttpExtensions builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton< IPostConfigureOptions, OpenIddictClientSystemNetHttpConfiguration>()); + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton< + IPostConfigureOptions, OpenIddictClientSystemNetHttpConfiguration>()); + return new OpenIddictClientSystemNetHttpBuilder(builder.Services); } diff --git a/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.Device.cs b/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.Device.cs index b0ef42f7..f848d329 100644 --- a/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.Device.cs +++ b/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.Device.cs @@ -5,10 +5,6 @@ */ using System.Collections.Immutable; -using System.Diagnostics; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Text; namespace OpenIddict.Client.SystemNetHttp; @@ -26,7 +22,7 @@ public static partial class OpenIddictClientSystemNetHttpHandlers AttachJsonAcceptHeaders.Descriptor, AttachUserAgentHeader.Descriptor, AttachFromHeader.Descriptor, - AttachBasicAuthenticationCredentials.Descriptor, + AttachBasicAuthenticationCredentials.Descriptor, AttachHttpParameters.Descriptor, SendHttpRequest.Descriptor, DisposeHttpRequest.Descriptor, @@ -40,94 +36,5 @@ public static partial class OpenIddictClientSystemNetHttpHandlers ValidateHttpResponse.Descriptor, DisposeHttpResponse.Descriptor ]); - - /// - /// Contains the logic responsible for attaching the client credentials to the HTTP Authorization header. - /// - public sealed class AttachBasicAuthenticationCredentials : IOpenIddictClientHandler - { - /// - /// Gets the default descriptor definition assigned to this handler. - /// - public static OpenIddictClientHandlerDescriptor Descriptor { get; } - = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() - .UseSingletonHandler() - .SetOrder(AttachHttpParameters.Descriptor.Order - 500) - .SetType(OpenIddictClientHandlerType.BuiltIn) - .Build(); - - /// - public ValueTask HandleAsync(PrepareDeviceAuthorizationRequestContext context) - { - if (context is null) - { - throw new ArgumentNullException(nameof(context)); - } - - Debug.Assert(context.Request is not null, SR.GetResourceString(SR.ID4008)); - - // This handler only applies to System.Net.Http requests. If the HTTP request cannot be resolved, - // this may indicate that the request was incorrectly processed by another client stack. - var request = context.Transaction.GetHttpRequestMessage() ?? - throw new InvalidOperationException(SR.GetResourceString(SR.ID0173)); - - // The OAuth 2.0 specification recommends sending the client credentials using basic authentication. - // However, this authentication method is known to have severe compatibility/interoperability issues: - // - // - While restricted to clients that have been given a secret (i.e confidential clients) by the - // specification, basic authentication is also sometimes required by server implementations for - // public clients that don't have a client secret: in this case, an empty password is used and - // the client identifier is sent alone in the Authorization header (instead of being sent using - // the standard "client_id" parameter present in the request body). - // - // - While the OAuth 2.0 specification requires that the client credentials be formURL-encoded - // before being base64-encoded, many implementations are known to implement a non-standard - // encoding scheme, where neither the client_id nor the client_secret are formURL-encoded. - // - // To guarantee that the OpenIddict implementation can be used with most servers implementions, - // basic authentication is only used when a client secret is present and client_secret_post is - // always preferred when it's explicitly listed as a supported client authentication method. - // If client_secret_post is not listed or if the server returned an empty methods list, - // client_secret_basic is always used, as it MUST be implemented by all OAuth 2.0 servers. - // - // See https://tools.ietf.org/html/rfc8414#section-2 - // and https://tools.ietf.org/html/rfc6749#section-2.3.1 for more information. - if (request.Headers.Authorization is null && - !string.IsNullOrEmpty(context.Request.ClientId) && - !string.IsNullOrEmpty(context.Request.ClientSecret) && - UseBasicAuthentication(context.Configuration)) - { - // Important: the credentials MUST be formURL-encoded before being base64-encoded. - var credentials = Convert.ToBase64String(Encoding.ASCII.GetBytes(new StringBuilder() - .Append(EscapeDataString(context.Request.ClientId)) - .Append(':') - .Append(EscapeDataString(context.Request.ClientSecret)) - .ToString())); - - // Attach the authorization header containing the client credentials to the HTTP request. - request.Headers.Authorization = new AuthenticationHeaderValue(Schemes.Basic, credentials); - - // Remove the client credentials from the request payload to ensure they are not sent twice. - context.Request.ClientId = context.Request.ClientSecret = null; - } - - return default; - - static bool UseBasicAuthentication(OpenIddictConfiguration configuration) - => configuration.DeviceAuthorizationEndpointAuthMethodsSupported switch - { - // If at least one authentication method was explicit added, only use basic authentication - // if it's supported AND if client_secret_post is not supported or enabled by the server. - { Count: > 0 } methods => methods.Contains(ClientAuthenticationMethods.ClientSecretBasic) && - !methods.Contains(ClientAuthenticationMethods.ClientSecretPost), - - // Otherwise, if no authentication method was explicit added, assume only basic is supported. - { Count: _ } => true - }; - - static string EscapeDataString(string value) => Uri.EscapeDataString(value).Replace("%20", "+"); - } - } } } diff --git a/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.Exchange.cs b/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.Exchange.cs index 11f54ce5..bc024fd8 100644 --- a/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.Exchange.cs +++ b/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.Exchange.cs @@ -26,7 +26,7 @@ public static partial class OpenIddictClientSystemNetHttpHandlers AttachJsonAcceptHeaders.Descriptor, AttachUserAgentHeader.Descriptor, AttachFromHeader.Descriptor, - AttachBasicAuthenticationCredentials.Descriptor, + AttachBasicAuthenticationCredentials.Descriptor, AttachHttpParameters.Descriptor, SendHttpRequest.Descriptor, DisposeHttpRequest.Descriptor, @@ -40,94 +40,5 @@ public static partial class OpenIddictClientSystemNetHttpHandlers ValidateHttpResponse.Descriptor, DisposeHttpResponse.Descriptor ]); - - /// - /// Contains the logic responsible for attaching the client credentials to the HTTP Authorization header. - /// - public sealed class AttachBasicAuthenticationCredentials : IOpenIddictClientHandler - { - /// - /// Gets the default descriptor definition assigned to this handler. - /// - public static OpenIddictClientHandlerDescriptor Descriptor { get; } - = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() - .UseSingletonHandler() - .SetOrder(AttachHttpParameters.Descriptor.Order - 500) - .SetType(OpenIddictClientHandlerType.BuiltIn) - .Build(); - - /// - public ValueTask HandleAsync(PrepareTokenRequestContext context) - { - if (context is null) - { - throw new ArgumentNullException(nameof(context)); - } - - Debug.Assert(context.Request is not null, SR.GetResourceString(SR.ID4008)); - - // This handler only applies to System.Net.Http requests. If the HTTP request cannot be resolved, - // this may indicate that the request was incorrectly processed by another client stack. - var request = context.Transaction.GetHttpRequestMessage() ?? - throw new InvalidOperationException(SR.GetResourceString(SR.ID0173)); - - // The OAuth 2.0 specification recommends sending the client credentials using basic authentication. - // However, this authentication method is known to have severe compatibility/interoperability issues: - // - // - While restricted to clients that have been given a secret (i.e confidential clients) by the - // specification, basic authentication is also sometimes required by server implementations for - // public clients that don't have a client secret: in this case, an empty password is used and - // the client identifier is sent alone in the Authorization header (instead of being sent using - // the standard "client_id" parameter present in the request body). - // - // - While the OAuth 2.0 specification requires that the client credentials be formURL-encoded - // before being base64-encoded, many implementations are known to implement a non-standard - // encoding scheme, where neither the client_id nor the client_secret are formURL-encoded. - // - // To guarantee that the OpenIddict implementation can be used with most servers implementions, - // basic authentication is only used when a client secret is present and client_secret_post is - // always preferred when it's explicitly listed as a supported client authentication method. - // If client_secret_post is not listed or if the server returned an empty methods list, - // client_secret_basic is always used, as it MUST be implemented by all OAuth 2.0 servers. - // - // See https://tools.ietf.org/html/rfc8414#section-2 - // and https://tools.ietf.org/html/rfc6749#section-2.3.1 for more information. - if (request.Headers.Authorization is null && - !string.IsNullOrEmpty(context.Request.ClientId) && - !string.IsNullOrEmpty(context.Request.ClientSecret) && - UseBasicAuthentication(context.Configuration)) - { - // Important: the credentials MUST be formURL-encoded before being base64-encoded. - var credentials = Convert.ToBase64String(Encoding.ASCII.GetBytes(new StringBuilder() - .Append(EscapeDataString(context.Request.ClientId)) - .Append(':') - .Append(EscapeDataString(context.Request.ClientSecret)) - .ToString())); - - // Attach the authorization header containing the client credentials to the HTTP request. - request.Headers.Authorization = new AuthenticationHeaderValue(Schemes.Basic, credentials); - - // Remove the client credentials from the request payload to ensure they are not sent twice. - context.Request.ClientId = context.Request.ClientSecret = null; - } - - return default; - - static bool UseBasicAuthentication(OpenIddictConfiguration configuration) - => configuration.TokenEndpointAuthMethodsSupported switch - { - // If at least one authentication method was explicit added, only use basic authentication - // if it's supported AND if client_secret_post is not supported or enabled by the server. - { Count: > 0 } methods => methods.Contains(ClientAuthenticationMethods.ClientSecretBasic) && - !methods.Contains(ClientAuthenticationMethods.ClientSecretPost), - - // Otherwise, if no authentication method was explicit added, assume only basic is supported. - { Count: _ } => true - }; - - static string EscapeDataString(string value) => Uri.EscapeDataString(value).Replace("%20", "+"); - } - } } } diff --git a/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.Introspection.cs b/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.Introspection.cs index fd4a390d..f9f45e45 100644 --- a/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.Introspection.cs +++ b/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.Introspection.cs @@ -26,7 +26,7 @@ public static partial class OpenIddictClientSystemNetHttpHandlers AttachJsonAcceptHeaders.Descriptor, AttachUserAgentHeader.Descriptor, AttachFromHeader.Descriptor, - AttachBasicAuthenticationCredentials.Descriptor, + AttachBasicAuthenticationCredentials.Descriptor, AttachHttpParameters.Descriptor, SendHttpRequest.Descriptor, DisposeHttpRequest.Descriptor, @@ -40,94 +40,5 @@ public static partial class OpenIddictClientSystemNetHttpHandlers ValidateHttpResponse.Descriptor, DisposeHttpResponse.Descriptor ]); - - /// - /// Contains the logic responsible for attaching the client credentials to the HTTP Authorization header. - /// - public sealed class AttachBasicAuthenticationCredentials : IOpenIddictClientHandler - { - /// - /// Gets the default descriptor definition assigned to this handler. - /// - public static OpenIddictClientHandlerDescriptor Descriptor { get; } - = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() - .UseSingletonHandler() - .SetOrder(AttachHttpParameters.Descriptor.Order - 500) - .SetType(OpenIddictClientHandlerType.BuiltIn) - .Build(); - - /// - public ValueTask HandleAsync(PrepareIntrospectionRequestContext context) - { - if (context is null) - { - throw new ArgumentNullException(nameof(context)); - } - - Debug.Assert(context.Request is not null, SR.GetResourceString(SR.ID4008)); - - // This handler only applies to System.Net.Http requests. If the HTTP request cannot be resolved, - // this may indicate that the request was incorrectly processed by another client stack. - var request = context.Transaction.GetHttpRequestMessage() ?? - throw new InvalidOperationException(SR.GetResourceString(SR.ID0173)); - - // The OAuth 2.0 specification recommends sending the client credentials using basic authentication. - // However, this authentication method is known to have severe compatibility/interoperability issues: - // - // - While restricted to clients that have been given a secret (i.e confidential clients) by the - // specification, basic authentication is also sometimes required by server implementations for - // public clients that don't have a client secret: in this case, an empty password is used and - // the client identifier is sent alone in the Authorization header (instead of being sent using - // the standard "client_id" parameter present in the request body). - // - // - While the OAuth 2.0 specification requires that the client credentials be formURL-encoded - // before being base64-encoded, many implementations are known to implement a non-standard - // encoding scheme, where neither the client_id nor the client_secret are formURL-encoded. - // - // To guarantee that the OpenIddict implementation can be used with most servers implementions, - // basic authentication is only used when a client secret is present and client_secret_post is - // always preferred when it's explicitly listed as a supported client authentication method. - // If client_secret_post is not listed or if the server returned an empty methods list, - // client_secret_basic is always used, as it MUST be implemented by all OAuth 2.0 servers. - // - // See https://tools.ietf.org/html/rfc8414#section-2 - // and https://tools.ietf.org/html/rfc6749#section-2.3.1 for more information. - if (request.Headers.Authorization is null && - !string.IsNullOrEmpty(context.Request.ClientId) && - !string.IsNullOrEmpty(context.Request.ClientSecret) && - UseBasicAuthentication(context.Configuration)) - { - // Important: the credentials MUST be formURL-encoded before being base64-encoded. - var credentials = Convert.ToBase64String(Encoding.ASCII.GetBytes(new StringBuilder() - .Append(EscapeDataString(context.Request.ClientId)) - .Append(':') - .Append(EscapeDataString(context.Request.ClientSecret)) - .ToString())); - - // Attach the authorization header containing the client credentials to the HTTP request. - request.Headers.Authorization = new AuthenticationHeaderValue(Schemes.Basic, credentials); - - // Remove the client credentials from the request payload to ensure they are not sent twice. - context.Request.ClientId = context.Request.ClientSecret = null; - } - - return default; - - static bool UseBasicAuthentication(OpenIddictConfiguration configuration) - => configuration.IntrospectionEndpointAuthMethodsSupported switch - { - // If at least one authentication method was explicit added, only use basic authentication - // if it's supported AND if client_secret_post is not supported or enabled by the server. - { Count: > 0 } methods => methods.Contains(ClientAuthenticationMethods.ClientSecretBasic) && - !methods.Contains(ClientAuthenticationMethods.ClientSecretPost), - - // Otherwise, if no authentication method was explicit added, assume only basic is supported. - { Count: _ } => true - }; - - static string EscapeDataString(string value) => Uri.EscapeDataString(value).Replace("%20", "+"); - } - } } } diff --git a/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.Revocation.cs b/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.Revocation.cs index d8fa2dc2..169543d2 100644 --- a/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.Revocation.cs +++ b/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.Revocation.cs @@ -26,7 +26,7 @@ public static partial class OpenIddictClientSystemNetHttpHandlers AttachJsonAcceptHeaders.Descriptor, AttachUserAgentHeader.Descriptor, AttachFromHeader.Descriptor, - AttachBasicAuthenticationCredentials.Descriptor, + AttachBasicAuthenticationCredentials.Descriptor, AttachHttpParameters.Descriptor, SendHttpRequest.Descriptor, DisposeHttpRequest.Descriptor, @@ -41,94 +41,5 @@ public static partial class OpenIddictClientSystemNetHttpHandlers ValidateHttpResponse.Descriptor, DisposeHttpResponse.Descriptor ]); - - /// - /// Contains the logic responsible for attaching the client credentials to the HTTP Authorization header. - /// - public sealed class AttachBasicAuthenticationCredentials : IOpenIddictClientHandler - { - /// - /// Gets the default descriptor definition assigned to this handler. - /// - public static OpenIddictClientHandlerDescriptor Descriptor { get; } - = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() - .UseSingletonHandler() - .SetOrder(AttachHttpParameters.Descriptor.Order - 500) - .SetType(OpenIddictClientHandlerType.BuiltIn) - .Build(); - - /// - public ValueTask HandleAsync(PrepareRevocationRequestContext context) - { - if (context is null) - { - throw new ArgumentNullException(nameof(context)); - } - - Debug.Assert(context.Request is not null, SR.GetResourceString(SR.ID4008)); - - // This handler only applies to System.Net.Http requests. If the HTTP request cannot be resolved, - // this may indicate that the request was incorrectly processed by another client stack. - var request = context.Transaction.GetHttpRequestMessage() ?? - throw new InvalidOperationException(SR.GetResourceString(SR.ID0173)); - - // The OAuth 2.0 specification recommends sending the client credentials using basic authentication. - // However, this authentication method is known to have severe compatibility/interoperability issues: - // - // - While restricted to clients that have been given a secret (i.e confidential clients) by the - // specification, basic authentication is also sometimes required by server implementations for - // public clients that don't have a client secret: in this case, an empty password is used and - // the client identifier is sent alone in the Authorization header (instead of being sent using - // the standard "client_id" parameter present in the request body). - // - // - While the OAuth 2.0 specification requires that the client credentials be formURL-encoded - // before being base64-encoded, many implementations are known to implement a non-standard - // encoding scheme, where neither the client_id nor the client_secret are formURL-encoded. - // - // To guarantee that the OpenIddict implementation can be used with most servers implementions, - // basic authentication is only used when a client secret is present and client_secret_post is - // always preferred when it's explicitly listed as a supported client authentication method. - // If client_secret_post is not listed or if the server returned an empty methods list, - // client_secret_basic is always used, as it MUST be implemented by all OAuth 2.0 servers. - // - // See https://tools.ietf.org/html/rfc8414#section-2 - // and https://tools.ietf.org/html/rfc6749#section-2.3.1 for more information. - if (request.Headers.Authorization is null && - !string.IsNullOrEmpty(context.Request.ClientId) && - !string.IsNullOrEmpty(context.Request.ClientSecret) && - UseBasicAuthentication(context.Configuration)) - { - // Important: the credentials MUST be formURL-encoded before being base64-encoded. - var credentials = Convert.ToBase64String(Encoding.ASCII.GetBytes(new StringBuilder() - .Append(EscapeDataString(context.Request.ClientId)) - .Append(':') - .Append(EscapeDataString(context.Request.ClientSecret)) - .ToString())); - - // Attach the authorization header containing the client credentials to the HTTP request. - request.Headers.Authorization = new AuthenticationHeaderValue(Schemes.Basic, credentials); - - // Remove the client credentials from the request payload to ensure they are not sent twice. - context.Request.ClientId = context.Request.ClientSecret = null; - } - - return default; - - static bool UseBasicAuthentication(OpenIddictConfiguration configuration) - => configuration.RevocationEndpointAuthMethodsSupported switch - { - // If at least one authentication method was explicit added, only use basic authentication - // if it's supported AND if client_secret_post is not supported or enabled by the server. - { Count: > 0 } methods => methods.Contains(ClientAuthenticationMethods.ClientSecretBasic) && - !methods.Contains(ClientAuthenticationMethods.ClientSecretPost), - - // Otherwise, if no authentication method was explicit added, assume only basic is supported. - { Count: _ } => true - }; - - static string EscapeDataString(string value) => Uri.EscapeDataString(value).Replace("%20", "+"); - } - } } } diff --git a/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.cs b/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.cs index a6e8e5bb..3625acaf 100644 --- a/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.cs +++ b/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.cs @@ -11,9 +11,12 @@ using System.IO.Compression; using System.Net.Http; using System.Net.Http.Headers; using System.Runtime.InteropServices; +using System.Security.Cryptography.X509Certificates; +using System.Text; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; +using Microsoft.IdentityModel.Tokens; using OpenIddict.Extensions; using static OpenIddict.Client.SystemNetHttp.OpenIddictClientSystemNetHttpConstants; @@ -23,6 +26,27 @@ namespace OpenIddict.Client.SystemNetHttp; public static partial class OpenIddictClientSystemNetHttpHandlers { public static ImmutableArray DefaultHandlers { get; } = ImmutableArray.Create([ + /* + * Authentication processing: + */ + AttachNonDefaultTokenEndpointClientAuthenticationMethod.Descriptor, + AttachNonDefaultUserInfoEndpointTokenBindingMethods.Descriptor, + + /* + * Challenge processing: + */ + AttachNonDefaultDeviceAuthorizationEndpointClientAuthenticationMethod.Descriptor, + + /* + * Introspection processing: + */ + AttachNonDefaultIntrospectionEndpointClientAuthenticationMethod.Descriptor, + + /* + * Revocation processing: + */ + AttachNonDefaultRevocationEndpointClientAuthenticationMethod.Descriptor, + .. Device.DefaultHandlers, .. Discovery.DefaultHandlers, .. Exchange.DefaultHandlers, @@ -31,6 +55,559 @@ public static partial class OpenIddictClientSystemNetHttpHandlers .. UserInfo.DefaultHandlers ]); + /// + /// Contains the logic responsible for negotiating the best token endpoint client + /// authentication method supported by both the client and the authorization server. + /// + public sealed class AttachNonDefaultTokenEndpointClientAuthenticationMethod : IOpenIddictClientHandler + { + private readonly IOptionsMonitor _options; + + public AttachNonDefaultTokenEndpointClientAuthenticationMethod( + IOptionsMonitor options) + => _options = options ?? throw new ArgumentNullException(nameof(options)); + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(AttachTokenEndpointClientAuthenticationMethod.Descriptor.Order - 500) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessAuthenticationContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // If an explicit client authentication method was attached, don't overwrite it. + if (!string.IsNullOrEmpty(context.TokenEndpointClientAuthenticationMethod)) + { + return default; + } + + context.TokenEndpointClientAuthenticationMethod = ( + // Note: if client authentication methods are explicitly listed in the client registration, only use + // the client authentication methods that are both listed and enabled in the global client options. + // Otherwise, always default to the client authentication methods that have been enabled globally. + Client: context.Registration.ClientAuthenticationMethods.Count switch + { + 0 => context.Options.ClientAuthenticationMethods as ICollection, + _ => context.Options.ClientAuthenticationMethods.Intersect(context.Registration.ClientAuthenticationMethods, StringComparer.Ordinal).ToList() + }, + + Server: context.Configuration.TokenEndpointAuthMethodsSupported) switch + { + // If a TLS client authentication certificate could be resolved and both the + // client and the server explicitly support tls_client_auth, always prefer it. + ({ Count: > 0 } client, { Count: > 0 } server) when + client.Contains(ClientAuthenticationMethods.TlsClientAuth) && + server.Contains(ClientAuthenticationMethods.TlsClientAuth) && + (context.Configuration.MtlsTokenEndpoint ?? context.Configuration.TokenEndpoint) is Uri endpoint && + string.Equals(endpoint.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) && + _options.CurrentValue.TlsClientAuthenticationCertificateSelector(context.Registration) is not null + => ClientAuthenticationMethods.TlsClientAuth, + + // If a self-signed TLS client authentication certificate could be resolved and both + // the client and the server explicitly support self_signed_tls_client_auth, use it. + ({ Count: > 0 } client, { Count: > 0 } server) when + client.Contains(ClientAuthenticationMethods.SelfSignedTlsClientAuth) && + server.Contains(ClientAuthenticationMethods.SelfSignedTlsClientAuth) && + (context.Configuration.MtlsTokenEndpoint ?? context.Configuration.TokenEndpoint) is Uri endpoint && + string.Equals(endpoint.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) && + _options.CurrentValue.SelfSignedTlsClientAuthenticationCertificateSelector(context.Registration) is not null + => ClientAuthenticationMethods.SelfSignedTlsClientAuth, + + // If at least one asymmetric signing key was attached to the client registration + // and both the client and the server explicitly support private_key_jwt, use it. + ({ Count: > 0 } client, { Count: > 0 } server) when + client.Contains(ClientAuthenticationMethods.PrivateKeyJwt) && + server.Contains(ClientAuthenticationMethods.PrivateKeyJwt) && + context.Registration.SigningCredentials.Exists(static credentials => credentials.Key is AsymmetricSecurityKey) + => ClientAuthenticationMethods.PrivateKeyJwt, + + // If a client secret was attached to the client registration and both the client and + // the server explicitly support client_secret_post, prefer it to basic authentication. + ({ Count: > 0 } client, { Count: > 0 } server) when !string.IsNullOrEmpty(context.Registration.ClientSecret) && + client.Contains(ClientAuthenticationMethods.ClientSecretPost) && + server.Contains(ClientAuthenticationMethods.ClientSecretPost) + => ClientAuthenticationMethods.ClientSecretPost, + + // The OAuth 2.0 specification recommends sending the client credentials using basic authentication. + // However, this authentication method is known to have severe compatibility/interoperability issues: + // + // - While restricted to clients that have been given a secret (i.e confidential clients) by the + // specification, basic authentication is also sometimes required by server implementations for + // public clients that don't have a client secret: in this case, an empty password is used and + // the client identifier is sent alone in the Authorization header (instead of being sent using + // the standard "client_id" parameter present in the request body). + // + // - While the OAuth 2.0 specification requires that the client credentials be formURL-encoded + // before being base64-encoded, many implementations are known to implement a non-standard + // encoding scheme, where neither the client_id nor the client_secret are formURL-encoded. + // + // To guarantee that the OpenIddict implementation can be used with most servers implementions, + // basic authentication is only used when a client secret is present and the server configuration + // doesn't list any supported client authentication method or doesn't support client_secret_post. + // + // If client_secret_post is not listed or if the server returned an empty methods list, + // client_secret_basic is always used, as it MUST be implemented by all OAuth 2.0 servers. + // + // See https://tools.ietf.org/html/rfc8414#section-2 + // and https://tools.ietf.org/html/rfc6749#section-2.3.1 for more information. + ({ Count: > 0 } client, { Count: > 0 } server) when !string.IsNullOrEmpty(context.Registration.ClientSecret) && + client.Contains(ClientAuthenticationMethods.ClientSecretBasic) && + server.Contains(ClientAuthenticationMethods.ClientSecretBasic) + => ClientAuthenticationMethods.ClientSecretBasic, + + ({ Count: > 0 } client, { Count: 0 }) when !string.IsNullOrEmpty(context.Registration.ClientSecret) && + client.Contains(ClientAuthenticationMethods.ClientSecretBasic) + => ClientAuthenticationMethods.ClientSecretBasic, + + _ => null + }; + + return default; + } + } + + /// + /// Contains the logic responsible for negotiating the best token binding + /// methods supported by both the client and the authorization server. + /// + public sealed class AttachNonDefaultUserInfoEndpointTokenBindingMethods : IOpenIddictClientHandler + { + private readonly IOptionsMonitor _options; + + public AttachNonDefaultUserInfoEndpointTokenBindingMethods( + IOptionsMonitor options) + => _options = options ?? throw new ArgumentNullException(nameof(options)); + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(AttachUserInfoEndpointTokenBindingMethods.Descriptor.Order - 500) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessAuthenticationContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // Unlike DPoP, the mTLS specification doesn't use a specific token type to represent + // certificate-bound tokens. As such, most implementations (e.g Keycloak) simply return + // the "Bearer" value even if the access token is - by definition - not a bearer token + // and requires using the same X.509 certificate that was used for client authentication. + // + // Since the token type cannot be trusted in this case, OpenIddict assumes that the access + // token used in the userinfo request is certificate-bound if the server configuration + // indicates that the server supports certificate-bound access tokens and if either + // tls_client_auth or self_signed_tls_client_auth was used for the token request. + + if (context.Configuration.TlsClientCertificateBoundAccessTokens is not true || + !context.SendTokenRequest || string.IsNullOrEmpty(context.BackchannelAccessToken) || + (context.Configuration.MtlsUserInfoEndpoint ?? context.Configuration.UserInfoEndpoint) is not Uri endpoint || + !string.Equals(endpoint.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + return default; + } + + if (context.TokenEndpointClientAuthenticationMethod is ClientAuthenticationMethods.TlsClientAuth && + _options.CurrentValue.TlsClientAuthenticationCertificateSelector(context.Registration) is not null) + { + context.UserInfoEndpointTokenBindingMethods.Add(TokenBindingMethods.TlsClientCertificate); + } + + else if (context.TokenEndpointClientAuthenticationMethod is ClientAuthenticationMethods.SelfSignedTlsClientAuth && + _options.CurrentValue.SelfSignedTlsClientAuthenticationCertificateSelector(context.Registration) is not null) + { + context.UserInfoEndpointTokenBindingMethods.Add(TokenBindingMethods.SelfSignedTlsClientCertificate); + } + + return default; + } + } + + /// + /// Contains the logic responsible for negotiating the best device authorization endpoint + /// client authentication method supported by both the client and the authorization server. + /// + public sealed class AttachNonDefaultDeviceAuthorizationEndpointClientAuthenticationMethod : IOpenIddictClientHandler + { + private readonly IOptionsMonitor _options; + + public AttachNonDefaultDeviceAuthorizationEndpointClientAuthenticationMethod( + IOptionsMonitor options) + => _options = options ?? throw new ArgumentNullException(nameof(options)); + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(AttachDeviceAuthorizationEndpointClientAuthenticationMethod.Descriptor.Order - 500) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessChallengeContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // If an explicit client authentication method was attached, don't overwrite it. + if (!string.IsNullOrEmpty(context.DeviceAuthorizationEndpointClientAuthenticationMethod)) + { + return default; + } + + context.DeviceAuthorizationEndpointClientAuthenticationMethod = ( + // Note: if client authentication methods are explicitly listed in the client registration, only use + // the client authentication methods that are both listed and enabled in the global client options. + // Otherwise, always default to the client authentication methods that have been enabled globally. + Client: context.Registration.ClientAuthenticationMethods.Count switch + { + 0 => context.Options.ClientAuthenticationMethods as ICollection, + _ => context.Options.ClientAuthenticationMethods.Intersect(context.Registration.ClientAuthenticationMethods, StringComparer.Ordinal).ToList() + }, + + Server: context.Configuration.DeviceAuthorizationEndpointAuthMethodsSupported) switch + { + // If a TLS client authentication certificate could be resolved and both the + // client and the server explicitly support tls_client_auth, always prefer it. + ({ Count: > 0 } client, { Count: > 0 } server) when + client.Contains(ClientAuthenticationMethods.TlsClientAuth) && + server.Contains(ClientAuthenticationMethods.TlsClientAuth) && + (context.Configuration.MtlsDeviceAuthorizationEndpoint ?? context.Configuration.DeviceAuthorizationEndpoint) is Uri endpoint && + string.Equals(endpoint.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) && + _options.CurrentValue.TlsClientAuthenticationCertificateSelector(context.Registration) is not null + => ClientAuthenticationMethods.TlsClientAuth, + + // If a self-signed TLS client authentication certificate could be resolved and both + // the client and the server explicitly support self_signed_tls_client_auth, use it. + ({ Count: > 0 } client, { Count: > 0 } server) when + client.Contains(ClientAuthenticationMethods.SelfSignedTlsClientAuth) && + server.Contains(ClientAuthenticationMethods.SelfSignedTlsClientAuth) && + (context.Configuration.MtlsDeviceAuthorizationEndpoint ?? context.Configuration.DeviceAuthorizationEndpoint) is Uri endpoint && + string.Equals(endpoint.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) && + _options.CurrentValue.SelfSignedTlsClientAuthenticationCertificateSelector(context.Registration) is not null + => ClientAuthenticationMethods.SelfSignedTlsClientAuth, + + // If at least one asymmetric signing key was attached to the client registration + // and both the client and the server explicitly support private_key_jwt, use it. + ({ Count: > 0 } client, { Count: > 0 } server) when + client.Contains(ClientAuthenticationMethods.PrivateKeyJwt) && + server.Contains(ClientAuthenticationMethods.PrivateKeyJwt) && + context.Registration.SigningCredentials.Exists(static credentials => credentials.Key is AsymmetricSecurityKey) + => ClientAuthenticationMethods.PrivateKeyJwt, + + // If a client secret was attached to the client registration and both the client and + // the server explicitly support client_secret_post, prefer it to basic authentication. + ({ Count: > 0 } client, { Count: > 0 } server) when !string.IsNullOrEmpty(context.Registration.ClientSecret) && + client.Contains(ClientAuthenticationMethods.ClientSecretPost) && + server.Contains(ClientAuthenticationMethods.ClientSecretPost) + => ClientAuthenticationMethods.ClientSecretPost, + + // The OAuth 2.0 specification recommends sending the client credentials using basic authentication. + // However, this authentication method is known to have severe compatibility/interoperability issues: + // + // - While restricted to clients that have been given a secret (i.e confidential clients) by the + // specification, basic authentication is also sometimes required by server implementations for + // public clients that don't have a client secret: in this case, an empty password is used and + // the client identifier is sent alone in the Authorization header (instead of being sent using + // the standard "client_id" parameter present in the request body). + // + // - While the OAuth 2.0 specification requires that the client credentials be formURL-encoded + // before being base64-encoded, many implementations are known to implement a non-standard + // encoding scheme, where neither the client_id nor the client_secret are formURL-encoded. + // + // To guarantee that the OpenIddict implementation can be used with most servers implementions, + // basic authentication is only used when a client secret is present and the server configuration + // doesn't list any supported client authentication method or doesn't support client_secret_post. + // + // If client_secret_post is not listed or if the server returned an empty methods list, + // client_secret_basic is always used, as it MUST be implemented by all OAuth 2.0 servers. + // + // See https://tools.ietf.org/html/rfc8414#section-2 + // and https://tools.ietf.org/html/rfc6749#section-2.3.1 for more information. + ({ Count: > 0 } client, { Count: > 0 } server) when !string.IsNullOrEmpty(context.Registration.ClientSecret) && + client.Contains(ClientAuthenticationMethods.ClientSecretBasic) && + server.Contains(ClientAuthenticationMethods.ClientSecretBasic) + => ClientAuthenticationMethods.ClientSecretBasic, + + ({ Count: > 0 } client, { Count: 0 }) when !string.IsNullOrEmpty(context.Registration.ClientSecret) && + client.Contains(ClientAuthenticationMethods.ClientSecretBasic) + => ClientAuthenticationMethods.ClientSecretBasic, + + _ => null + }; + + return default; + } + } + + /// + /// Contains the logic responsible for negotiating the best introspection endpoint client + /// authentication method supported by both the client and the authorization server. + /// + public sealed class AttachNonDefaultIntrospectionEndpointClientAuthenticationMethod : IOpenIddictClientHandler + { + private readonly IOptionsMonitor _options; + + public AttachNonDefaultIntrospectionEndpointClientAuthenticationMethod( + IOptionsMonitor options) + => _options = options ?? throw new ArgumentNullException(nameof(options)); + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(AttachIntrospectionEndpointClientAuthenticationMethod.Descriptor.Order - 500) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessIntrospectionContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // If an explicit client authentication method was attached, don't overwrite it. + if (!string.IsNullOrEmpty(context.IntrospectionEndpointClientAuthenticationMethod)) + { + return default; + } + + context.IntrospectionEndpointClientAuthenticationMethod = ( + // Note: if client authentication methods are explicitly listed in the client registration, only use + // the client authentication methods that are both listed and enabled in the global client options. + // Otherwise, always default to the client authentication methods that have been enabled globally. + Client: context.Registration.ClientAuthenticationMethods.Count switch + { + 0 => context.Options.ClientAuthenticationMethods as ICollection, + _ => context.Options.ClientAuthenticationMethods.Intersect(context.Registration.ClientAuthenticationMethods, StringComparer.Ordinal).ToList() + }, + + Server: context.Configuration.IntrospectionEndpointAuthMethodsSupported) switch + { + // If a TLS client authentication certificate could be resolved and both the + // client and the server explicitly support tls_client_auth, always prefer it. + ({ Count: > 0 } client, { Count: > 0 } server) when + client.Contains(ClientAuthenticationMethods.TlsClientAuth) && + server.Contains(ClientAuthenticationMethods.TlsClientAuth) && + (context.Configuration.MtlsIntrospectionEndpoint ?? context.Configuration.IntrospectionEndpoint) is Uri endpoint && + string.Equals(endpoint.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) && + _options.CurrentValue.TlsClientAuthenticationCertificateSelector(context.Registration) is not null + => ClientAuthenticationMethods.TlsClientAuth, + + // If a self-signed TLS client authentication certificate could be resolved and both + // the client and the server explicitly support self_signed_tls_client_auth, use it. + ({ Count: > 0 } client, { Count: > 0 } server) when + client.Contains(ClientAuthenticationMethods.SelfSignedTlsClientAuth) && + server.Contains(ClientAuthenticationMethods.SelfSignedTlsClientAuth) && + (context.Configuration.MtlsIntrospectionEndpoint ?? context.Configuration.IntrospectionEndpoint) is Uri endpoint && + string.Equals(endpoint.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) && + _options.CurrentValue.SelfSignedTlsClientAuthenticationCertificateSelector(context.Registration) is not null + => ClientAuthenticationMethods.SelfSignedTlsClientAuth, + + // If at least one asymmetric signing key was attached to the client registration + // and both the client and the server explicitly support private_key_jwt, use it. + ({ Count: > 0 } client, { Count: > 0 } server) when + client.Contains(ClientAuthenticationMethods.PrivateKeyJwt) && + server.Contains(ClientAuthenticationMethods.PrivateKeyJwt) && + context.Registration.SigningCredentials.Exists(static credentials => credentials.Key is AsymmetricSecurityKey) + => ClientAuthenticationMethods.PrivateKeyJwt, + + // If a client secret was attached to the client registration and both the client and + // the server explicitly support client_secret_post, prefer it to basic authentication. + ({ Count: > 0 } client, { Count: > 0 } server) when !string.IsNullOrEmpty(context.Registration.ClientSecret) && + client.Contains(ClientAuthenticationMethods.ClientSecretPost) && + server.Contains(ClientAuthenticationMethods.ClientSecretPost) + => ClientAuthenticationMethods.ClientSecretPost, + + // The OAuth 2.0 specification recommends sending the client credentials using basic authentication. + // However, this authentication method is known to have severe compatibility/interoperability issues: + // + // - While restricted to clients that have been given a secret (i.e confidential clients) by the + // specification, basic authentication is also sometimes required by server implementations for + // public clients that don't have a client secret: in this case, an empty password is used and + // the client identifier is sent alone in the Authorization header (instead of being sent using + // the standard "client_id" parameter present in the request body). + // + // - While the OAuth 2.0 specification requires that the client credentials be formURL-encoded + // before being base64-encoded, many implementations are known to implement a non-standard + // encoding scheme, where neither the client_id nor the client_secret are formURL-encoded. + // + // To guarantee that the OpenIddict implementation can be used with most servers implementions, + // basic authentication is only used when a client secret is present and the server configuration + // doesn't list any supported client authentication method or doesn't support client_secret_post. + // + // If client_secret_post is not listed or if the server returned an empty methods list, + // client_secret_basic is always used, as it MUST be implemented by all OAuth 2.0 servers. + // + // See https://tools.ietf.org/html/rfc8414#section-2 + // and https://tools.ietf.org/html/rfc6749#section-2.3.1 for more information. + ({ Count: > 0 } client, { Count: > 0 } server) when !string.IsNullOrEmpty(context.Registration.ClientSecret) && + client.Contains(ClientAuthenticationMethods.ClientSecretBasic) && + server.Contains(ClientAuthenticationMethods.ClientSecretBasic) + => ClientAuthenticationMethods.ClientSecretBasic, + + ({ Count: > 0 } client, { Count: 0 }) when !string.IsNullOrEmpty(context.Registration.ClientSecret) && + client.Contains(ClientAuthenticationMethods.ClientSecretBasic) + => ClientAuthenticationMethods.ClientSecretBasic, + + _ => null + }; + + return default; + } + } + + /// + /// Contains the logic responsible for negotiating the best revocation endpoint client + /// authentication method supported by both the client and the authorization server. + /// + public sealed class AttachNonDefaultRevocationEndpointClientAuthenticationMethod : IOpenIddictClientHandler + { + private readonly IOptionsMonitor _options; + + public AttachNonDefaultRevocationEndpointClientAuthenticationMethod( + IOptionsMonitor options) + => _options = options ?? throw new ArgumentNullException(nameof(options)); + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(AttachRevocationEndpointClientAuthenticationMethod.Descriptor.Order - 500) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessRevocationContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // If an explicit client authentication method was attached, don't overwrite it. + if (!string.IsNullOrEmpty(context.RevocationEndpointClientAuthenticationMethod)) + { + return default; + } + + context.RevocationEndpointClientAuthenticationMethod = ( + // Note: if client authentication methods are explicitly listed in the client registration, only use + // the client authentication methods that are both listed and enabled in the global client options. + // Otherwise, always default to the client authentication methods that have been enabled globally. + Client: context.Registration.ClientAuthenticationMethods.Count switch + { + 0 => context.Options.ClientAuthenticationMethods as ICollection, + _ => context.Options.ClientAuthenticationMethods.Intersect(context.Registration.ClientAuthenticationMethods, StringComparer.Ordinal).ToList() + }, + + Server: context.Configuration.RevocationEndpointAuthMethodsSupported) switch + { + // If a TLS client authentication certificate could be resolved and both the + // client and the server explicitly support tls_client_auth, always prefer it. + ({ Count: > 0 } client, { Count: > 0 } server) when + client.Contains(ClientAuthenticationMethods.TlsClientAuth) && + server.Contains(ClientAuthenticationMethods.TlsClientAuth) && + (context.Configuration.MtlsRevocationEndpoint ?? context.Configuration.RevocationEndpoint) is Uri endpoint && + string.Equals(endpoint.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) && + _options.CurrentValue.TlsClientAuthenticationCertificateSelector(context.Registration) is not null + => ClientAuthenticationMethods.TlsClientAuth, + + // If a self-signed TLS client authentication certificate could be resolved and both + // the client and the server explicitly support self_signed_tls_client_auth, use it. + ({ Count: > 0 } client, { Count: > 0 } server) when + client.Contains(ClientAuthenticationMethods.SelfSignedTlsClientAuth) && + server.Contains(ClientAuthenticationMethods.SelfSignedTlsClientAuth) && + (context.Configuration.MtlsRevocationEndpoint ?? context.Configuration.RevocationEndpoint) is Uri endpoint && + string.Equals(endpoint.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) && + _options.CurrentValue.SelfSignedTlsClientAuthenticationCertificateSelector(context.Registration) is not null + => ClientAuthenticationMethods.SelfSignedTlsClientAuth, + + // If at least one asymmetric signing key was attached to the client registration + // and both the client and the server explicitly support private_key_jwt, use it. + ({ Count: > 0 } client, { Count: > 0 } server) when + client.Contains(ClientAuthenticationMethods.PrivateKeyJwt) && + server.Contains(ClientAuthenticationMethods.PrivateKeyJwt) && + context.Registration.SigningCredentials.Exists(static credentials => credentials.Key is AsymmetricSecurityKey) + => ClientAuthenticationMethods.PrivateKeyJwt, + + // If a client secret was attached to the client registration and both the client and + // the server explicitly support client_secret_post, prefer it to basic authentication. + ({ Count: > 0 } client, { Count: > 0 } server) when !string.IsNullOrEmpty(context.Registration.ClientSecret) && + client.Contains(ClientAuthenticationMethods.ClientSecretPost) && + server.Contains(ClientAuthenticationMethods.ClientSecretPost) + => ClientAuthenticationMethods.ClientSecretPost, + + // The OAuth 2.0 specification recommends sending the client credentials using basic authentication. + // However, this authentication method is known to have severe compatibility/interoperability issues: + // + // - While restricted to clients that have been given a secret (i.e confidential clients) by the + // specification, basic authentication is also sometimes required by server implementations for + // public clients that don't have a client secret: in this case, an empty password is used and + // the client identifier is sent alone in the Authorization header (instead of being sent using + // the standard "client_id" parameter present in the request body). + // + // - While the OAuth 2.0 specification requires that the client credentials be formURL-encoded + // before being base64-encoded, many implementations are known to implement a non-standard + // encoding scheme, where neither the client_id nor the client_secret are formURL-encoded. + // + // To guarantee that the OpenIddict implementation can be used with most servers implementions, + // basic authentication is only used when a client secret is present and the server configuration + // doesn't list any supported client authentication method or doesn't support client_secret_post. + // + // If client_secret_post is not listed or if the server returned an empty methods list, + // client_secret_basic is always used, as it MUST be implemented by all OAuth 2.0 servers. + // + // See https://tools.ietf.org/html/rfc8414#section-2 + // and https://tools.ietf.org/html/rfc6749#section-2.3.1 for more information. + ({ Count: > 0 } client, { Count: > 0 } server) when !string.IsNullOrEmpty(context.Registration.ClientSecret) && + client.Contains(ClientAuthenticationMethods.ClientSecretBasic) && + server.Contains(ClientAuthenticationMethods.ClientSecretBasic) + => ClientAuthenticationMethods.ClientSecretBasic, + + ({ Count: > 0 } client, { Count: 0 }) when !string.IsNullOrEmpty(context.Registration.ClientSecret) && + client.Contains(ClientAuthenticationMethods.ClientSecretBasic) + => ClientAuthenticationMethods.ClientSecretBasic, + + _ => null + }; + + return default; + } + } + /// /// Contains the logic responsible for creating and attaching a . /// @@ -60,11 +637,60 @@ public static partial class OpenIddictClientSystemNetHttpHandlers throw new ArgumentNullException(nameof(context)); } - var assembly = typeof(OpenIddictClientSystemNetHttpOptions).Assembly.GetName(); - var name = $"{assembly.Name}:{context.Registration.RegistrationId}"; + // Note: HttpClientFactory doesn't support flowing a list of properties that can be + // accessed from the HttpClientAction or HttpMessageHandlerBuilderAction delegates + // to dynamically amend the resulting HttpClient or HttpClientHandler instance. + // + // To work around this limitation, the OpenIddict System.Net.Http integration + // uses dynamic client names and supports appending a list of key-value pairs + // to the client name to flow per-instance properties. + + var builder = new StringBuilder(); + + // Always prefix the HTTP client name with the assembly name of the System.Net.Http package. + builder.Append(typeof(OpenIddictClientSystemNetHttpOptions).Assembly.GetName().Name); + + builder.Append(':'); + + // Attach the registration identifier. + builder.Append("RegistrationId") + .Append('\u001e') + .Append(context.Registration.RegistrationId); + + // If both a client authentication method and one or multiple token binding methods were negotiated, + // make sure they are compatible (e.g that they all use a CA-issued or self-signed X.509 certificate). + if ((context.ClientAuthenticationMethod is ClientAuthenticationMethods.TlsClientAuth && + context.TokenBindingMethods.Contains(TokenBindingMethods.SelfSignedTlsClientCertificate)) || + (context.ClientAuthenticationMethod is ClientAuthenticationMethods.SelfSignedTlsClientAuth && + context.TokenBindingMethods.Contains(TokenBindingMethods.TlsClientCertificate))) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0456)); + } + + // Attach a flag indicating that a client certificate should be used in the TLS handshake. + if (context.ClientAuthenticationMethod is ClientAuthenticationMethods.TlsClientAuth || + context.TokenBindingMethods.Contains(TokenBindingMethods.TlsClientCertificate)) + { + builder.Append('\u001f'); + + builder.Append("AttachTlsClientCertificate") + .Append('\u001e') + .Append(bool.TrueString); + } + + // Attach a flag indicating that a self-signed client certificate should be used in the TLS handshake. + else if (context.ClientAuthenticationMethod is ClientAuthenticationMethods.SelfSignedTlsClientAuth || + context.TokenBindingMethods.Contains(TokenBindingMethods.SelfSignedTlsClientCertificate)) + { + builder.Append('\u001f'); + + builder.Append("AttachSelfSignedTlsClientCertificate") + .Append('\u001e') + .Append(bool.TrueString); + } // Create and store the HttpClient in the transaction properties. - context.Transaction.SetProperty(typeof(HttpClient).FullName!, _factory.CreateClient(name) ?? + context.Transaction.SetProperty(typeof(HttpClient).FullName!, _factory.CreateClient(builder.ToString()) ?? throw new InvalidOperationException(SR.GetResourceString(SR.ID0174))); return default; @@ -318,6 +944,63 @@ public static partial class OpenIddictClientSystemNetHttpHandlers } } + /// + /// Contains the logic responsible for attaching the client credentials to the HTTP Authorization header. + /// + public sealed class AttachBasicAuthenticationCredentials : IOpenIddictClientHandler where TContext : BaseExternalContext + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler>() + .SetOrder(AttachHttpParameters.Descriptor.Order - 500) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(TContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + Debug.Assert(context.Transaction.Request is not null, SR.GetResourceString(SR.ID4008)); + + // This handler only applies to System.Net.Http requests. If the HTTP request cannot be resolved, + // this may indicate that the request was incorrectly processed by another client stack. + var request = context.Transaction.GetHttpRequestMessage() ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0173)); + + // Note: don't overwrite the authorization header if one was already set by another handler. + if (request.Headers.Authorization is null && + context.ClientAuthenticationMethod is ClientAuthenticationMethods.ClientSecretBasic && + !string.IsNullOrEmpty(context.Transaction.Request.ClientId)) + { + // Important: the credentials MUST be formURL-encoded before being base64-encoded. + var credentials = Convert.ToBase64String(Encoding.ASCII.GetBytes(new StringBuilder() + .Append(EscapeDataString(context.Transaction.Request.ClientId)) + .Append(':') + .Append(EscapeDataString(context.Transaction.Request.ClientSecret)) + .ToString())); + + // Attach the authorization header containing the client credentials to the HTTP request. + request.Headers.Authorization = new AuthenticationHeaderValue(Schemes.Basic, credentials); + + // Remove the client credentials from the request payload to ensure they are not sent twice. + context.Transaction.Request.ClientId = context.Transaction.Request.ClientSecret = null; + } + + return default; + + static string? EscapeDataString(string? value) + => value is not null ? Uri.EscapeDataString(value).Replace("%20", "+") : null; + } + } + /// /// Contains the logic responsible for attaching the parameters to the HTTP request. /// diff --git a/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpOptions.cs b/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpOptions.cs index 4547a9b8..dce47330 100644 --- a/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpOptions.cs +++ b/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpOptions.cs @@ -4,10 +4,12 @@ * the license and the contributors participating to this project. */ +using System.ComponentModel; using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Net.Mail; +using System.Security.Cryptography.X509Certificates; using Polly; using Polly.Extensions.Http; @@ -80,4 +82,32 @@ public sealed class OpenIddictClientSystemNetHttpOptions /// instances created by the OpenIddict client/System.Net.Http integration. /// public List> HttpClientHandlerActions { get; } = []; + + /// + /// Gets or sets the delegate called by OpenIddict when trying to resolve the + /// self-signed TLS client authentication certificate that will be used for OAuth 2.0 + /// mTLS-based client authentication (self_signed_tls_client_auth), if applicable. + /// + /// + /// If no value is explicitly set, OpenIddict automatically tries to resolve the + /// X.509 certificate from the signing credentials attached to the client registration + /// (in this case, the X.509 certificate MUST include the digital signature and + /// client authentication key usages to be automatically selected by OpenIddict). + /// + [EditorBrowsable(EditorBrowsableState.Advanced)] + public Func SelfSignedTlsClientAuthenticationCertificateSelector { get; set; } = default!; + + /// + /// Gets or sets the delegate called by OpenIddict when trying to resolve the TLS + /// client authentication certificate that will be used for OAuth 2.0 mTLS-based + /// client authentication (tls_client_auth), if applicable. + /// + /// + /// If no value is explicitly set, OpenIddict automatically tries to resolve the + /// X.509 certificate from the signing credentials attached to the client registration + /// (in this case, the X.509 certificate MUST include the digital signature and + /// client authentication key usages to be automatically selected by OpenIddict). + /// + [EditorBrowsable(EditorBrowsableState.Advanced)] + public Func TlsClientAuthenticationCertificateSelector { get; set; } = default!; } diff --git a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationConfiguration.cs b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationConfiguration.cs index 25b9e2d7..85d5b74e 100644 --- a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationConfiguration.cs +++ b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationConfiguration.cs @@ -5,10 +5,7 @@ */ using System.ComponentModel; -using System.Net.Http; using Microsoft.Extensions.Options; -using OpenIddict.Client.SystemNetHttp; -using static OpenIddict.Client.WebIntegration.OpenIddictClientWebIntegrationConstants; namespace OpenIddict.Client.WebIntegration; @@ -17,7 +14,6 @@ namespace OpenIddict.Client.WebIntegration; /// [EditorBrowsable(EditorBrowsableState.Advanced)] public sealed partial class OpenIddictClientWebIntegrationConfiguration : IConfigureOptions, - IConfigureOptions, IPostConfigureOptions { /// @@ -32,36 +28,6 @@ public sealed partial class OpenIddictClientWebIntegrationConfiguration : IConfi options.Handlers.AddRange(OpenIddictClientWebIntegrationHandlers.DefaultHandlers); } - /// - public void Configure(OpenIddictClientSystemNetHttpOptions options) - { - if (options is null) - { - throw new ArgumentNullException(nameof(options)); - } - - options.HttpClientHandlerActions.Add(static (registration, handler) => - { - var certificate = registration.ProviderType switch - { - // Note: while not enforced yet, Pro Santé Connect's specification requires sending a TLS - // client certificate when communicating with its backchannel OpenID Connect endpoints. - // - // For more information, see EXI PSC 24 in the annex part of - // https://www.legifrance.gouv.fr/jorf/id/JORFTEXT000045551195. - ProviderTypes.ProSantéConnect => registration.GetProSantéConnectSettings().ClientCertificate, - - _ => null - }; - - if (certificate is not null) - { - handler.ClientCertificateOptions = ClientCertificateOption.Manual; - handler.ClientCertificates.Add(certificate); - } - }); - } - /// public void PostConfigure(string? name, OpenIddictClientOptions options) { diff --git a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationExtensions.cs b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationExtensions.cs index b062de02..3b78e5d0 100644 --- a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationExtensions.cs +++ b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationExtensions.cs @@ -7,7 +7,6 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; using OpenIddict.Client; -using OpenIddict.Client.SystemNetHttp; using OpenIddict.Client.WebIntegration; namespace Microsoft.Extensions.DependencyInjection; @@ -42,9 +41,6 @@ public static partial class OpenIddictClientWebIntegrationExtensions builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton< IConfigureOptions, OpenIddictClientWebIntegrationConfiguration>()); - builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton< - IConfigureOptions, OpenIddictClientWebIntegrationConfiguration>()); - // Note: the IPostConfigureOptions service responsible for populating // the client registrations MUST be registered before OpenIddictClientConfiguration to ensure // the registrations are correctly populated before being validated. diff --git a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Discovery.cs b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Discovery.cs index 83ef00a5..c7aab55a 100644 --- a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Discovery.cs +++ b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Discovery.cs @@ -338,6 +338,24 @@ public static partial class OpenIddictClientWebIntegrationHandlers ClientAuthenticationMethods.ClientSecretPost); } + // Pro Santé Connect lists private_key_jwt as a supported client authentication method but + // only supports client_secret_basic/client_secret_post and tls_client_auth and plans to + // remove secret-based authentication support in late 2024 to force clients to use mTLS. + else if (context.Registration.ProviderType is ProviderTypes.ProSantéConnect) + { + context.Configuration.DeviceAuthorizationEndpointAuthMethodsSupported.Remove( + ClientAuthenticationMethods.PrivateKeyJwt); + + context.Configuration.IntrospectionEndpointAuthMethodsSupported.Remove( + ClientAuthenticationMethods.PrivateKeyJwt); + + context.Configuration.RevocationEndpointAuthMethodsSupported.Remove( + ClientAuthenticationMethods.PrivateKeyJwt); + + context.Configuration.TokenEndpointAuthMethodsSupported.Remove( + ClientAuthenticationMethods.PrivateKeyJwt); + } + return default; } } diff --git a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Exchange.cs b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Exchange.cs index 73e36441..0e2dcab1 100644 --- a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Exchange.cs +++ b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Exchange.cs @@ -16,7 +16,6 @@ using OpenIddict.Extensions; using static OpenIddict.Client.SystemNetHttp.OpenIddictClientSystemNetHttpConstants; using static OpenIddict.Client.SystemNetHttp.OpenIddictClientSystemNetHttpHandlerFilters; using static OpenIddict.Client.SystemNetHttp.OpenIddictClientSystemNetHttpHandlers; -using static OpenIddict.Client.SystemNetHttp.OpenIddictClientSystemNetHttpHandlers.Exchange; using static OpenIddict.Client.WebIntegration.OpenIddictClientWebIntegrationConstants; namespace OpenIddict.Client.WebIntegration; @@ -131,7 +130,7 @@ public static partial class OpenIddictClientWebIntegrationHandlers = OpenIddictClientHandlerDescriptor.CreateBuilder() .AddFilter() .UseSingletonHandler() - .SetOrder(AttachBasicAuthenticationCredentials.Descriptor.Order - 500) + .SetOrder(AttachBasicAuthenticationCredentials.Descriptor.Order - 500) .SetType(OpenIddictClientHandlerType.BuiltIn) .Build(); @@ -155,49 +154,10 @@ public static partial class OpenIddictClientWebIntegrationHandlers var request = context.Transaction.GetHttpRequestMessage() ?? throw new InvalidOperationException(SR.GetResourceString(SR.ID0173)); - // Note: Bitly only supports using "client_secret_post" for the authorization code grant but not for - // the resource owner password credentials grant, that requires using "client_secret_basic" instead. - if (context.Registration.ProviderType is ProviderTypes.Bitly && - context.GrantType is GrantTypes.Password && - !string.IsNullOrEmpty(context.Request.ClientId) && - !string.IsNullOrEmpty(context.Request.ClientSecret)) - { - // Important: the credentials MUST be formURL-encoded before being base64-encoded. - var credentials = Convert.ToBase64String(Encoding.ASCII.GetBytes(new StringBuilder() - .Append(EscapeDataString(context.Request.ClientId)) - .Append(':') - .Append(EscapeDataString(context.Request.ClientSecret)) - .ToString())); - - // Attach the authorization header containing the client credentials to the HTTP request. - request.Headers.Authorization = new AuthenticationHeaderValue(Schemes.Basic, credentials); - - // Remove the client credentials from the request payload to ensure they are not sent twice. - context.Request.ClientId = context.Request.ClientSecret = null; - } - - // These providers require using basic authentication to flow the client_id - // for all types of client applications, even when there's no client_secret. - if (context.Registration.ProviderType is ProviderTypes.Reddit && - !string.IsNullOrEmpty(context.Request.ClientId)) - { - // Important: the credentials MUST be formURL-encoded before being base64-encoded. - var credentials = Convert.ToBase64String(Encoding.ASCII.GetBytes(new StringBuilder() - .Append(EscapeDataString(context.Request.ClientId)) - .Append(':') - .Append(EscapeDataString(context.Request.ClientSecret)) - .ToString())); - - // Attach the authorization header containing the client identifier to the HTTP request. - request.Headers.Authorization = new AuthenticationHeaderValue(Schemes.Basic, credentials); - - // Remove the client credentials from the request payload to ensure they are not sent twice. - context.Request.ClientId = context.Request.ClientSecret = null; - } - // These providers don't implement the standard version of the client_secret_basic // authentication method as they don't support formURL-encoding the client credentials. - else if (context.Registration.ProviderType is ProviderTypes.EpicGames && + if (context.Registration.ProviderType is ProviderTypes.EpicGames && + context.ClientAuthenticationMethod is ClientAuthenticationMethods.ClientSecretBasic && !string.IsNullOrEmpty(context.Request.ClientId) && !string.IsNullOrEmpty(context.Request.ClientSecret)) { @@ -215,9 +175,6 @@ public static partial class OpenIddictClientWebIntegrationHandlers } return default; - - static string? EscapeDataString(string? value) - => value is not null ? Uri.EscapeDataString(value).Replace("%20", "+") : null; } } diff --git a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Revocation.cs b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Revocation.cs index 3d22205f..d3c614fb 100644 --- a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Revocation.cs +++ b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Revocation.cs @@ -5,13 +5,9 @@ */ using System.Collections.Immutable; -using System.Diagnostics; using System.Net.Http; -using System.Net.Http.Headers; -using System.Text; using static OpenIddict.Client.SystemNetHttp.OpenIddictClientSystemNetHttpHandlerFilters; using static OpenIddict.Client.SystemNetHttp.OpenIddictClientSystemNetHttpHandlers; -using static OpenIddict.Client.SystemNetHttp.OpenIddictClientSystemNetHttpHandlers.Exchange; using static OpenIddict.Client.WebIntegration.OpenIddictClientWebIntegrationConstants; namespace OpenIddict.Client.WebIntegration; @@ -21,80 +17,12 @@ public static partial class OpenIddictClientWebIntegrationHandlers public static class Revocation { public static ImmutableArray DefaultHandlers { get; } = ImmutableArray.Create([ - /* - * Revocation request preparation: - */ - AttachNonStandardBasicAuthenticationCredentials.Descriptor, - /* * Revocation response extraction: */ NormalizeContentType.Descriptor ]); - /// - /// Contains the logic responsible for attaching the client credentials to the HTTP Authorization - /// header using a non-standard construction logic for the providers that require it. - /// - public sealed class AttachNonStandardBasicAuthenticationCredentials : IOpenIddictClientHandler - { - /// - /// Gets the default descriptor definition assigned to this handler. - /// - public static OpenIddictClientHandlerDescriptor Descriptor { get; } - = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() - .UseSingletonHandler() - .SetOrder(AttachBasicAuthenticationCredentials.Descriptor.Order - 500) - .SetType(OpenIddictClientHandlerType.BuiltIn) - .Build(); - - /// - public ValueTask HandleAsync(PrepareRevocationRequestContext context) - { - if (context is null) - { - throw new ArgumentNullException(nameof(context)); - } - - // Some providers are known to incorrectly implement basic authentication support, either because - // an incorrect encoding scheme is used (e.g the credentials are not formURL-encoded as required - // by the OAuth 2.0 specification) or because basic authentication is required even for public - // clients, even though these clients don't have a secret (which requires using an empty password). - - Debug.Assert(context.Request is not null, SR.GetResourceString(SR.ID4008)); - - // This handler only applies to System.Net.Http requests. If the HTTP request cannot be resolved, - // this may indicate that the request was incorrectly processed by another client stack. - var request = context.Transaction.GetHttpRequestMessage() ?? - throw new InvalidOperationException(SR.GetResourceString(SR.ID0173)); - - // These providers require using basic authentication to flow the client_id - // for all types of client applications, even when there's no client_secret. - if (context.Registration.ProviderType is ProviderTypes.Reddit && - !string.IsNullOrEmpty(context.Request.ClientId)) - { - // Important: the credentials MUST be formURL-encoded before being base64-encoded. - var credentials = Convert.ToBase64String(Encoding.ASCII.GetBytes(new StringBuilder() - .Append(EscapeDataString(context.Request.ClientId)) - .Append(':') - .Append(EscapeDataString(context.Request.ClientSecret)) - .ToString())); - - // Attach the authorization header containing the client identifier to the HTTP request. - request.Headers.Authorization = new AuthenticationHeaderValue(Schemes.Basic, credentials); - - // Remove the client credentials from the request payload to ensure they are not sent twice. - context.Request.ClientId = context.Request.ClientSecret = null; - } - - return default; - - static string? EscapeDataString(string? value) - => value is not null ? Uri.EscapeDataString(value).Replace("%20", "+") : null; - } - } - /// /// Contains the logic responsible for normalizing the returned content /// type of revocation responses for the providers that require it. diff --git a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.cs b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.cs index e306ac55..d040b712 100644 --- a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.cs +++ b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.cs @@ -25,6 +25,7 @@ public static partial class OpenIddictClientWebIntegrationHandlers ValidateRedirectionRequestSignature.Descriptor, HandleNonStandardFrontchannelErrorResponse.Descriptor, ValidateNonStandardParameters.Descriptor, + OverrideTokenEndpointClientAuthenticationMethod.Descriptor, OverrideTokenEndpoint.Descriptor, AttachNonStandardClientAssertionClaims.Descriptor, AttachAdditionalTokenRequestParameters.Descriptor, @@ -32,9 +33,9 @@ public static partial class OpenIddictClientWebIntegrationHandlers AdjustRedirectUriInTokenRequest.Descriptor, OverrideValidatedBackchannelTokens.Descriptor, DisableBackchannelIdentityTokenNonceValidation.Descriptor, + OverrideUserInfoRetrieval.Descriptor, + OverrideUserInfoValidation.Descriptor, OverrideUserInfoEndpoint.Descriptor, - DisableUserInfoRetrieval.Descriptor, - DisableUserInfoValidation.Descriptor, AttachAdditionalUserInfoRequestParameters.Descriptor, PopulateUserInfoTokenPrincipalFromTokenResponse.Descriptor, MapCustomWebServicesFederationClaims.Descriptor, @@ -52,6 +53,7 @@ public static partial class OpenIddictClientWebIntegrationHandlers /* * Revocation processing: */ + OverrideRevocationEndpointClientAuthenticationMethod.Descriptor, AttachNonStandardRevocationClientAssertionClaims.Descriptor, AttachRevocationRequestNonStandardClientCredentials.Descriptor, @@ -424,6 +426,50 @@ public static partial class OpenIddictClientWebIntegrationHandlers } } + /// + /// Contains the logic responsible for overriding the negotiated token + /// endpoint client authentication method for the providers that require it. + /// + public sealed class OverrideTokenEndpointClientAuthenticationMethod : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(AttachTokenEndpointClientAuthenticationMethod.Descriptor.Order + 500) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessAuthenticationContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + context.TokenEndpointClientAuthenticationMethod = context.Registration.ProviderType switch + { + // Note: Bitly only supports using "client_secret_post" for the authorization code + // grant but not for the resource owner password credentials grant, that requires + // using the "client_secret_basic" method instead. + ProviderTypes.Bitly when context.GrantType is GrantTypes.Password + => ClientAuthenticationMethods.ClientSecretBasic, + + // Note: Reddit requires using basic authentication to flow the client_id for all types + // of client applications, even when there's no client_secret assigned or attached. + ProviderTypes.Reddit => ClientAuthenticationMethods.ClientSecretBasic, + + _ => context.TokenEndpointClientAuthenticationMethod + }; + + return default; + } + } + /// /// Contains the logic responsible for overriding the address /// of the token endpoint for the providers that require it. @@ -794,102 +840,16 @@ public static partial class OpenIddictClientWebIntegrationHandlers } /// - /// Contains the logic responsible for overriding the address - /// of the userinfo endpoint for the providers that require it. - /// - public sealed class OverrideUserInfoEndpoint : IOpenIddictClientHandler - { - /// - /// Gets the default descriptor definition assigned to this handler. - /// - public static OpenIddictClientHandlerDescriptor Descriptor { get; } - = OpenIddictClientHandlerDescriptor.CreateBuilder() - .UseSingletonHandler() - .SetOrder(ResolveUserInfoEndpoint.Descriptor.Order + 500) - .SetType(OpenIddictClientHandlerType.BuiltIn) - .Build(); - - /// - public ValueTask HandleAsync(ProcessAuthenticationContext context) - { - if (context is null) - { - throw new ArgumentNullException(nameof(context)); - } - - context.UserInfoEndpoint = context.Registration.ProviderType switch - { - // Dailymotion's userinfo endpoint requires sending the user identifier in the URI path. - ProviderTypes.Dailymotion when (string?) context.TokenResponse?["uid"] is string identifier - => OpenIddictHelpers.CreateAbsoluteUri( - left : new Uri("https://api.dailymotion.com/user", UriKind.Absolute), - right: new Uri(identifier, UriKind.Relative)), - - // HubSpot doesn't have a static userinfo endpoint but allows retrieving basic information - // by using an access token info endpoint that requires sending the token in the URI path. - ProviderTypes.HubSpot when - (context.BackchannelAccessToken ?? context.FrontchannelAccessToken) is { Length: > 0 } token - => OpenIddictHelpers.CreateAbsoluteUri( - left : new Uri("https://api.hubapi.com/oauth/v1/access-tokens", UriKind.Absolute), - right: new Uri(token, UriKind.Relative)), - - // SuperOffice doesn't expose a static OpenID Connect userinfo endpoint but offers an API whose - // absolute URI needs to be computed based on a special claim returned in the identity token. - ProviderTypes.SuperOffice when - (context.BackchannelIdentityTokenPrincipal ?? // Always prefer the backchannel identity token when available. - context.FrontchannelIdentityTokenPrincipal) is ClaimsPrincipal principal && - Uri.TryCreate(principal.GetClaim("http://schemes.superoffice.net/identity/webapi_url"), UriKind.Absolute, out Uri? uri) - => OpenIddictHelpers.CreateAbsoluteUri(uri, new Uri("v1/user/currentPrincipal", UriKind.Relative)), - - // Zoho requires using a region-specific userinfo endpoint determined using - // the "location" parameter returned from the authorization endpoint. - // - // For more information, see - // https://www.zoho.com/accounts/protocol/oauth/multi-dc/client-authorization.html. - ProviderTypes.Zoho when context.GrantType is GrantTypes.AuthorizationCode - => ((string?) context.Request?["location"])?.ToUpperInvariant() switch - { - "AU" => new Uri("https://accounts.zoho.com.au/oauth/user/info", UriKind.Absolute), - "CA" => new Uri("https://accounts.zohocloud.ca/oauth/user/info", UriKind.Absolute), - "EU" => new Uri("https://accounts.zoho.eu/oauth/user/info", UriKind.Absolute), - "IN" => new Uri("https://accounts.zoho.in/oauth/user/info", UriKind.Absolute), - "JP" => new Uri("https://accounts.zoho.jp/oauth/user/info", UriKind.Absolute), - "SA" => new Uri("https://accounts.zoho.sa/oauth/user/info", UriKind.Absolute), - _ => new Uri("https://accounts.zoho.com/oauth/user/info", UriKind.Absolute) - }, - - ProviderTypes.Zoho when context.GrantType is GrantTypes.RefreshToken - => !context.Properties.TryGetValue(Zoho.Properties.Location, out string? location) || - string.IsNullOrEmpty(location) ? throw new InvalidOperationException(SR.GetResourceString(SR.ID0451)) : - location?.ToUpperInvariant() switch - { - "AU" => new Uri("https://accounts.zoho.com.au/oauth/user/info", UriKind.Absolute), - "CA" => new Uri("https://accounts.zohocloud.ca/oauth/user/info", UriKind.Absolute), - "EU" => new Uri("https://accounts.zoho.eu/oauth/user/info", UriKind.Absolute), - "IN" => new Uri("https://accounts.zoho.in/oauth/user/info", UriKind.Absolute), - "JP" => new Uri("https://accounts.zoho.jp/oauth/user/info", UriKind.Absolute), - "SA" => new Uri("https://accounts.zoho.sa/oauth/user/info", UriKind.Absolute), - _ => new Uri("https://accounts.zoho.com/oauth/user/info", UriKind.Absolute) - }, - - _ => context.UserInfoEndpoint - }; - - return default; - } - } - - /// - /// Contains the logic responsible for disabling the userinfo retrieval for the providers that require it. + /// Contains the logic responsible for overriding the userinfo retrieval for the providers that require it. /// - public sealed class DisableUserInfoRetrieval : IOpenIddictClientHandler + public sealed class OverrideUserInfoRetrieval : IOpenIddictClientHandler { /// /// Gets the default descriptor definition assigned to this handler. /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .UseSingletonHandler() + .UseSingletonHandler() .SetOrder(EvaluateUserInfoRequest.Descriptor.Order + 250) .SetType(OpenIddictClientHandlerType.BuiltIn) .Build(); @@ -912,6 +872,21 @@ public static partial class OpenIddictClientWebIntegrationHandlers // userinfo retrieval is always disabled for the ADFS provider. ProviderTypes.ActiveDirectoryFederationServices => false, + // Note: these providers don't have a static userinfo endpoint attached to their configuration + // so OpenIddict doesn't, by default, send a userinfo request. Since a dynamic endpoint is later + // computed and attached to the context, the default value MUST be overridden to send a request. + ProviderTypes.Dailymotion or ProviderTypes.HubSpot or + ProviderTypes.SuperOffice or ProviderTypes.Zoho + when context.GrantType is GrantTypes.AuthorizationCode or GrantTypes.DeviceCode or + GrantTypes.Implicit or GrantTypes.Password or + GrantTypes.RefreshToken or + // Apply the same logic for custom grant types. + (not null and not (GrantTypes.AuthorizationCode or GrantTypes.ClientCredentials or + GrantTypes.DeviceCode or GrantTypes.Implicit or + GrantTypes.Password or GrantTypes.RefreshToken)) && + !context.DisableUserInfoRetrieval && (!string.IsNullOrEmpty(context.BackchannelAccessToken) || + !string.IsNullOrEmpty(context.FrontchannelAccessToken)) => true, + // Note: the frontchannel or backchannel access tokens returned by Microsoft Entra ID // when a Xbox scope is requested cannot be used with the userinfo endpoint as they use // a legacy format that is not supported by the Microsoft Entra ID userinfo implementation. @@ -956,17 +931,17 @@ public static partial class OpenIddictClientWebIntegrationHandlers } /// - /// Contains the logic responsible for disabling the userinfo validation for the providers that require it. + /// Contains the logic responsible for overriding the userinfo validation for the providers that require it. /// - public sealed class DisableUserInfoValidation : IOpenIddictClientHandler + public sealed class OverrideUserInfoValidation : IOpenIddictClientHandler { /// /// Gets the default descriptor definition assigned to this handler. /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .UseSingletonHandler() - .SetOrder(DisableUserInfoRetrieval.Descriptor.Order + 250) + .UseSingletonHandler() + .SetOrder(OverrideUserInfoRetrieval.Descriptor.Order + 250) .SetType(OpenIddictClientHandlerType.BuiltIn) .Build(); @@ -995,6 +970,93 @@ public static partial class OpenIddictClientWebIntegrationHandlers } } + /// + /// Contains the logic responsible for overriding the address + /// of the userinfo endpoint for the providers that require it. + /// + public sealed class OverrideUserInfoEndpoint : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(ResolveUserInfoEndpoint.Descriptor.Order + 500) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessAuthenticationContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + context.UserInfoEndpoint = context.Registration.ProviderType switch + { + // Dailymotion's userinfo endpoint requires sending the user identifier in the URI path. + ProviderTypes.Dailymotion when (string?) context.TokenResponse?["uid"] is string identifier + => OpenIddictHelpers.CreateAbsoluteUri( + left : new Uri("https://api.dailymotion.com/user", UriKind.Absolute), + right: new Uri(identifier, UriKind.Relative)), + + // HubSpot doesn't have a static userinfo endpoint but allows retrieving basic information + // by using an access token info endpoint that requires sending the token in the URI path. + ProviderTypes.HubSpot when + (context.BackchannelAccessToken ?? context.FrontchannelAccessToken) is { Length: > 0 } token + => OpenIddictHelpers.CreateAbsoluteUri( + left : new Uri("https://api.hubapi.com/oauth/v1/access-tokens", UriKind.Absolute), + right: new Uri(token, UriKind.Relative)), + + // SuperOffice doesn't expose a static OpenID Connect userinfo endpoint but offers an API whose + // absolute URI needs to be computed based on a special claim returned in the identity token. + ProviderTypes.SuperOffice when + (context.BackchannelIdentityTokenPrincipal ?? // Always prefer the backchannel identity token when available. + context.FrontchannelIdentityTokenPrincipal) is ClaimsPrincipal principal && + Uri.TryCreate(principal.GetClaim("http://schemes.superoffice.net/identity/webapi_url"), UriKind.Absolute, out Uri? uri) + => OpenIddictHelpers.CreateAbsoluteUri(uri, new Uri("v1/user/currentPrincipal", UriKind.Relative)), + + // Zoho requires using a region-specific userinfo endpoint determined using + // the "location" parameter returned from the authorization endpoint. + // + // For more information, see + // https://www.zoho.com/accounts/protocol/oauth/multi-dc/client-authorization.html. + ProviderTypes.Zoho when context.GrantType is GrantTypes.AuthorizationCode + => ((string?) context.Request?["location"])?.ToUpperInvariant() switch + { + "AU" => new Uri("https://accounts.zoho.com.au/oauth/user/info", UriKind.Absolute), + "CA" => new Uri("https://accounts.zohocloud.ca/oauth/user/info", UriKind.Absolute), + "EU" => new Uri("https://accounts.zoho.eu/oauth/user/info", UriKind.Absolute), + "IN" => new Uri("https://accounts.zoho.in/oauth/user/info", UriKind.Absolute), + "JP" => new Uri("https://accounts.zoho.jp/oauth/user/info", UriKind.Absolute), + "SA" => new Uri("https://accounts.zoho.sa/oauth/user/info", UriKind.Absolute), + _ => new Uri("https://accounts.zoho.com/oauth/user/info", UriKind.Absolute) + }, + + ProviderTypes.Zoho when context.GrantType is GrantTypes.RefreshToken + => !context.Properties.TryGetValue(Zoho.Properties.Location, out string? location) || + string.IsNullOrEmpty(location) ? throw new InvalidOperationException(SR.GetResourceString(SR.ID0451)) : + location?.ToUpperInvariant() switch + { + "AU" => new Uri("https://accounts.zoho.com.au/oauth/user/info", UriKind.Absolute), + "CA" => new Uri("https://accounts.zohocloud.ca/oauth/user/info", UriKind.Absolute), + "EU" => new Uri("https://accounts.zoho.eu/oauth/user/info", UriKind.Absolute), + "IN" => new Uri("https://accounts.zoho.in/oauth/user/info", UriKind.Absolute), + "JP" => new Uri("https://accounts.zoho.jp/oauth/user/info", UriKind.Absolute), + "SA" => new Uri("https://accounts.zoho.sa/oauth/user/info", UriKind.Absolute), + _ => new Uri("https://accounts.zoho.com/oauth/user/info", UriKind.Absolute) + }, + + _ => context.UserInfoEndpoint + }; + + return default; + } + } + /// /// Contains the logic responsible for attaching additional parameters /// to the userinfo request for the providers that require it. @@ -1864,6 +1926,44 @@ public static partial class OpenIddictClientWebIntegrationHandlers } } + /// + /// Contains the logic responsible for overriding the negotiated revocation + /// endpoint client authentication method for the providers that require it. + /// + public sealed class OverrideRevocationEndpointClientAuthenticationMethod : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(AttachRevocationEndpointClientAuthenticationMethod.Descriptor.Order + 500) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessRevocationContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + context.RevocationEndpointClientAuthenticationMethod = context.Registration.ProviderType switch + { + // Note: Reddit requires using basic authentication to flow the client_id for all types + // of client applications, even when there's no client_secret assigned or attached. + ProviderTypes.Reddit => ClientAuthenticationMethods.ClientSecretBasic, + + _ => context.RevocationEndpointClientAuthenticationMethod + }; + + return default; + } + } + /// /// Contains the logic responsible for adding non-standard claims to the client /// assertions used for the revocation endpoint for the providers that require it. diff --git a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationProviders.xml b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationProviders.xml index 42f5a1ca..99f22ae2 100644 --- a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationProviders.xml +++ b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationProviders.xml @@ -877,6 +877,12 @@ + + + +