From a37e6c65a1d99101b8e0c15cf2e7ade9754ca151 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Chalet?= Date: Sat, 24 Jun 2023 17:43:25 +0200 Subject: [PATCH] Add Shopify to the list of supported providers and update the generator to generate constants for the authentication properties --- Directory.Build.targets | 3 +- ...OpenIddictClientWebIntegrationGenerator.cs | 14 ++ .../Helpers/OpenIddictHelpers.cs | 66 +++++ .../OpenIddictResources.resx | 6 + ...ctClientWebIntegrationHandlers.Userinfo.cs | 9 + .../OpenIddictClientWebIntegrationHandlers.cs | 228 +++++++++++++++++- ...penIddictClientWebIntegrationProviders.xml | 39 +++ ...penIddictClientWebIntegrationProviders.xsd | 32 +++ .../OpenIddictClientHandlers.cs | 2 +- 9 files changed, 395 insertions(+), 4 deletions(-) diff --git a/Directory.Build.targets b/Directory.Build.targets index 28cb8624..3fa30360 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -85,13 +85,14 @@ $(DefineConstants);SUPPORTS_ENVIRONMENT_PROCESS_PATH + $(DefineConstants);SUPPORTS_HEXADECIMAL_STRING_CONVERSION $(DefineConstants);SUPPORTS_HTTP_CLIENT_DEFAULT_REQUEST_VERSION_POLICY $(DefineConstants);SUPPORTS_MULTIPLE_VALUES_IN_QUERYHELPERS + $(DefineConstants);SUPPORTS_NAMED_PIPE_STATIC_FACTORY_WITH_ACL $(DefineConstants);SUPPORTS_ONE_SHOT_HASHING_METHODS $(DefineConstants);SUPPORTS_OPERATING_SYSTEM_VERSIONS_COMPARISON $(DefineConstants);SUPPORTS_PEM_ENCODED_KEY_IMPORT $(DefineConstants);SUPPORTS_WINFORMS_TASK_DIALOG - $(DefineConstants);SUPPORTS_NAMED_PIPE_STATIC_FACTORY_WITH_ACL new + { + Name = (string) property.Attribute("Name"), + DictionaryKey = (string) property.Attribute("DictionaryKey") + }) + .ToList(), }) .ToList() }); diff --git a/shared/OpenIddict.Extensions/Helpers/OpenIddictHelpers.cs b/shared/OpenIddict.Extensions/Helpers/OpenIddictHelpers.cs index 846cab31..dbdacb13 100644 --- a/shared/OpenIddict.Extensions/Helpers/OpenIddictHelpers.cs +++ b/shared/OpenIddict.Extensions/Helpers/OpenIddictHelpers.cs @@ -548,6 +548,46 @@ internal static class OpenIddictHelpers return algorithm; } + /// + /// Computes the SHA-256 message authentication code (HMAC) of the specified array. + /// + /// The cryptographic key. + /// The data to hash. + /// The SHA-256 message authentication code (HMAC) of the specified array. + /// + /// The implementation resolved from is not valid. + /// + public static byte[] ComputeSha256MessageAuthenticationCode(byte[] key, byte[] data) + { + var algorithm = CryptoConfig.CreateFromName("OpenIddict HMAC SHA-256 Cryptographic Provider", new[] { key }) switch + { + HMACSHA256 result => result, + null => null, + var result => throw new CryptographicException(SR.FormatID0351(result.GetType().FullName)) + }; + + // If no custom algorithm was registered, use either the static/one-shot HashData() API + // on platforms that support it or create a default instance provided by the BCL. + if (algorithm is null) + { +#if SUPPORTS_ONE_SHOT_HASHING_METHODS + return HMACSHA256.HashData(key, data); +#else + algorithm = new HMACSHA256(key); +#endif + } + + try + { + return algorithm.ComputeHash(data); + } + + finally + { + algorithm.Dispose(); + } + } + /// /// Computes the SHA-256 hash of the specified array. /// @@ -842,6 +882,32 @@ internal static class OpenIddictHelpers #endif } + /// + /// Converts the specified hex-encoded to a byte array. + /// + /// The hexadecimal string. + /// The byte array. + public static byte[] ConvertFromHexadecimalString(string value) + { +#if SUPPORTS_HEXADECIMAL_STRING_CONVERSION + return Convert.FromHexString(value); +#else + if ((uint) value.Length % 2 is not 0) + { + throw new FormatException(SR.GetResourceString(SR.ID0413)); + } + + var array = new byte[value.Length / 2]; + + for (var index = 0; index < value.Length; index += 2) + { + array[index / 2] = Convert.ToByte(value.Substring(index, 2), 16); + } + + return array; +#endif + } + #if SUPPORTS_KEY_DERIVATION_WITH_SPECIFIED_HASH_ALGORITHM /// /// Creates a derived key based on the specified using PBKDF2. diff --git a/src/OpenIddict.Abstractions/OpenIddictResources.resx b/src/OpenIddict.Abstractions/OpenIddictResources.resx index de85102b..bd3b43a4 100644 --- a/src/OpenIddict.Abstractions/OpenIddictResources.resx +++ b/src/OpenIddict.Abstractions/OpenIddictResources.resx @@ -1546,6 +1546,12 @@ To apply post-logout redirection responses, create a class implementing 'IOpenId The issuer couldn't be resolved from the provider configuration or is not a valid absolute URI. Make sure the OpenIddict.Client.WebIntegration package is referenced and 'options.UseWebProviders()' is correctly called. + + The Shopify integration requires setting the shop name to be able to determine the location of the OAuth 2.0 endpoints. To dynamically set the shop name when triggering a challenge, add a ".shopify_shop_name" authentication property containing the shop name received by the installation endpoint or specified by the user. + + + The specified string is not a valid hexadecimal string. + The security token is missing. diff --git a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Userinfo.cs b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Userinfo.cs index ade77d65..b529c04f 100644 --- a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Userinfo.cs +++ b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Userinfo.cs @@ -126,6 +126,15 @@ public static partial class OpenIddictClientWebIntegrationHandlers request.Headers.Authorization = null; } + // Shopify requires using the non-standard "X-Shopify-Access-Token" header. + else if (context.Registration.ProviderType is ProviderTypes.Shopify) + { + request.Headers.TryAddWithoutValidation("X-Shopify-Access-Token", request.Headers.Authorization?.Parameter); + + // Remove the access token from the request headers to ensure it's not sent twice. + request.Headers.Authorization = null; + } + // Trovo requires using the "OAuth" scheme instead of the standard "Bearer" value. else if (context.Registration.ProviderType is ProviderTypes.Trovo) { diff --git a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.cs b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.cs index ad1bb6fe..b40e8332 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 System.Text; using System.Text.Json; using OpenIddict.Extensions; using static OpenIddict.Client.WebIntegration.OpenIddictClientWebIntegrationConstants; @@ -21,7 +22,9 @@ public static partial class OpenIddictClientWebIntegrationHandlers /* * Authentication processing: */ + ValidateRedirectionRequestSignature.Descriptor, HandleNonStandardFrontchannelErrorResponse.Descriptor, + ValidateNonStandardParameters.Descriptor, OverrideTokenEndpoint.Descriptor, AttachNonStandardClientAssertionTokenClaims.Descriptor, AttachTokenRequestNonStandardClientCredentials.Descriptor, @@ -49,6 +52,119 @@ public static partial class OpenIddictClientWebIntegrationHandlers .AddRange(Protection.DefaultHandlers) .AddRange(Userinfo.DefaultHandlers); + /// + /// Contains the logic responsible for validating the signature or message authentication + /// code attached to the redirection request for the providers that require it. + /// + public sealed class ValidateRedirectionRequestSignature : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(ValidateIssuerParameter.Descriptor.Order + 500) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessAuthenticationContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // Shopify returns custom/non-standard parameters like the name of the shop for which the + // installation request was initiated. To prevent these parameters from being tampered with, + // a "hmac" parameter is added by Shopify alongside a "timestamp" parameter containing the + // UNIX-formatted date at which the authorization response was generated. While this doesn't + // by itself protect against replayed HMACs, the HMAC always includes the "state" parameter, + // which is itself protected against replay attacks as state tokens are automatically marked + // as redeemed by OpenIddict when they are returned to the redirection endpoint. + // + // For more information, see + // https://shopify.dev/docs/apps/auth/oauth/getting-started#step-2-verify-the-installation-request. + if (context.Registration.ProviderType is ProviderTypes.Shopify && + !string.IsNullOrEmpty(context.Registration.ClientSecret)) + { + var signature = (string?) context.Request["hmac"]; + if (string.IsNullOrEmpty(signature)) + { + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2029("hmac"), + uri: SR.FormatID8000(SR.ID2029)); + + return default; + } + + var builder = new StringBuilder(); + + // Note: the "hmac" parameter MUST be ignored and the remaining parameters MUST be sorted alphabetically. + // + // See https://shopify.dev/docs/apps/auth/oauth/getting-started#remove-the-hmac-parameter-from-the-query-string + // for more information. + foreach (var (name, value) in + from parameter in OpenIddictHelpers.ParseQuery(context.RequestUri!.Query) + where !string.IsNullOrEmpty(parameter.Key) + where !string.Equals(parameter.Key, "hmac", StringComparison.Ordinal) + orderby parameter.Key ascending + from value in parameter.Value + select (Name: parameter.Key, Value: value)) + { + if (builder.Length > 0) + { + builder.Append('&'); + } + + builder.Append(Uri.EscapeDataString(name)); + + if (!string.IsNullOrEmpty(value)) + { + builder.Append('='); + builder.Append(Uri.EscapeDataString(value)); + } + } + + // Compare the received HMAC (represented as an hexadecimal string) and the HMAC computed + // locally from the concatenated query string: if the two don't match, return an error. + // + // Note: to prevent timing attacks, a time-constant comparer is always used. + try + { + if (!OpenIddictHelpers.FixedTimeEquals( + left : OpenIddictHelpers.ConvertFromHexadecimalString(signature), + right: OpenIddictHelpers.ComputeSha256MessageAuthenticationCode( + key : Encoding.UTF8.GetBytes(context.Registration.ClientSecret), + data: Encoding.UTF8.GetBytes(builder.ToString())))) + { + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2052("hmac"), + uri: SR.FormatID8000(SR.ID2052)); + + return default; + } + } + + catch (Exception exception) when (!OpenIddictHelpers.IsFatal(exception)) + { + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2052("hmac"), + uri: SR.FormatID8000(SR.ID2052)); + + return default; + } + } + + return default; + } + } + /// /// Contains the logic responsible for handling non-standard /// authorization errors for the providers that require it. @@ -132,6 +248,75 @@ public static partial class OpenIddictClientWebIntegrationHandlers } } + /// + /// Contains the logic responsible for validating custom parameters for the providers that require it. + /// + public sealed class ValidateNonStandardParameters : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .AddFilter() + .AddFilter() + .UseSingletonHandler() + .SetOrder(ResolveGrantTypeAndResponseTypeFromStateToken.Descriptor.Order + 500) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessAuthenticationContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + Debug.Assert(context.StateTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); + + if (context.Registration.ProviderType is ProviderTypes.Shopify) + { + var domain = (string?) context.Request["shop"]; + if (string.IsNullOrEmpty(domain)) + { + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2029("shop"), + uri: SR.FormatID8000(SR.ID2029)); + + return default; + } + + // Resolve the shop name from the authentication properties stored in the state token principal. + if (context.StateTokenPrincipal.FindFirst(Claims.Private.HostProperties)?.Value is not string value || + JsonSerializer.Deserialize(value) is not { ValueKind: JsonValueKind.Object } properties || + !properties.TryGetProperty(Shopify.Properties.ShopName, out JsonElement name)) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0412)); + } + + // Note: the shop domain extracted from the redirection request is not used by OpenIddict (that stores + // the shop name in the state token, but it can be resolved and used by the developers in their own code. + // + // To ensure the value is correct, it is compared to the shop name stored in the state token: if + // the two don't match, the request is automatically rejected to prevent a potential mixup attack. + if (!string.Equals(domain, $"{name}.myshopify.com", StringComparison.Ordinal)) + { + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2052("shop"), + uri: SR.FormatID8000(SR.ID2052)); + + return default; + } + } + + return default; + } + } + /// /// Contains the logic responsible for overriding the address /// of the token endpoint for the providers that require it. @@ -158,6 +343,19 @@ public static partial class OpenIddictClientWebIntegrationHandlers context.TokenEndpoint = context.Registration.ProviderType switch { + // Shopify is a multitenant provider that requires setting the token endpoint dynamically + // based on the shop name stored in the authentication properties set during the challenge. + // + // For more information, see + // https://shopify.dev/docs/apps/auth/oauth/getting-started#step-5-get-an-access-token. + ProviderTypes.Shopify when context.GrantType is GrantTypes.AuthorizationCode => + context.StateTokenPrincipal is ClaimsPrincipal principal && + principal.FindFirst(Claims.Private.HostProperties)?.Value is string value && + JsonSerializer.Deserialize(value) is { ValueKind: JsonValueKind.Object } properties && + properties.TryGetProperty(Shopify.Properties.ShopName, out JsonElement name) ? + new Uri($"https://{name}.myshopify.com/admin/oauth/access_token", UriKind.Absolute) : + throw new InvalidOperationException(SR.GetResourceString(SR.ID0412)), + // Trovo uses a different token endpoint for the refresh token grant. // // For more information, see @@ -666,6 +864,11 @@ public static partial class OpenIddictClientWebIntegrationHandlers var parameters = context.Registration.ProviderType switch { + // For Shopify, include all the parameters contained in the "associated_user" object. + // + // Note: the "associated_user" node is only available when using the online access mode. + ProviderTypes.Shopify => context.TokenResponse["associated_user"]?.GetNamedParameters(), + // For Strava, include all the parameters contained in the "athlete" object. // // Note: the "athlete" node is not returned for grant_type=refresh_token requests. @@ -748,6 +951,15 @@ public static partial class OpenIddictClientWebIntegrationHandlers context.AuthorizationEndpoint = context.Registration.ProviderType switch { + // Shopify is a multitenant provider that requires setting the authorization endpoint + // dynamically based on the shop name stored in the authentication properties. + // + // For more information, see + // https://shopify.dev/docs/apps/auth/oauth/getting-started#step-3-ask-for-permission. + ProviderTypes.Shopify => context.Properties.TryGetValue(Shopify.Properties.ShopName, out string? name) ? + new Uri($"https://{name}.myshopify.com/admin/oauth/authorize", UriKind.Absolute) : + throw new InvalidOperationException(SR.GetResourceString(SR.ID0412)), + // Stripe uses a different authorization endpoint for express accounts. // // The type of account can be defined globally (via the Stripe options) or @@ -755,7 +967,8 @@ public static partial class OpenIddictClientWebIntegrationHandlers // // For more information, see // https://stripe.com/docs/connect/oauth-reference?locale=en-us#get-authorize. - ProviderTypes.StripeConnect when context.Properties.TryGetValue(".stripe_account_type", out string? type) => + ProviderTypes.StripeConnect when context.Properties.TryGetValue( + StripeConnect.Properties.AccountType, out string? type) => string.Equals(type, "express", StringComparison.OrdinalIgnoreCase) ? new Uri("https://connect.stripe.com/express/oauth/authorize", UriKind.Absolute) : new Uri("https://connect.stripe.com/oauth/authorize", UriKind.Absolute), @@ -835,7 +1048,8 @@ 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): - ProviderTypes.Deezer or ProviderTypes.Strava => string.Join(",", context.Scopes), + ProviderTypes.Deezer or ProviderTypes.Shopify or ProviderTypes.Strava + => string.Join(",", context.Scopes), // The following providers are known to use plus-separated scopes instead of // the standard format (that requires using a space as the scope separator): @@ -964,6 +1178,16 @@ public static partial class OpenIddictClientWebIntegrationHandlers context.Request["duration"] = settings.Duration; } + // Shopify allows setting an optional access mode to enable per-user authorization. + else if (context.Registration.ProviderType is ProviderTypes.Shopify) + { + var settings = context.Registration.GetShopifySettings(); + if (string.Equals(settings.AccessMode, "online", StringComparison.OrdinalIgnoreCase)) + { + context.Request["grant_options[]"] = "per-user"; + } + } + // Slack allows sending an optional "team" parameter to simplify the login process. else if (context.Registration.ProviderType is ProviderTypes.Slack) { diff --git a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationProviders.xml b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationProviders.xml index 8d461030..5ba167ca 100644 --- a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationProviders.xml +++ b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationProviders.xml @@ -782,6 +782,43 @@ + + + + + + + + + + + + + + + + + +