From 2f5a76636ba61e4c6240188ee03cdbc8eeba3dab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Chalet?= Date: Mon, 24 Oct 2022 17:11:38 +0200 Subject: [PATCH] Add Deezer support --- ...ctClientWebIntegrationHandlers.Exchange.cs | 119 +++++++++++++++ ...ctClientWebIntegrationHandlers.Userinfo.cs | 12 +- .../OpenIddictClientWebIntegrationHandlers.cs | 144 ++++++++++++++++-- ...penIddictClientWebIntegrationProviders.xml | 16 ++ 4 files changed, 275 insertions(+), 16 deletions(-) create mode 100644 src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Exchange.cs diff --git a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Exchange.cs b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Exchange.cs new file mode 100644 index 00000000..62f49568 --- /dev/null +++ b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Exchange.cs @@ -0,0 +1,119 @@ +/* + * 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 OpenIddict.Extensions; +using static OpenIddict.Client.SystemNetHttp.OpenIddictClientSystemNetHttpHandlerFilters; +using static OpenIddict.Client.SystemNetHttp.OpenIddictClientSystemNetHttpHandlers; +using static OpenIddict.Client.WebIntegration.OpenIddictClientWebIntegrationConstants; + +namespace OpenIddict.Client.WebIntegration; + +public static partial class OpenIddictClientWebIntegrationHandlers +{ + public static class Exchange + { + public static ImmutableArray DefaultHandlers { get; } = ImmutableArray.Create( + /* + * Token request preparation: + */ + AttachNonStandardQueryStringParameters.Descriptor, + + /* + * Token response extraction: + */ + MapNonStandardResponseParameters.Descriptor); + + /// + /// Contains the logic responsible for attaching non-standard query string + /// parameters to the token request for the providers that require it. + /// + public class AttachNonStandardQueryStringParameters : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(AttachQueryStringParameters.Descriptor.Order + 500) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(PrepareTokenRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // 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)); + + if (request.RequestUri is null) + { + return default; + } + + // By default, Deezer returns non-standard token responses formatted as formurl-encoded + // payloads and declared as "text/html" content but allows sending an "output" query string + // parameter containing "json" to get a response conforming to the OAuth 2.0 specification. + if (context.Registration.ProviderName is Providers.Deezer) + { + request.RequestUri = OpenIddictHelpers.AddQueryStringParameter( + request.RequestUri, name: "output", value: "json"); + } + + return default; + } + } + + /// + /// Contains the logic responsible for attaching non-standard query string + /// parameters to the token request for the providers that require it. + /// + public class MapNonStandardResponseParameters : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(int.MaxValue - 50_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ExtractTokenResponseContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (context.Response is null) + { + return default; + } + + // Note: Deezer doesn't return a standard "expires_in" parameter + // but returns an equivalent "expires" integer parameter instead. + if (context.Registration.ProviderName is Providers.Deezer) + { + context.Response[Parameters.ExpiresIn] = context.Response["expires"]; + context.Response["expires"] = null; + } + + return default; + } + } + } +} diff --git a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Userinfo.cs b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Userinfo.cs index 7e7ec84a..93df7ac1 100644 --- a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Userinfo.cs +++ b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Userinfo.cs @@ -7,7 +7,6 @@ using System.Collections.Immutable; using System.Diagnostics; using System.Text.Json; -using static OpenIddict.Client.OpenIddictClientHandlers.Userinfo; using static OpenIddict.Client.SystemNetHttp.OpenIddictClientSystemNetHttpHandlerFilters; using static OpenIddict.Client.SystemNetHttp.OpenIddictClientSystemNetHttpHandlers.Userinfo; using static OpenIddict.Client.WebIntegration.OpenIddictClientWebIntegrationConstants; @@ -63,7 +62,7 @@ public static partial class OpenIddictClientWebIntegrationHandlers // using the Bearer authentication scheme. Some providers don't support this method // and require sending the access token as part of the userinfo request payload. - if (context.Registration.ProviderName is Providers.StackExchange) + if (context.Registration.ProviderName is Providers.Deezer or Providers.StackExchange) { context.Request.AccessToken = request.Headers.Authorization?.Parameter; request.Headers.Authorization = null; @@ -77,21 +76,20 @@ public static partial class OpenIddictClientWebIntegrationHandlers /// Contains the logic responsible for extracting the userinfo response /// from nested JSON nodes (e.g "data") for the providers that require it. /// - public class UnwrapUserinfoResponse : IOpenIddictClientHandler + public class UnwrapUserinfoResponse : IOpenIddictClientHandler { /// /// Gets the default descriptor definition assigned to this handler. /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } - = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() + = OpenIddictClientHandlerDescriptor.CreateBuilder() .UseSingletonHandler() - .SetOrder(PopulateClaims.Descriptor.Order - 500) + .SetOrder(int.MaxValue - 50_000) .SetType(OpenIddictClientHandlerType.BuiltIn) .Build(); /// - public ValueTask HandleAsync(HandleUserinfoResponseContext context) + public ValueTask HandleAsync(ExtractUserinfoResponseContext context) { if (context is null) { diff --git a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.cs b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.cs index 48acb1b9..1a4643fb 100644 --- a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.cs +++ b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.cs @@ -8,6 +8,7 @@ using System.Collections.Immutable; using System.ComponentModel; using System.Diagnostics; using System.Security.Claims; +using OpenIddict.Extensions; using static OpenIddict.Client.WebIntegration.OpenIddictClientWebIntegrationConstants; namespace OpenIddict.Client.WebIntegration; @@ -22,15 +23,18 @@ public static partial class OpenIddictClientWebIntegrationHandlers HandleNonStandardFrontchannelErrorResponse.Descriptor, AttachNonStandardClientAssertionTokenClaims.Descriptor, AttachTokenRequestNonStandardClientCredentials.Descriptor, + AdjustRedirectUriInTokenRequest.Descriptor, OverrideValidatedBackchannelTokens.Descriptor, AttachAdditionalUserinfoRequestParameters.Descriptor, /* * Challenge processing: */ - AttachNonDefaultResponseMode.Descriptor, - FormatNonStandardScopeParameter.Descriptor) + OverrideResponseMode.Descriptor, + FormatNonStandardScopeParameter.Descriptor, + IncludeStateParameterInRedirectUri.Descriptor) .AddRange(Discovery.DefaultHandlers) + .AddRange(Exchange.DefaultHandlers) .AddRange(Protection.DefaultHandlers) .AddRange(Userinfo.DefaultHandlers); @@ -65,14 +69,27 @@ public static partial class OpenIddictClientWebIntegrationHandlers // Errors that are not handled here will be automatically handled // by the standard handler present in the core OpenIddict client. - if (context.Registration.ProviderName is Providers.LinkedIn) + if (context.Registration.ProviderName is Providers.Deezer) { - var error = (string?) context.Request[Parameters.Error]; - if (string.IsNullOrEmpty(error)) + // Note: Deezer uses a custom "error_reason" parameter instead of the + // standard "error" parameter defined by the OAuth 2.0 specification. + // + // See https://developers.deezer.com/api/oauth for more information. + var error = (string?) context.Request["error_reason"]; + if (string.Equals(error, "user_denied", StringComparison.Ordinal)) { + context.Reject( + error: Errors.AccessDenied, + description: SR.GetResourceString(SR.ID2149), + uri: SR.FormatID8000(SR.ID2149)); + return default; } + } + else if (context.Registration.ProviderName is Providers.LinkedIn) + { + var error = (string?) context.Request[Parameters.Error]; if (string.Equals(error, "user_cancelled_authorize", StringComparison.Ordinal) || string.Equals(error, "user_cancelled_login", StringComparison.Ordinal)) { @@ -178,6 +195,61 @@ public static partial class OpenIddictClientWebIntegrationHandlers } } + /// + /// Contains the logic responsible for attaching custom client credentials + /// parameters to the token request for the providers that require it. + /// + public class AdjustRedirectUriInTokenRequest : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(AttachTokenRequestClientCredentials.Descriptor.Order + 500) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessAuthenticationContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + Debug.Assert(context.TokenRequest is not null, SR.GetResourceString(SR.ID4008)); + + if (context.TokenRequest.RedirectUri is null) + { + return default; + } + + // Note: some providers don't support the "state" parameter, don't flow + // it correctly or don't include it in errored authorization responses. + // + // Since OpenIddict requires flowing the state token in every circumstance + // (for security reasons), the state token is appended to the "redirect_uri" + // instead of being sent as a standard OAuth 2.0 authorization request parameter. + // + // Note: for token requests to use the actual redirect_uri that was sent as part + // of the authorization requests, the value persisted in the state token principal + // MUST be replaced to include the state token received by the redirection endpoint. + + if (context.Registration.ProviderName is Providers.Deezer) + { + context.TokenRequest.RedirectUri = OpenIddictHelpers.AddQueryStringParameter( + address: new Uri(context.TokenRequest.RedirectUri, UriKind.Absolute), + name: Parameters.State, + value: context.StateToken).AbsoluteUri; + } + + return default; + } + } + /// /// Contains the logic responsible for overriding the set /// of required tokens for the providers that require it. @@ -283,9 +355,9 @@ public static partial class OpenIddictClientWebIntegrationHandlers } /// - /// Contains the logic responsible for attaching a specific response mode for providers that require it. + /// Contains the logic responsible for overriding response mode for providers that require it. /// - public class AttachNonDefaultResponseMode : IOpenIddictClientHandler + public class OverrideResponseMode : IOpenIddictClientHandler { /// /// Gets the default descriptor definition assigned to this handler. @@ -293,7 +365,7 @@ public static partial class OpenIddictClientWebIntegrationHandlers public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() .AddFilter() - .UseSingletonHandler() + .UseSingletonHandler() // Note: this handler MUST be invoked after the scopes have been attached to the // context to support overriding the response mode based on the requested scopes. .SetOrder(AttachScopes.Descriptor.Order + 500) @@ -350,7 +422,7 @@ public static partial class OpenIddictClientWebIntegrationHandlers { // The following providers are known to use comma-separated scopes instead of // the standard format (that requires using a space as the scope separator): - Providers.Reddit => string.Join(",", context.Scopes), + Providers.Deezer or Providers.Reddit => string.Join(",", context.Scopes), _ => context.Request.Scope }; @@ -358,4 +430,58 @@ public static partial class OpenIddictClientWebIntegrationHandlers return default; } } + + /// + /// Contains the logic responsible for persisting the state parameter in the redirect URI for + /// providers that don't support it but allow arbitrary dynamic parameters in redirect_uri. + /// + public class IncludeStateParameterInRedirectUri : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(FormatNonStandardScopeParameter.Descriptor.Order + 500) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessChallengeContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (context.RedirectUri is null) + { + return default; + } + + // Note: some providers don't support the "state" parameter, don't flow + // it correctly or don't include it in errored authorization responses. + // + // Since OpenIddict requires flowing the state token in every circumstance + // (for security reasons), the state token is appended to the "redirect_uri" + // instead of being sent as a standard OAuth 2.0 authorization request parameter. + // + // Note: this workaround only works for providers that allow dynamic + // redirection URIs and implement a relaxed validation policy logic. + + if (context.Registration.ProviderName is Providers.Deezer) + { + context.Request.RedirectUri = OpenIddictHelpers.AddQueryStringParameter( + address: new Uri(context.RedirectUri, UriKind.Absolute), + name: Parameters.State, + value: context.Request.State).AbsoluteUri; + + context.Request.State = null; + } + + return default; + } + } } diff --git a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationProviders.xml b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationProviders.xml index 2364ff9d..b8075c98 100644 --- a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationProviders.xml +++ b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationProviders.xml @@ -13,6 +13,22 @@ Description="The team ID associated with the developer account" /> + + + + + + + +