-
Notifications
You must be signed in to change notification settings - Fork 551
Shopify provider #207
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Shopify provider #207
Changes from 1 commit
b07120f
c195be8
042e11c
0811f47
4adbb08
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
- Loading branch information
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,9 @@ | |
|
|
||
| namespace AspNet.Security.OAuth.Shopify | ||
| { | ||
| /// <summary> | ||
| /// Default values used by the Shopify authentication middleware. | ||
| /// </summary> | ||
| public static class ShopifyAuthenticationDefaults | ||
| { | ||
| /// <summary> | ||
|
|
@@ -44,11 +47,51 @@ public static class ShopifyAuthenticationDefaults | |
| /// <summary> | ||
| /// Default value for <see cref="OAuthOptions.UserInformationEndpoint"/>. | ||
| /// </summary> | ||
| public const string UserInformationEndpoint = "https://{0}.myshopify.com/admin"; | ||
| public const string UserInformationEndpoint = "https://{0}.myshopify.com/admin/shop"; | ||
|
|
||
|
|
||
| /// <summary> | ||
| /// | ||
| /// Name of dictionary entry in <see cref="AuthenticationProperties.Items"/> that contains | ||
| /// the name of the shop. | ||
| /// </summary> | ||
| public const string ShopNameAuthenticationProperty = "ShopName"; | ||
|
|
||
| /// <summary> | ||
| /// Set this authentication property to override the scope set in <see cref="OAuthOptions.Scope"/>. Note - if this | ||
| /// override is used, it must be fully formatted. | ||
| /// </summary> | ||
| public const string ShopScopeAuthenticationProperty = "Scope"; | ||
|
|
||
| /// <summary> | ||
| /// Additional grant options. The only acceptable value is "per-user" | ||
| /// </summary> | ||
| public const string GrantOptionsAuthenticationProperty = "GrantOptions"; | ||
|
|
||
| /// <summary> | ||
| /// Per user is the only acceptable grant option at this time. | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this still true? |
||
| /// </summary> | ||
| public const string PerUserAuthenticationPropertyValue = "per-user"; | ||
|
|
||
|
|
||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ditto. |
||
| /// <summary> | ||
| /// The claim type which contains the permission scope returned by Shopify during authorization. | ||
| /// This may not be the same scope requested, so apps should verify they have the scope they need. | ||
| /// </summary> | ||
| public const string ShopifyScopeClaimType = "urn:shopify:scope"; | ||
|
|
||
| /// <summary> | ||
| /// The plan name that this shop is using. | ||
| /// </summary> | ||
| public const string ShopifyPlanNameClaimType = "urn:shopify:plan_name"; | ||
|
|
||
| /// <summary> | ||
| /// Claim type indicating whether or not this shop if eligable to make payments. | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Typo: eligible |
||
| /// </summary> | ||
| public const string ShopifyEligableForPaymentsClaimType = "urn:shopify:eligible_for_payments"; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Typo: |
||
|
|
||
| /// <summary> | ||
| /// The timezone that that the shop is using. | ||
| /// </summary> | ||
| public const string ShopifyTimezoneClaimType = "urn:shopify:timezone"; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,10 +4,10 @@ | |
| * for more information concerning the license and the contributors participating to this project. | ||
| */ | ||
|
|
||
| using System; | ||
| using System; | ||
| using System.Collections.Generic; | ||
| using System.Linq; | ||
| using System.Net.Http; | ||
| using System.Linq; | ||
| using System.Net.Http; | ||
| using System.Net.Http.Headers; | ||
| using System.Security.Claims; | ||
| using System.Text.Encodings.Web; | ||
|
|
@@ -18,12 +18,16 @@ | |
| using Microsoft.AspNetCore.WebUtilities; | ||
| using Microsoft.Extensions.Logging; | ||
| using Microsoft.Extensions.Options; | ||
| using Newtonsoft.Json; | ||
| using Newtonsoft.Json.Linq; | ||
|
|
||
| namespace AspNet.Security.OAuth.Shopify | ||
| { | ||
| /// <inheritdoc /> | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A more specific comment is better as it's for Shopify, not any-old-OAuth. |
||
| [UsedImplicitly] | ||
| public class ShopifyAuthenticationHandler : OAuthHandler<ShopifyAuthenticationOptions> | ||
| { | ||
| /// <inheritdoc /> | ||
| public ShopifyAuthenticationHandler( | ||
| [NotNull] IOptionsMonitor<ShopifyAuthenticationOptions> options, | ||
| [NotNull] ILoggerFactory logger, | ||
|
|
@@ -33,12 +37,13 @@ public ShopifyAuthenticationHandler( | |
| { | ||
| } | ||
|
|
||
| /// <inheritdoc /> | ||
| protected override async Task<AuthenticationTicket> CreateTicketAsync( | ||
| [NotNull] ClaimsIdentity identity, | ||
| [NotNull] AuthenticationProperties properties, | ||
| [NotNull] OAuthTokenResponse tokens) | ||
| { | ||
| var uri = string.Format(ShopifyAuthenticationDefaults.UserInformationEndpoint + "/shop", | ||
| var uri = string.Format(ShopifyAuthenticationDefaults.UserInformationEndpoint, | ||
| properties.Items[ShopifyAuthenticationDefaults.ShopNameAuthenticationProperty]); | ||
|
|
||
| var request = new HttpRequestMessage(HttpMethod.Get, uri); | ||
|
|
@@ -59,24 +64,51 @@ protected override async Task<AuthenticationTicket> CreateTicketAsync( | |
|
|
||
| var payload = JObject.Parse(await response.Content.ReadAsStringAsync()); | ||
|
|
||
| #if DEBUG | ||
| // ReSharper disable once UnusedVariable | ||
| var jsonStr = payload.ToString(Formatting.Indented); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove this please. |
||
| #endif | ||
|
|
||
| // In Shopify, the customer can modify the scope given to the app. Apps should verify | ||
| // that the customer is allowing the required scope. | ||
| var actualScope = tokens.Response["scope"].ToString(); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does this need to access |
||
| identity.AddClaim(new Claim("urn:shopify:scope", actualScope)); | ||
| var isPersistent = true; | ||
|
|
||
| // If the request was for a "per-user" (i.e. no offline access) | ||
| if (tokens.Response.TryGetValue("expires_in", out var val)) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Better local name than |
||
| { | ||
| isPersistent = false; | ||
|
|
||
| var expires = DateTimeOffset.Now.AddSeconds(Convert.ToInt32(val.ToString())); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use |
||
| identity.AddClaim(new Claim(ClaimTypes.Expiration, expires.ToString("O"), "dateTime")); | ||
|
|
||
| actualScope = tokens.Response["associated_user_scope"].ToString(); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What is the type of these items being |
||
|
|
||
| var userData = tokens.Response["associated_user"].ToString(); | ||
| identity.AddClaim(new Claim(ClaimTypes.UserData, userData, "json")); | ||
| } | ||
|
|
||
| identity.AddClaim(new Claim(ClaimTypes.IsPersistent, isPersistent.ToString(), "boolean")); | ||
| identity.AddClaim(new Claim(ShopifyAuthenticationDefaults.ShopifyScopeClaimType, actualScope)); | ||
|
|
||
| var principal = new ClaimsPrincipal(identity); | ||
| var context = new OAuthCreatingTicketContext(principal, properties, Context, Scheme, Options, Backchannel, tokens, payload); | ||
|
|
||
| var context = new OAuthCreatingTicketContext(principal, properties, Context, Scheme, Options, Backchannel, tokens, payload); | ||
| context.RunClaimActions(payload); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This can just be |
||
|
|
||
| await Options.Events.CreatingTicket(context); | ||
| var ticket = new AuthenticationTicket(context.Principal, context.Properties, Scheme.Name); | ||
|
|
||
| return ticket; | ||
| } | ||
|
|
||
| /// <inheritdoc /> | ||
| protected override string FormatScope() | ||
| { | ||
| return Options.ShopifyScope; | ||
| return string.Join(",", Options.Scope); | ||
| } | ||
|
|
||
| /// <inheritdoc /> | ||
| protected override string BuildChallengeUrl(AuthenticationProperties properties, string redirectUri) | ||
| { | ||
| if (!properties.Items.ContainsKey(ShopifyAuthenticationDefaults.ShopNameAuthenticationProperty)) | ||
|
|
@@ -91,32 +123,68 @@ protected override string BuildChallengeUrl(AuthenticationProperties properties, | |
| var shopName = properties.Items[ShopifyAuthenticationDefaults.ShopNameAuthenticationProperty]; | ||
| var uri = string.Format(Options.AuthorizationEndpoint, shopName); | ||
|
|
||
| // Get the permission scope, which can either be set in options or overridden in AuthenticationProperties. | ||
| var scope = properties.Items.ContainsKey(ShopifyAuthenticationDefaults.ShopScopeAuthenticationProperty) ? | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| properties.Items[ShopifyAuthenticationDefaults.ShopScopeAuthenticationProperty] : | ||
| FormatScope(); | ||
|
|
||
| var url = QueryHelpers.AddQueryString(uri, new Dictionary<string, string> | ||
| { | ||
| ["client_id"] = Options.ApiKey ?? Options.ClientId, | ||
| ["scope"] = FormatScope(), | ||
| ["client_id"] = Options.ClientId, | ||
| ["scope"] = scope, | ||
| ["redirect_uri"] = redirectUri, | ||
| ["state"] = Options.StateDataFormat.Protect(properties), | ||
| ["state"] = Options.StateDataFormat.Protect(properties) | ||
| }); | ||
|
|
||
| // If we're requesting a per-user, online only, token, add the grant_options query param. | ||
| if (properties.Items.ContainsKey(ShopifyAuthenticationDefaults.GrantOptionsAuthenticationProperty)) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| { | ||
| var grantOptions = properties.Items[ShopifyAuthenticationDefaults.GrantOptionsAuthenticationProperty]; | ||
| if (grantOptions != null && | ||
| grantOptions.Equals(ShopifyAuthenticationDefaults.PerUserAuthenticationPropertyValue)) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Assuming this is a |
||
| { | ||
| url = QueryHelpers.AddQueryString(url, "grant_options[]", | ||
| ShopifyAuthenticationDefaults.PerUserAuthenticationPropertyValue); | ||
| } | ||
| } | ||
|
|
||
| return url; | ||
| } | ||
|
|
||
|
|
||
| /// <inheritdoc /> | ||
| protected override async Task<OAuthTokenResponse> ExchangeCodeAsync( | ||
| [NotNull] string code, | ||
| [NotNull] string redirectUri) | ||
| { | ||
| var shop = Context.Request.Query["shop"]; | ||
| var hmac = Context.Request.Query["hmac"]; | ||
| var state = Context.Request.Query["state"]; | ||
|
|
||
| var shopDns = shop.ToString().Split('.').First(); | ||
| var z = Options.StateDataFormat.Unprotect(state); | ||
| if (!z.Items[ShopifyAuthenticationDefaults.ShopNameAuthenticationProperty] | ||
| .Equals(shopDns, StringComparison.InvariantCultureIgnoreCase)) | ||
| string shopDns; | ||
|
|
||
| try | ||
| { | ||
| var shop = Context.Request.Query["shop"]; | ||
| var state = Context.Request.Query["state"]; | ||
|
|
||
| // Shop name must end with myshopify.com | ||
| if (!shop.ToString().EndsWith(".myshopify.com")) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Specify whether this is case sensitive or not explicitly via |
||
| { | ||
| throw new Exception("Unexpected query string."); | ||
| } | ||
|
|
||
| // Strip out the "myshopify.com" suffix | ||
| shopDns = shop.ToString().Split('.').First(); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the second time
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| // Verify that the shop name encoded in "state" matches the shop name we used to | ||
| // request the token. This probably isn't necessary, but it's an easy extra verification. | ||
| var z = Options.StateDataFormat.Unprotect(state); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Better name than |
||
| if (!z.Items[ShopifyAuthenticationDefaults.ShopNameAuthenticationProperty] | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use the static |
||
| .Equals(shopDns, StringComparison.InvariantCultureIgnoreCase)) | ||
| { | ||
| throw new Exception("Unexpected query string"); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there a better exception type you can use?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Tell the integrating developer why it's "unexpected"? Maybe explicitly state that the shop is mismatched? |
||
| } | ||
| } | ||
| catch (Exception e) | ||
| { | ||
|
|
||
| Logger.LogError("An error occurred while exchanging tokens: " + e.Message); | ||
| return OAuthTokenResponse.Failed(e); | ||
| } | ||
|
|
||
| var uri = string.Format(Options.TokenEndpoint, shopDns); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
@@ -125,9 +193,9 @@ protected override async Task<OAuthTokenResponse> ExchangeCodeAsync( | |
|
|
||
| request.Content = new FormUrlEncodedContent(new Dictionary<string, string> | ||
| { | ||
| ["client_id"] = Options.ApiKey ?? Options.ClientId, | ||
| ["client_secret"] = Options.ApiSecretKey ?? Options.ClientSecret, | ||
| ["code"] = code, | ||
| ["client_id"] = Options.ClientId, | ||
| ["client_secret"] = Options.ClientSecret, | ||
| ["code"] = code | ||
| }); | ||
|
|
||
| var response = await Backchannel.SendAsync(request, Context.RequestAborted); | ||
|
|
@@ -144,7 +212,6 @@ protected override async Task<OAuthTokenResponse> ExchangeCodeAsync( | |
| } | ||
|
|
||
| var payload = JObject.Parse(await response.Content.ReadAsStringAsync()); | ||
|
|
||
| return OAuthTokenResponse.Success(payload); | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,10 +14,8 @@ namespace AspNet.Security.OAuth.Shopify | |
| /// <inheritdoc /> | ||
| public class ShopifyAuthenticationOptions : OAuthOptions | ||
| { | ||
| public string ShopifyScope { get; set; } = "read_customers"; | ||
|
|
||
| /// <summary> | ||
| /// An alias for ClientId | ||
| /// An alias for <see cref="OAuthOptions.ClientId"/> | ||
| /// </summary> | ||
| public string ApiKey | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we really need these aliases? |
||
| { | ||
|
|
@@ -26,7 +24,7 @@ public string ApiKey | |
| } | ||
|
|
||
| /// <summary> | ||
| /// An alias for ClientSecret | ||
| /// An alias for <see cref="OAuthOptions.ClientSecret"/> | ||
| /// </summary> | ||
| public string ApiSecretKey | ||
| { | ||
|
|
@@ -45,13 +43,18 @@ public ShopifyAuthenticationOptions() | |
| AuthorizationEndpoint = ShopifyAuthenticationDefaults.AuthorizationEndpoint; | ||
| TokenEndpoint = ShopifyAuthenticationDefaults.TokenEndpoint; | ||
| UserInformationEndpoint = ShopifyAuthenticationDefaults.UserInformationEndpoint; | ||
|
|
||
| Scope.Add(ShopifyScope); | ||
|
|
||
| // ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "id"); | ||
| // ClaimActions.MapJsonKey(ClaimTypes.Name, "full_name"); | ||
| ClaimActions.MapJsonSubKey(ClaimTypes.Dns, "shop", "myshopify_domain"); | ||
| ClaimActions.MapJsonSubKey(ClaimTypes.NameIdentifier, "shop", "id", "long"); | ||
|
|
||
| ClaimActions.MapJsonSubKey(ClaimTypes.NameIdentifier, "shop", "myshopify_domain"); | ||
| ClaimActions.MapJsonSubKey(ClaimTypes.Name, "shop", "name"); | ||
| ClaimActions.MapJsonSubKey(ClaimTypes.Webpage, "shop", "domain"); | ||
| ClaimActions.MapJsonSubKey(ClaimTypes.Email, "shop", "email"); | ||
| ClaimActions.MapJsonSubKey(ClaimTypes.Country, "shop", "country_code"); | ||
| ClaimActions.MapJsonSubKey(ClaimTypes.StateOrProvince, "shop", "province_code"); | ||
| ClaimActions.MapJsonSubKey(ClaimTypes.PostalCode, "shop", "zip"); | ||
| ClaimActions.MapJsonSubKey(ClaimTypes.Locality, "shop", "primary_locale"); | ||
| ClaimActions.MapJsonSubKey(ShopifyAuthenticationDefaults.ShopifyPlanNameClaimType, "shop", "plan_name"); | ||
| ClaimActions.MapJsonSubKey(ShopifyAuthenticationDefaults.ShopifyEligableForPaymentsClaimType, "shop", "eligible_for_payments", "boolean"); | ||
| ClaimActions.MapJsonSubKey(ShopifyAuthenticationDefaults.ShopifyTimezoneClaimType, "shop", "timezone"); | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: unnecessary new line.