Browse Source

Add Shopify to the list of supported providers and update the generator to generate constants for the authentication properties

pull/1812/head
Kévin Chalet 3 years ago
parent
commit
a37e6c65a1
  1. 3
      Directory.Build.targets
  2. 14
      gen/OpenIddict.Client.WebIntegration.Generators/OpenIddictClientWebIntegrationGenerator.cs
  3. 66
      shared/OpenIddict.Extensions/Helpers/OpenIddictHelpers.cs
  4. 6
      src/OpenIddict.Abstractions/OpenIddictResources.resx
  5. 9
      src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Userinfo.cs
  6. 228
      src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.cs
  7. 39
      src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationProviders.xml
  8. 32
      src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationProviders.xsd
  9. 2
      src/OpenIddict.Client/OpenIddictClientHandlers.cs

3
Directory.Build.targets

@ -85,13 +85,14 @@
<PropertyGroup
Condition=" ('$(TargetFrameworkIdentifier)' == '.NETCoreApp' And $([MSBuild]::VersionGreaterThanOrEquals($(TargetFrameworkVersion), '5.0'))) ">
<DefineConstants>$(DefineConstants);SUPPORTS_ENVIRONMENT_PROCESS_PATH</DefineConstants>
<DefineConstants>$(DefineConstants);SUPPORTS_HEXADECIMAL_STRING_CONVERSION</DefineConstants>
<DefineConstants>$(DefineConstants);SUPPORTS_HTTP_CLIENT_DEFAULT_REQUEST_VERSION_POLICY</DefineConstants>
<DefineConstants>$(DefineConstants);SUPPORTS_MULTIPLE_VALUES_IN_QUERYHELPERS</DefineConstants>
<DefineConstants>$(DefineConstants);SUPPORTS_NAMED_PIPE_STATIC_FACTORY_WITH_ACL</DefineConstants>
<DefineConstants>$(DefineConstants);SUPPORTS_ONE_SHOT_HASHING_METHODS</DefineConstants>
<DefineConstants>$(DefineConstants);SUPPORTS_OPERATING_SYSTEM_VERSIONS_COMPARISON</DefineConstants>
<DefineConstants>$(DefineConstants);SUPPORTS_PEM_ENCODED_KEY_IMPORT</DefineConstants>
<DefineConstants>$(DefineConstants);SUPPORTS_WINFORMS_TASK_DIALOG</DefineConstants>
<DefineConstants>$(DefineConstants);SUPPORTS_NAMED_PIPE_STATIC_FACTORY_WITH_ACL</DefineConstants>
</PropertyGroup>
<PropertyGroup

14
gen/OpenIddict.Client.WebIntegration.Generators/OpenIddictClientWebIntegrationGenerator.cs

@ -787,6 +787,13 @@ public static partial class OpenIddictClientWebIntegrationConstants
public const string {{ environment.name }} = ""{{ environment.name }}"";
{{~ end ~}}
}
public static class Properties
{
{{~ for property in provider.properties ~}}
public const string {{ property.name }} = ""{{ property.dictionary_key }}"";
{{~ end ~}}
}
}
{{~ end ~}}
@ -818,6 +825,13 @@ public static partial class OpenIddictClientWebIntegrationConstants
Name = (string?) environment.Attribute("Name") ?? "Production"
})
.ToList(),
Properties = provider.Elements("Property").Select(property => new
{
Name = (string) property.Attribute("Name"),
DictionaryKey = (string) property.Attribute("DictionaryKey")
})
.ToList(),
})
.ToList()
});

66
shared/OpenIddict.Extensions/Helpers/OpenIddictHelpers.cs

@ -548,6 +548,46 @@ internal static class OpenIddictHelpers
return algorithm;
}
/// <summary>
/// Computes the SHA-256 message authentication code (HMAC) of the specified <paramref name="data"/> array.
/// </summary>
/// <param name="key">The cryptographic key.</param>
/// <param name="data">The data to hash.</param>
/// <returns>The SHA-256 message authentication code (HMAC) of the specified <paramref name="data"/> array.</returns>
/// <exception cref="CryptographicException">
/// The implementation resolved from <see cref="CryptoConfig.CreateFromName(string)"/> is not valid.
/// </exception>
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();
}
}
/// <summary>
/// Computes the SHA-256 hash of the specified <paramref name="data"/> array.
/// </summary>
@ -842,6 +882,32 @@ internal static class OpenIddictHelpers
#endif
}
/// <summary>
/// Converts the specified hex-encoded <paramref name="value"/> to a byte array.
/// </summary>
/// <param name="value">The hexadecimal string.</param>
/// <returns>The byte array.</returns>
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
/// <summary>
/// Creates a derived key based on the specified <paramref name="secret"/> using PBKDF2.

6
src/OpenIddict.Abstractions/OpenIddictResources.resx

@ -1546,6 +1546,12 @@ To apply post-logout redirection responses, create a class implementing 'IOpenId
<data name="ID0411" xml:space="preserve">
<value>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.</value>
</data>
<data name="ID0412" xml:space="preserve">
<value>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.</value>
</data>
<data name="ID0413" xml:space="preserve">
<value>The specified string is not a valid hexadecimal string.</value>
</data>
<data name="ID2000" xml:space="preserve">
<value>The security token is missing.</value>
</data>

9
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)
{

228
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);
/// <summary>
/// Contains the logic responsible for validating the signature or message authentication
/// code attached to the redirection request for the providers that require it.
/// </summary>
public sealed class ValidateRedirectionRequestSignature : IOpenIddictClientHandler<ProcessAuthenticationContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessAuthenticationContext>()
.AddFilter<RequireRedirectionRequest>()
.UseSingletonHandler<ValidateRedirectionRequestSignature>()
.SetOrder(ValidateIssuerParameter.Descriptor.Order + 500)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
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;
}
}
/// <summary>
/// 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
}
}
/// <summary>
/// Contains the logic responsible for validating custom parameters for the providers that require it.
/// </summary>
public sealed class ValidateNonStandardParameters : IOpenIddictClientHandler<ProcessAuthenticationContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessAuthenticationContext>()
.AddFilter<RequireRedirectionRequest>()
.AddFilter<RequireStateTokenPrincipal>()
.AddFilter<RequireStateTokenValidated>()
.UseSingletonHandler<ValidateNonStandardParameters>()
.SetOrder(ResolveGrantTypeAndResponseTypeFromStateToken.Descriptor.Order + 500)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
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<JsonElement>(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;
}
}
/// <summary>
/// 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<JsonElement>(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)
{

39
src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationProviders.xml

@ -782,6 +782,43 @@
</Environment>
</Provider>
<!--
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
██ ▄▄▄ ██ ██ ██ ▄▄▄ ██ ▄▄ █▄ ▄██ ▄▄▄██ ███ ██
██▄▄▄▀▀██ ▄▄ ██ ███ ██ ▀▀ ██ ███ ▄▄███▄▀▀▀▄██
██ ▀▀▀ ██ ██ ██ ▀▀▀ ██ ████▀ ▀██ ███████ ████
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
-->
<Provider Name="Shopify" Id="b4ad4afd-1893-46ef-9b8e-f4a14998bbd1" Documentation="https://shopify.dev/docs/apps/auth/oauth">
<Environment Issuer="https://myshopify.com/">
<!--
Note: Shopify is a special multitenant provider for which the location of the authorization and
token endpoints must be determined dynamically based on the shop name specified by the user or
received by an application-defined endpoint (known as "installation link") that is triggered from
Shopify's website when starting the installation process. To achieve that, an empty configuration
is used here and dedicated event handlers are responsible for setting the endpoints dynamically.
For more information about this process, visit
https://shopify.dev/docs/apps/auth/oauth/getting-started#step-2-verify-the-installation-request.
-->
<Configuration />
<!--
Note: at least one scope must be specified for the authorization request to be accepted.
For that, the "read_products" (that doesn't require a specific permission) is added by default.
-->
<Scope Name="read_products" Default="true" Required="false" />
</Environment>
<Property Name="ShopName" DictionaryKey=".shopify_shop_name" />
<Setting PropertyName="AccessMode" ParameterName="mode" Type="String" Required="false"
Description="The access mode (can be set to 'online' for per-user authorization)" />
</Provider>
<!--
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
██ ▄▄▄ ██ ████ ▄▄▀██ ▄▄▀██ █▀▄██
@ -919,6 +956,8 @@
<Scope Name="read_write" Default="true" Required="false" />
</Environment>
<Property Name="AccountType" DictionaryKey=".stripe_account_type" />
<Setting PropertyName="AccountType" ParameterName="type" Type="String" Required="true" DefaultValue="standard"
Description="The type of the Stripe account (by default, 'standard', but can also be set to 'express')" />
</Provider>

32
src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationProviders.xsd

@ -269,6 +269,38 @@
</xs:complexType>
</xs:element>
<xs:element name="Property" minOccurs="0" maxOccurs="10">
<xs:annotation>
<xs:documentation>A custom, user-set authentication property supported by the provider integration.</xs:documentation>
</xs:annotation>
<xs:complexType>
<xs:attribute name="Name" use="required">
<xs:annotation>
<xs:documentation>The name of the constant used to represent the property.</xs:documentation>
</xs:annotation>
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:pattern value="^[A-Z][a-zA-Z0-9]*$" />
</xs:restriction>
</xs:simpleType>
</xs:attribute>
<xs:attribute name="DictionaryKey" use="required">
<xs:annotation>
<xs:documentation>The key associated with the property, used as the dictionary lookup value.</xs:documentation>
</xs:annotation>
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:pattern value="^\.[a-z_]*$" />
</xs:restriction>
</xs:simpleType>
</xs:attribute>
</xs:complexType>
</xs:element>
<xs:element name="Setting" minOccurs="0" maxOccurs="10">
<xs:annotation>
<xs:documentation>A custom setting exposed by the provider integration.</xs:documentation>

2
src/OpenIddict.Client/OpenIddictClientHandlers.cs

@ -1086,7 +1086,7 @@ public static partial class OpenIddictClientHandlers
{
context.Reject(
error: Errors.InvalidRequest,
description: SR.GetResourceString(SR.ID2029),
description: SR.FormatID2029(Parameters.Iss),
uri: SR.FormatID8000(SR.ID2029));
return default;

Loading…
Cancel
Save