Recently we built an embedded Shopify application for one of our clients. The UI was relatively straightforward, and in an effort to keep the code simple we elected to create a server-side rendered (SSR) Rails app and use Hotwire (Turbo and Stimulus, specifically) as needed for the front end.
Part of building a Shopify application means going through a rigorous review and approval process. The app is submitted to Shopify and the Shopify team goes through it with a fine-tooth comb in an attempt to validate functionality and find any bugs that would be frustrating for merchants - free beta testing! One of the requirements for newly submitted Shopify applications is that they use session token authentication with AppBridge2.0. Fortunately, we were using the shopify_app
gem, a popular Rails engine for building Shopify apps that is maintained by Shopify. This library does a lot of heavy lifting, specifically around boilerplate authentication code. We thought that it would Just WorkTM but we were wrong.đą
This article serves to be a comprehensive guide to navigating Shopifyâs session token authentication for embedded, server-side rendered Rails apps. We hope to expose some common pitfalls and provide clear and concrete examples to guide you through the process. Letâs hit the road, shall we?
Missing Our Exit
We submitted our application to Shopify and it was returned with a few small items and one rather surprising one: âYou havenât implemented OAuth correctlyâ. We've implemented many OAuth integrations and many Shopify integrations on past projects, so what was the issue here? We had run into some minor issues in the past that seemed authentication-related, namely:
- Authenticated content flashing on the screen before redirection to a login screen when acting as an unauthenticated merchant
- Errors around a missing or incorrectly formatted
host
parameter - An error stating that there was no app at a given location during an uninstall/reinstall cycle
We had resolved these issues and were under the impression that they were just one-off bugs. We trusted that the shopify_app
gem had correctly implemented session token authentication and besides, there were no errors in the console or server logs, and we were able to successfully authenticate and install the app on our development stores without issue. The reviewer was kind enough to provide a screen recording of the OAuth-related error and after digging through our logs, it became clear that we were seeing widespread 5xx errors for Invalid or missing Cross-Site Request Forgery (CSRF) token.
UhhâŚwhat? None of the developers had experienced this issue locally or during QA cycles.
We attempted to replicate this using different combinations of stores and app environments to no avail. Finally, a teammate remembered reading that Shopify testing is performed in an incognito browser window with cookies disabled. When we mirrored that setup, BOOM, there they were - the Invalid or missing CSRF token errors. This does make sense though because the Rails CSRF protection strategy uses cookies, which were now disabled. After some digging we discovered this bit in the Shopify documentation:
Without third-party cookies, setting Cross-Site Request Forgery (CSRF) tokens in a cookie might not be possible. The session token serves as an alternative to CSRF tokens, because you can trust that the session token has been issued by Shopify to your app frontend.
This makes sense from a modern Shopify perspective as well. Session Token authentication and the AppBridge library were implemented and are now enforced as a result of browsersâ increasing restrictions on the use of cookies in iframes.
But ⌠we were using AppBridge. So why wasnât the session token being used as an alternative as described in the documentation? After hours of reading through Shopify Community Forum Discussion posts and poring over an overwhelming expanse of documentation at Shopify.dev, we uncovered this section and more specifically, this link:
Authenticate a server-side rendered embedded app using Rails and Turbolinks.
nestled in the introductory session token documentation. If there was ever a time to use the RTFM expression, it was now. We couldnât help but wonder why this wasnât made glaringly obvious, though. This wasnât the first run-in weâd had with inadequate Shopify documentation. Were we the first people to write a server-side rendered embedded Shopify app with a Rails backend? Surely this wasnât a novel approach?
Taking a Detour
We followed the sample app and instructions in the documentation as closely as possible. There were a few obvious differences we knew weâd need to contend with, like replacing Turbolinks events with their modern Turbo counterparts and removing the rogue jQuery references throughout the front-end code. Despite following the tutorial to a T, now, we were unable to authenticate at all. Instead, we saw a confusing combination of white screens and âmissing host parameterâ errors in the developer console, despite a host
being set in our ApplicationController
. đ¤Ś
Finding an Alternate Route
Feeling a bit discouraged, we did what any good engineering team would do and hit the web looking for an example of someone else who had implemented this using a modern Rails approach. Enter ⨠shopify-hotwire-sample
â¨. This repository was synonymous with the sample app provided as a supplement to the Turbolinks-specific tutorial but used Turbo instead. The code actually ended up being a bit more comprehensive than what we needed but it served as an extremely helpful guide in getting session token auth working properly. Given how complex this process was, I want to take you through the process step-by-step to illustrate how we implemented the feature using a combination of the tutorials and sample apps mentioned earlier in the article.
Create a Splash Page
The splash page is intended to be an unauthenticated page that is rendered when a user visits the app and should indicate that the app is loading. From a technical perspective, the splash page indicates that your app has begun to fetch a session token. After your app receives the token, the user should be directed to the main view (the home page), which may contain authenticated resources.
Letâs add a controller with an index
action that sets a @shop_origin
instance variable to the current_shopify_domain
helper. The helper and the included modules come out of the box with the shopify_app
gem.
# In app/controllers/splash_page_controller.rb
class SplashPageController < ApplicationController
include ShopifyApp::EmbeddedApp
include ShopifyApp::RequireKnownShop
include ShopifyApp::ShopAccessScopesVerification
def index
@shop_origin = current_shopify_domain
end
end
Tests are good too.
# In spec/request/splash_page_request_spec.rb
require "rails_helper"
RSpec.describe "SplashPages", type: :request do
describe "GET /" do
it "returns http success" do
get "/"
expect(response).to have_http_status(:found)
end
end
end
Make sure the root URL for the app is set to the splash page, and create a new endpoint for the home page. Remember to update any feature specs!
# In config/routes.rb
root to: "splash_page#index"
get "/home", to: "home#index", as: :home
Create the view for the splash page, and add an indicator to illustrate that the app is loading.
<%# In app/views/splash_page/index.html.erb %>
<div class="splash-page__loading">
<div class="bounce1"></div>
<div class="bounce2"></div>
<div class="bounce3"></div>
</div>
Style the indicator, because weâre fancy (the styles were yoinked from the shopify-hotwire-sample
repo).
/* In app/assets/stylesheets/loading.scss */
.splash-page__loading {
align-items: center;
display: flex;
height: 100vh;
justify-content: center;
}
.splash-page__loading > div {
-webkit-animation: sk-bouncedelay 1.4s infinite ease-in-out both;
animation: sk-bouncedelay 1.4s infinite ease-in-out both;
background-color: #c4cdd5;
border-radius: 100%;
display: inline-block;
height: 18px;
width: 18px;
}
.splash-page__loading .bounce1 {
-webkit-animation-delay: -0.32s;
animation-delay: -0.32s;
}
.splash-page__loading .bounce2 {
-webkit-animation-delay: -0.16s;
animation-delay: -0.16s;
}
@-webkit-keyframes sk-bouncedelay {
0%,
80%,
100% {
-webkit-transform: scale(0);
}
40% {
-webkit-transform: scale(1);
}
}
@keyframes sk-bouncedelay {
0%,
80%,
100% {
-webkit-transform: scale(0);
transform: scale(0);
}
40% {
-webkit-transform: scale(1);
transform: scale(1);
}
}
Protect the Controllers
We no longer want our controllers to inherit from ApplicationController
because we should not be able to access them unless we are authenticated. We can handle this by making the controllers that require authentication inherit from the AuthenticatedController
(which comes from shopify_app
as boilerplate).
For reference, our ApplicationController
looks like this and does a number of things, namely setting up some instance variables for @shop
, @shop_origin
, and @host
that will be globally available in our controllers and views, as well as checking the installation of a shop and setting up a content security policy for iframe protection.
# In app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
before_action :set_shopify_variables
before_action :set_shop
before_action :check_installation
after_action :set_csp_header
private
def set_shop
@shop = Shop.find_by(shopify_domain: @shop_origin)
end
def set_shopify_variables
@shop_origin = current_shopify_domain
@host = host
end
def shopify_params
params.permit(:hmac, :host, :session, :shop, :locale, :authenticity_token)
end
def host
params[:host]
end
def check_installation
return if Rails.env.test? || @shop.nil?
ShopifyApiSession.create(shop: @shop)
begin
ShopifyAPI::Shop.current
rescue ActiveResource::UnauthorizedAccess
redirect_to "#{ENV['HOST']}/login?shop=#{@shop.shopify_domain}"
end
end
def set_csp_header
response.set_header(
"Content-Security-Policy",
"frame-ancestors https://admin.shopify.com https://#{current_shopify_domain};",
)
end
end
Skip Forgery Protection for Controllers That Make External API Calls
Remember that CSRF issue I mentioned earlier? Well, in this app we are making one type of API call that doesnât need to be protected as a part of Shopify and can live outside of Shopifyâs authentication system. This may not apply to you, but it is required in our application.
# In app/controllers/api_controller.rb
class ApiController < ActionController::Base
skip_forgery_protection
def create
# API stuff
end
end
Add Middleware to Properly Set and Encode the host
One neat thing about this feature is that we get to write and test custom middleware! In the event that we are missing the host key in params during an authentication cycle, this piece of middleware will encode the host
in the correct format and set the value of host
in the request params.
# In app/middleware/app_bridge_middleware.rb
class AppBridgeMiddleware
def initialize(app)
@app = app
end
def call(env)
request = Rack::Request.new(env)
if request.params.has_key?("shop") && !request.params.has_key?("host")
shop = request.params["shop"]
host = Base64.urlsafe_encode64("#{shop}/admin", padding: false)
request.update_param("host", host)
end
@app.call(env)
end
end
We need to ensure that the middleware is available in our application.
# In config/application.rb
require_relative "../app/middleware/app_bridge_middleware"
# ...
module YourApp
class Application < Rails::Application
# ...
config.middleware.use AppBridgeMiddleware
end
end
And we need to test it, of course.
# In spec/middleware/app_bridge_middleware_spec.rb
require "rails_helper"
RSpec.describe AppBridgeMiddleware, type: :request do
it "adds missing host params" do
shop_domain = "foo.myshopify.com"
base64_encoded_host = Base64.urlsafe_encode64("foo.myshopify.com/admin", padding: false)
get "/?shop=#{shop_domain}"
expect(request.params["host"])
.to eq(base64_encoded_host)
end
end
Update the Embedded App Layout
Before we start writing JavaScript, we need to update the app/views/layouts/embedded_app.html.erb
file to pass along a load_path
data attribute that will be used by Turbo to navigate back to this app when a session token has been retrieved. In our example, weâre navigating to the home_path
.
<%# In app/views/layouts/embedded_app.html.erb %>
<%# ⌠%>
<%= content_tag(:div, nil, id: 'shopify-app-init', data: {
api_key: ShopifyApp.configuration.api_key,
shop_origin: @shop_origin || (@current_shopify_session.domain if @current_shopify_session),
load_path: params[:return_to] || home_path, # Add this line
host: @host,
debug: Rails.env.development?,
})%>
Fetch and Store Your Session Tokens with JavaScript
Now, we can write some JavaScript. In essence, what weâre doing is as follows:
- Creating a Shopify AppBridge instance
- Retrieving a session token and caching it
- Installing event listeners on Turbo events to add an Authorization request header
- Using Turbo to navigate to the
HomeController
First letâs grab the required packages. Weâre using yarn.
$ yarn add @shopify/app-bridge-utils
Now we can write some JavaScript to perform the steps outlined above. Note that this JavaScript was taken largely in part from the two sample repos linked above. Some of it was boilerplate from shopify_app
, and some of it is refactored by us.
// In app/javascript/shopify_app/shopify_app.js
import { getSessionToken } from '@shopify/app-bridge-utils'
const SESSION_TOKEN_REFRESH_INTERVAL = 2000 // Request a new token every 2s to ensure your tokens are always valid
document.addEventListener('turbo:before-fetch-request', function (event) {
event.detail.fetchOptions.headers['Authorization'] = `Bearer ${window.sessionToken}`
})
document.addEventListener('turbo:render', function () {
document.querySelectorAll('form, a[data-method=delete]').forEach(element => {
element.addEventListener('ajax:beforeSend', event => {
event.detail.fetchOptions.headers['Authorization'] = `Bearer ${window.sessionToken}`
})
})
})
document.addEventListener('DOMContentLoaded', async () => {
const data = document.getElementById('shopify-app-init').dataset
const AppBridge = window['app-bridge']
const createApp = AppBridge.default
window.app = createApp({
apiKey: data.apiKey,
host: data.host,
forceDirect: true,
})
const actions = AppBridge.actions
const TitleBar = actions.TitleBar
TitleBar.create(app, {
title: data.page,
})
// Wait for a session token before trying to load an authenticated page
await retrieveToken(app)
// Keep retrieving a session token periodically
keepRetrievingToken(app)
// Redirect to the requested page when DOM loads
const isInitialRedirect = true
redirectThroughTurbo(isInitialRedirect)
document.addEventListener('turbo:load', function (event) {
redirectThroughTurbo()
})
})
// Helper functions
function redirectThroughTurbo(isInitialRedirect = false) {
const data = document.getElementById('shopify-app-init').dataset
const validLoadPath = data && data.loadPath
const shouldRedirect = isInitialRedirect
? validLoadPath
: validLoadPath && data.loadPath !== '/home'
if (shouldRedirect) Turbo.visit(data.loadPath)
}
async function retrieveToken(app) {
window.sessionToken = await getSessionToken(app)
}
function keepRetrievingToken(app) {
setInterval(() => {
retrieveToken(app)
}, SESSION_TOKEN_REFRESH_INTERVAL)
}
Make Sure Your Shop Records Stay Up-To-Date
The final steps in the Shopify tutorial felt a bit disparate in nature. The instructions suggest that shop
records be removed from the database when the corresponding Shopify shop uninstalls our application. There isn't a reason provided in the documentation as to why this is explicitly necessary and frankly, we were never able to uncover why this is required for authentication purposes. From the docs:
To ensure OAuth continues to work with session tokens, your app must update its shop records when a shop uninstalls your appâŚ
However, it does seem to be a reasonable request, and likely a best practice, to eliminate data after a merchant has elected to uninstall the Shopify application. Letâs make sure their data isnât hanging around.
Register the app/uninstalled Webhook For New and Existing Shops
Shopify provides a number of webhooks that we can subscribe to at the shop-level. One of them is the app/uninstalled
webhook, which will allow us to receive a request any time a merchant uninstalls the Shopify app from their shop. The shopify_app
gem provides a lot of the webhook endpoint setup behind the scenes, the details of which are outside the scope of this article but you can learn more here if youâd like. What is relevant is that we can configure webhooks and add a background job that is named based on the webhook topic, which will automatically be invoked when the webhook is received.
# In config/initializers/shopify_app.rb
ShopifyApp.configure do |config|
# ...
# This assumes you have an environment variable named HOST
config.webhooks = [
{ topic: "app/uninstalled", address: "#{ENV['HOST']}/webhooks/app_uninstalled" },
]
end
Weâve added our new webhook, easy as pie. Moving forward, each merchant that installs our Shopify application on their store will be automatically subscribed to this webhook. However, existing stores will not be subscribed so weâll need to handle those cases. Letâs add a tested background job that we can invoke from the console to update existing shop
records. To do this, we can leverage the ShopifyApp::WebhooksManagerJob
that is provided by the shopify_app
gem.
# In app/jobs/register_webhooks_for_shops_job.rb
class RegisterWebhooksForShopsJob < ApplicationJob
def perform
Shop.find_each do |shop|
ShopifyApp::WebhooksManagerJob.perform_now(
shop_domain: shop.shopify_domain,
shop_token: shop.shopify_token,
webhooks: ShopifyApp.configuration.webhooks,
)
end
end
end
# In spec/jobs/register_webhooks_for_shops_job_spec.rb
require "rails_helper"
describe RegisterWebhooksForShopsJob, type: :job do
describe "#perform" do
it "performs the ShopifyApp::WebhooksManagerJob for each shop" do
shop_one, shop_two = create_pair(:shop)
webhooks = ShopifyApp.configuration.webhooks
allow(ShopifyApp::WebhooksManagerJob).to receive(:perform_now)
allow(ShopifyApp::WebhooksManagerJob).to receive(:perform_now)
RegisterWebhooksForShopsJob.new.perform
expect(ShopifyApp::WebhooksManagerJob).to have_received(:perform_now)
.with(
shop_domain: shop_one.shopify_domain,
shop_token: shop_one.shopify_token,
webhooks:,
)
expect(ShopifyApp::WebhooksManagerJob).to have_received(:perform_now)
.with(
shop_domain: shop_two.shopify_domain,
shop_token: shop_two.shopify_token,
webhooks:,
)
end
end
end
Now, we can open up our rails console
and run RegisterWebhooksForShopsJob.perform_later
to ensure the new webhook gets registered for all existing shop
s. Make sure your Sidekiq server is running!
Destroy Uninstalled Shops
The final step in the Shopify tutorial is to add the background job that is responsible for destroying the shop
after the Shopify app has been uninstalled. It is a requirement that the name of this job reflects the webhook topic (app/uninstalled
) since this is how the shopify_app
gem knows what job to trigger when a webhook is received. The params
that the #perform
method receives are the parameters included in the webhook request.
# In app/jobs/app_uninstalled_job.rb
class AppUninstalledJob < ApplicationJob
def perform(params)
shop = Shop.find_by(shopify_domain: params[:shop_domain])
shop.destroy! if shop
end
end
# In spec/jobs/app_uninstalled_job_spec.rb
require "rails_helper"
describe AppUninstalledJob, type: :job do
describe "#perform" do
it "destroys a shop" do
shop = create(:shop)
params = { shop_domain: shop.shopify_domain, webhook: {} }
expect { described_class.new.perform(params) }.to change(Shop, :count).from(1).to(0)
end
end
end
At this point we were able to authenticate in the browser and could see in the network tab that the session token was being fetched every two seconds as desired. We could also see our fun loading animation on a hard refresh. So⌠weâre done right? Not quite.
More Roadblocks
If youâre anything like us, you write a lot of tests. In this app, we had a fair amount of feature specs and despite this implementation working just fine in the browser, all of our feature specs were failing. What gives? Well, our embedded Shopify app is intended to be rendered in an iframe, which doesnât jibe well with acceptance tests. Fortunately, the engineer who created shopify-hotwire-sample
also wrote a great article on testing Shopify apps in Rails.
The approach used by the author, and the one we ultimately adopted, involves escaping the iframe by interfering with how AppBridge redirects in a test environment and fallback to cookie authentication. We need to make a few more updates to get our test suite back to green so letâs get to it.
Configure a Flag in Your Environment Files
Letâs add a custom boolean configuration variable called force_iframe
and set it to true in all environments except our test environment. We donât want to escape the iframe in development or production.
# In config/environment/test.rb
Rails.application.configure do
# ...
config.force_iframe = false
end
# In config/environment/development.rb
Rails.application.configure do
# ...
config.force_iframe = true
end
# In config/environment/production.rb
Rails.application.configure do
# ...
config.force_iframe = true
end
Update the Embedded App Layout (Again)
This time, weâll add a data attribute that passes the value of the environmentâs force_iframe
configuration variable to the front end.
<%# In app/views/layouts/embedded_app.html.erb %>
<%= content_tag(:div, nil, id: 'shopify-app-init', data: {
api_key: ShopifyApp.configuration.api_key,
shop_origin: @shop_origin || (@current_shopify_session.domain if @current_shopify_session),
load_path: params[:return_to] || home_path,
host: @host,
debug: Rails.env.development?,
force_iframe: Rails.configuration.force_iframe.to_s, # Add this line
} ) %>
Guard Session Token Retrieval in the Test Environment
Since we are sending the force_iframe
data attribute, we can use conditional logic to proceed as normal when the value of force_iframe
is true, and halt the execution in the case where it is false.
// In app/javascript/shopify_app/shopify_app.js
document.addEventListener('DOMContentLoaded', async () => {
const data = document.getElementById('shopify-app-init').dataset
const AppBridge = window['app-bridge']
const createApp = AppBridge.default
// Add this guard clause here
if (data.forceIframe === 'false') {
return
}
window.app = createApp({
apiKey: data.apiKey,
host: data.host,
forceDirect: true,
})
// ...
})
Skip Splash Page Redirection Based on force_iframe
Next, weâll want to prevent the AuthenticatedController
from redirecting to the splash page unless we are in an environment where force_iframe
is set to true so we can bypass token retrieval.
# In app/controllers/authenticated_controller
class AuthenticatedController < ApplicationController
# ...
skip_before_action :redirect_to_splash_page, unless: -> { Rails.configuration.force_iframe }
end
Fallback to Cookies in the Test Environment
We can configure ShopifyApp
to fallback to cookie authentication using a boolean. We chose to set this boolean based on the value of Rails.env.test?
, but you could also use the inverse of Rails.configuration.force_iframe
.
# In config/initializers/shopify_app.rb
ShopifyApp.configure do |config|
# ...
config.allow_cookie_authentication = Rails.env.test?
end
Set Up Test Helpers
The final piece in being able to successfully run our feature specs involves adding a helper that will allow us to mock OmniAuth and login as a shop
in our tests. Itâs worth mentioning that you would also need something similar for integration tests, if you have them. More on that under section 3, here.
# In spec/support/shopify_feature_helpers.rb
module ShopifyFeatureHelpers
def login(shop)
OmniAuth.config.test_mode = true
OmniAuth.config.add_mock(
:shopify,
provider: :shopify,
uid: shop.shopify_domain,
credentials: { token: shop.shopify_token },
extra: {
scope: ENV.fetch("SCOPES", ""),
},
)
OmniAuth.config.allowed_request_methods = [:post, :get]
OmniAuth.config.silence_get_warning = true
Rails.application.env_config["jwt.shopify_domain"] = shop.shopify_domain
visit "/auth/shopify"
end
def clear_login
Rails.application.env_config.delete("jwt.shopify_domain")
end
end
RSpec.configure do |config|
config.include ShopifyFeatureHelpers, type: :feature
end
At this point, the implementation should work in the browser and your feature specs should be back to that sweet, sweet green state.
Looking in the Rear View
Shopify is a really powerful, full-featured e-commerce platform and it has helped us to create some really incredible applications for our clients. However, there are certain aspects of Shopify development that are challenging. The vast documentation is not always clear and the expectations set forth are not always obvious. Our hope is that this guide has saved you some time on your journey to implement Shopifyâs session token authentication for an embedded, server-side rendered Rails application, and that youâre now on your way to a well-earned Shopify app approval. Happy Trails!
Learn more about how The Gnar builds Shopify apps.
Top comments (0)