Skip to content

Instantly share code, notes, and snippets.

@jduff
Last active January 6, 2024 19:28
Show Gist options
  • Save jduff/bedc2eee90fc168a8707838325b04122 to your computer and use it in GitHub Desktop.
Save jduff/bedc2eee90fc168a8707838325b04122 to your computer and use it in GitHub Desktop.
Basic Shopify OAuth implementation in Ruby
class Shopify::AuthsController < ApplicationController
NONCE_LENGTH = 15
AUTH_COOKIE_NAME = "_shopify_auth_session"
before_action :verify_request_hmac, only: :callback
before_action :verify_request_state, only: :callback
def new
# TODO: Check if we have a token already or if scopes have changed
if params[:embedded]
headers.except!("X-Frame-Options")
# redirect out of iframe when embedded
full_page_redirect_to(new_shopify_auth_url(shop: params[:shop]))
else
# TODO: validate params[:shop]
# there are some different formats to take into account https://github.com/Shopify/shopify_app/blob/main/lib/shopify_app/utils.rb#L6
# might need to look at referrer for embedded https://github.com/search?q=repo%3AShopify%2Fshopify_app%20referer_sanitized_shop_name&type=code
state = SecureRandom.alphanumeric(NONCE_LENGTH)
cookies.encrypted[AUTH_COOKIE_NAME] = {
expires: Time.now + 60,
secure: true,
http_only: true,
value: state,
}
query = {
client_id: ENV['SHOPIFY_APP_CLIENT_ID'],
scope: "write_orders",
redirect_uri: callback_shopify_auths_url(host: ENV['APP_HOSTNAME'], protocol: "https"),
"grant_options[]" => "",
state: state,
}
# TODO: use ActionDispatch::Http::URL.url_for to build urls, script_name: for path
redirect_to("https://#{params[:shop]}/admin/oauth/authorize?#{query.to_query}", allow_other_host: true)
end
end
def callback
query = {
client_id: ENV['SHOPIFY_APP_CLIENT_ID'],
client_secret: ENV['SHOPIFY_APP_CLIENT_SECRET'],
code: params[:code],
}
response = HTTParty.post("https://#{params[:shop]}/admin/oauth/access_token", query: query)
# TODO: store response["access_token"] and response["scope"]
# TODO: check if this app should be embedded or not
if params[:embedded] != "1"
decoded_host = Base64.decode64(params[:host])
redirect_to("https://#{decoded_host}/apps/#{ENV['SHOPIFY_APP_CLIENT_ID']}", allow_other_host: true)
else
redirect_to(root_path(params: { shop: session.shop, host: params[:host] }))
end
end
private
def full_page_redirect_to(url)
render("shopify/shared/redirect", layout: false, locals: { url: url })
end
def verify_request_state
state = cookies.encrypted[AUTH_COOKIE_NAME]
cookies.delete(AUTH_COOKIE_NAME)
raise "Invalid Request" unless params[:state] == state
end
def verify_request_hmac
request_params = params.except(:controller, :action, :hmac).permit!
raise "Invalid Request" unless valid_request?(request_params, params[:hmac], ENV['SHOPIFY_APP_CLIENT_SECRET'])
end
def valid_request?(params, hmac, secret)
digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), secret, params.to_param)
ActiveSupport::SecurityUtils.secure_compare(digest, hmac)
end
end
<!DOCTYPE html>
<html>
<head>
<title>Redirecting...</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= tag("meta", name: "shopify-api-key", content: ENV['SHOPIFY_APP_CLIENT_ID']) %>
<%= tag("meta", name: "shopify-host", content: params[:host]) %>
<%= javascript_importmap_tags %>
</head>
<body>
<%= content_tag :div, nil, data: { controller: "shopify--redirect", shopify__redirect_action_value: "REMOTE", shopify__redirect_immediate_value: true }, href: url %>
</body>
</html>
import { Controller } from "@hotwired/stimulus"
import { Redirect } from "@shopify/app-bridge/actions";
// Connects to data-controller="shopify--redirect"
export default class RedirectController extends Controller {
static Action = {
ADMIN_PATH: Redirect.Action.ADMIN_PATH,
ADMIN_SECTION: Redirect.Action.ADMIN_SECTION,
APP: Redirect.Action.APP,
REMOTE: Redirect.Action.REMOTE
};
static values = {
action: "ADMIN_PATH",
immediate: false,
}
connect() {
console.assert(this.actionValue, Object.keys(RedirectController.Action));
this.shopifyApiKey = document.head.querySelector(`meta[name="shopify-api-key"]`)?.getAttribute('content');
this.shopifyHost = document.head.querySelector(`meta[name="shopify-host"]`)?.getAttribute('content');
this.shopifyApp = createApp({
apiKey: this.shopifyApiKey,
host: this.shopifyHost,
forceRedirect: true,
});
this.redirect = Redirect.create(this.shopifyApp)
if(this.immediateValue) {
this.dispatch();
}
}
dispatch(event) {
event?.preventDefault();
this.redirect.dispatch(this.action, {
url: this.url,
newContext: this.newContext,
});
}
get action() {
return RedirectController.Action[this.actionValue] ?? Redirect.Action.ADMIN_PATH;
}
get url() {
return this.element.getAttribute('href');
}
get newContext() {
return this.element.getAttribute('target') === '_blank';
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment