From e17e4ee9b2846f166d3049bc52079604c0cde94d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Chalet?= Date: Mon, 19 Feb 2024 15:44:55 +0100 Subject: [PATCH] Update the System.Net.Http client integration to support empty HTTP responses and update the Reddit provider to support token revocation --- ...tClientSystemNetHttpHandlers.Revocation.cs | 1 + .../OpenIddictClientSystemNetHttpHandlers.cs | 48 +++++- ...ClientWebIntegrationHandlers.Revocation.cs | 151 ++++++++++++++++++ .../OpenIddictClientWebIntegrationHandlers.cs | 1 + ...penIddictClientWebIntegrationProviders.xml | 3 + 5 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Revocation.cs diff --git a/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.Revocation.cs b/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.Revocation.cs index 19b8f996..41d336f8 100644 --- a/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.Revocation.cs +++ b/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.Revocation.cs @@ -37,6 +37,7 @@ public static partial class OpenIddictClientSystemNetHttpHandlers DecompressResponseContent.Descriptor, ExtractJsonHttpResponse.Descriptor, ExtractWwwAuthenticateHeader.Descriptor, + ExtractEmptyHttpResponse.Descriptor, ValidateHttpResponse.Descriptor, DisposeHttpResponse.Descriptor ]; diff --git a/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.cs b/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.cs index 4b5807ed..849e7bd4 100644 --- a/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.cs +++ b/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.cs @@ -709,7 +709,7 @@ public static partial class OpenIddictClientSystemNetHttpHandlers = OpenIddictClientHandlerDescriptor.CreateBuilder() .AddFilter() .UseSingletonHandler>() - .SetOrder(ValidateHttpResponse.Descriptor.Order - 1_000) + .SetOrder(ExtractEmptyHttpResponse.Descriptor.Order - 1_000) .SetType(OpenIddictClientHandlerType.BuiltIn) .Build(); @@ -780,6 +780,52 @@ public static partial class OpenIddictClientSystemNetHttpHandlers } } + /// + /// Contains the logic responsible for extracting empty responses from the HTTP response. + /// + public sealed class ExtractEmptyHttpResponse : IOpenIddictClientHandler where TContext : BaseExternalContext + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler>() + .SetOrder(ValidateHttpResponse.Descriptor.Order - 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(TContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // Don't overwrite the response if one was already provided. + if (context.Transaction.Response is not null) + { + return default; + } + + // This handler only applies to System.Net.Http requests. If the HTTP response cannot be resolved, + // this may indicate that the request was incorrectly processed by another client stack. + var response = context.Transaction.GetHttpResponseMessage() ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0173)); + + // Only process an empty response if no Content-Type header is attached to the + // HTTP response and the Content-Length header is not present or set to 0. + if (response.Content.Headers is { ContentLength: null or 0, ContentType: null }) + { + context.Transaction.Response = new OpenIddictResponse(); + } + + return default; + } + } + /// /// Contains the logic responsible for extracting errors from WWW-Authenticate headers. /// diff --git a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Revocation.cs b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Revocation.cs new file mode 100644 index 00000000..f02e80b8 --- /dev/null +++ b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Revocation.cs @@ -0,0 +1,151 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +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; + +public static partial class OpenIddictClientWebIntegrationHandlers +{ + public static class Revocation + { + public static ImmutableArray DefaultHandlers { get; } = [ + /* + * 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. + /// + public sealed class NormalizeContentType : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ExtractJsonHttpResponse.Descriptor.Order - 500) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ExtractRevocationResponseContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // This handler only applies to System.Net.Http requests. If the HTTP response cannot be resolved, + // this may indicate that the request was incorrectly processed by another client stack. + var response = context.Transaction.GetHttpResponseMessage() ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0173)); + + if (response.Content is null) + { + return default; + } + + // Some providers are known to return invalid or incorrect media types, which prevents + // OpenIddict from extracting revocation responses. To work around that, the declared + // content type is replaced by the correct value for the providers that require it. + + response.Content.Headers.ContentType = context.Registration.ProviderType switch + { + // Reddit returns empty revocation responses declared as "application/json" responses. + // + // Since empty JSON payloads are not valid JSON nodes, the Content-Length is manually set + // to 0 to prevent OpenIddict from trying to extract a JSON payload from such responses. + ProviderTypes.Reddit when response.Content.Headers.ContentLength is 0 => null, + + _ => response.Content.Headers.ContentType + }; + + return default; + } + } + } +} diff --git a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.cs b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.cs index f55005ed..f95e801d 100644 --- a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.cs +++ b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.cs @@ -60,6 +60,7 @@ public static partial class OpenIddictClientWebIntegrationHandlers ..Discovery.DefaultHandlers, ..Exchange.DefaultHandlers, ..Protection.DefaultHandlers, + ..Revocation.DefaultHandlers, ..Userinfo.DefaultHandlers ]; diff --git a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationProviders.xml b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationProviders.xml index 02caf81d..2af3195c 100644 --- a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationProviders.xml +++ b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationProviders.xml @@ -945,12 +945,15 @@ Documentation="https://github.com/reddit-archive/reddit/wiki/OAuth2"> + +