DEV Community 👩‍💻👨‍💻

Ethan Fertsch for The Gnar Company

Posted on • Originally published at thegnar.com

Navigating Session Token Authentication in Shopify for Embedded, Server-Side Rendered Rails Apps

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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?,
})%>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
# 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
Enter fullscreen mode Exit fullscreen mode

Now, we can open up our rails console and run RegisterWebhooksForShopsJob.perform_later to ensure the new webhook gets registered for all existing shops. 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
Enter fullscreen mode Exit fullscreen mode
# 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
} ) %>
Enter fullscreen mode Exit fullscreen mode

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,
  })

  // ...
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)

🌚 Browsing with dark mode makes you a better developer by a factor of exactly 40.

It's a scientific fact.