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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+