Skip to content

Commit

Permalink
Merge pull request #1822 from Shopify/handle-invalid-access-token
Browse files Browse the repository at this point in the history
Handle invalid access tokens in token exchange
  • Loading branch information
rachel-carvalho committed Apr 16, 2024
2 parents 380376c + 0ddf652 commit 94c3ac3
Show file tree
Hide file tree
Showing 9 changed files with 451 additions and 164 deletions.
4 changes: 2 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ PATH
jwt (>= 2.2.3)
rails (> 5.2.1)
redirect_safely (~> 1.0)
shopify_api (>= 14.1.0, < 15.0)
shopify_api (>= 14.2.0, < 15.0)
sprockets-rails (>= 2.0.0)

GEM
Expand Down Expand Up @@ -217,7 +217,7 @@ GEM
ruby-progressbar (1.13.0)
ruby2_keywords (0.0.5)
securerandom (0.2.2)
shopify_api (14.1.0)
shopify_api (14.2.0)
activesupport
concurrent-ruby
hash_diff
Expand Down
4 changes: 4 additions & 0 deletions lib/shopify_app.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ def self.use_webpacker?

require "shopify_app/logger"

# Admin API helpers
require "shopify_app/admin_api/with_token_refetch"

# controller concerns
require "shopify_app/controller_concerns/csrf_protection"
require "shopify_app/controller_concerns/localization"
Expand All @@ -56,6 +59,7 @@ def self.use_webpacker?

# Auth helpers
require "shopify_app/auth/post_authenticate_tasks"
require "shopify_app/auth/token_exchange"

# jobs
require "shopify_app/jobs/webhooks_manager_job"
Expand Down
28 changes: 28 additions & 0 deletions lib/shopify_app/admin_api/with_token_refetch.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# frozen_string_literal: true

module ShopifyApp
module AdminAPI
module WithTokenRefetch
def with_token_refetch(session, shopify_id_token)
retrying = false if retrying.nil?
yield
rescue ShopifyAPI::Errors::HttpResponseError => error
if error.code != 401
ShopifyApp::Logger.debug("Encountered error: #{error.code} - #{error.response.inspect}, re-raising")
elsif retrying
ShopifyApp::Logger.debug("Shopify API returned a 401 Unauthorized error that was not corrected " \
"with token exchange, deleting current session and re-raising")
ShopifyApp::SessionRepository.delete_session(session.id)
else
retrying = true
ShopifyApp::Logger.debug("Shopify API returned a 401 Unauthorized error, exchanging token and " \
"retrying with new session")
new_session = ShopifyApp::Auth::TokenExchange.perform(shopify_id_token)
session.copy_attributes_from(new_session)
retry
end
raise
end
end
end
end
73 changes: 73 additions & 0 deletions lib/shopify_app/auth/token_exchange.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# frozen_string_literal: true

module ShopifyApp
module Auth
class TokenExchange
attr_reader :id_token

def self.perform(id_token)
new(id_token).perform
end

def initialize(id_token)
@id_token = id_token
end

def perform
domain = ShopifyApp::JWT.new(id_token).shopify_domain

Logger.info("Performing Token Exchange for [#{domain}] - (Offline)")
session = exchange_token(
shop: domain,
id_token: id_token,
requested_token_type: ShopifyAPI::Auth::TokenExchange::RequestedTokenType::OFFLINE_ACCESS_TOKEN,
)

if online_token_configured?
Logger.info("Performing Token Exchange for [#{domain}] - (Online)")
session = exchange_token(
shop: domain,
id_token: id_token,
requested_token_type: ShopifyAPI::Auth::TokenExchange::RequestedTokenType::ONLINE_ACCESS_TOKEN,
)
end

ShopifyApp.configuration.post_authenticate_tasks.perform(session)

session
end

private

def exchange_token(shop:, id_token:, requested_token_type:)
session = ShopifyAPI::Auth::TokenExchange.exchange_token(
shop: shop,
session_token: id_token,
requested_token_type: requested_token_type,
)

SessionRepository.store_session(session)

session
rescue ShopifyAPI::Errors::InvalidJwtTokenError
Logger.error("Invalid id token '#{id_token}' during token exchange")
raise
rescue ShopifyAPI::Errors::HttpResponseError => error
Logger.error(
"A #{error.code} error (#{error.class}) occurred during the token exchange. Response: #{error.response.body}",
)
raise
rescue ActiveRecord::RecordNotUnique
Logger.debug("Session not stored due to concurrent token exchange calls")
session
rescue => error
Logger.error("An error occurred during the token exchange: [#{error.class}] #{error.message}")
raise
end

def online_token_configured?
ShopifyApp.configuration.online_token_configured?
end
end
end
end
108 changes: 27 additions & 81 deletions lib/shopify_app/controller_concerns/token_exchange.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,38 +3,37 @@
module ShopifyApp
module TokenExchange
extend ActiveSupport::Concern
include ShopifyApp::AdminAPI::WithTokenRefetch

def activate_shopify_session
if current_shopify_session.blank?
retrieve_session_from_token_exchange
end

if ShopifyApp.configuration.check_session_expiry_date && current_shopify_session.expired?
@current_shopify_session = nil
retrieve_session_from_token_exchange
end
def activate_shopify_session(&block)
retrieve_session_from_token_exchange if current_shopify_session.blank? || should_exchange_expired_token?

begin
ShopifyApp::Logger.debug("Activating Shopify session")
ShopifyAPI::Context.activate_session(current_shopify_session)
yield
with_token_refetch(current_shopify_session, shopify_id_token, &block)
ensure
ShopifyApp::Logger.debug("Deactivating session")
ShopifyAPI::Context.deactivate_session
end
end

def should_exchange_expired_token?
ShopifyApp.configuration.check_session_expiry_date && current_shopify_session.expired?
end

def current_shopify_session
@current_shopify_session ||= begin
session_id = ShopifyAPI::Utils::SessionUtils.current_session_id(
request.headers["HTTP_AUTHORIZATION"],
nil,
online_token_configured?,
)
return nil unless session_id

ShopifyApp::SessionRepository.load_session(session_id)
end
return unless current_shopify_session_id

@current_shopify_session ||= ShopifyApp::SessionRepository.load_session(current_shopify_session_id)
end

def current_shopify_session_id
@current_shopify_session_id ||= ShopifyAPI::Utils::SessionUtils.current_session_id(
request.headers["HTTP_AUTHORIZATION"],
nil,
online_token_configured?,
)
end

def current_shopify_domain
Expand All @@ -46,75 +45,22 @@ def current_shopify_domain
private

def retrieve_session_from_token_exchange
# TODO: Right now JWT Middleware only updates env['jwt.shopify_domain'] from request headers tokens,
# which won't work for new installs.
# we need to update the middleware to also update the env['jwt.shopify_domain'] from the query params
domain = ShopifyApp::JWT.new(session_token).shopify_domain

ShopifyApp::Logger.info("Performing Token Exchange for [#{domain}] - (Offline)")
session = exchange_token(
shop: domain, # TODO: use jwt_shopify_domain ?
session_token: session_token,
requested_token_type: ShopifyAPI::Auth::TokenExchange::RequestedTokenType::OFFLINE_ACCESS_TOKEN,
)

if session && online_token_configured?
ShopifyApp::Logger.info("Performing Token Exchange for [#{domain}] - (Online)")
session = exchange_token(
shop: domain, # TODO: use jwt_shopify_domain ?
session_token: session_token,
requested_token_type: ShopifyAPI::Auth::TokenExchange::RequestedTokenType::ONLINE_ACCESS_TOKEN,
)
end

ShopifyApp.configuration.post_authenticate_tasks.perform(session)
end

def exchange_token(shop:, session_token:, requested_token_type:)
if session_token.blank?
# respond_to_invalid_session_token
return
end

begin
session = ShopifyAPI::Auth::TokenExchange.exchange_token(
shop: shop,
session_token: session_token,
requested_token_type: requested_token_type,
)
rescue ShopifyAPI::Errors::InvalidJwtTokenError
# respond_to_invalid_session_token
return
rescue ShopifyAPI::Errors::HttpResponseError => error
ShopifyApp::Logger.error(
"A #{error.code} error (#{error.class}) occurred during the token exchange. Response: #{error.response.body}",
)
raise
rescue => error
ShopifyApp::Logger.error("An error occurred during the token exchange: #{error.message}")
raise
end

if session
begin
ShopifyApp::SessionRepository.store_session(session)
rescue ActiveRecord::RecordNotUnique
ShopifyApp::Logger.debug("Session not stored due to concurrent token exchange calls")
end
end

session
@current_shopify_session = nil
ShopifyApp::Auth::TokenExchange.perform(shopify_id_token)
# TODO: Rescue JWT validation errors when bounce page is ready
# rescue ShopifyAPI::Errors::InvalidJwtTokenError
# respond_to_invalid_shopify_id_token
end

def session_token
@session_token ||= id_token_header
def shopify_id_token
@shopify_id_token ||= id_token_header
end

def id_token_header
request.headers["HTTP_AUTHORIZATION"]&.match(/^Bearer (.+)$/)&.[](1)
end

def respond_to_invalid_session_token
def respond_to_invalid_shopify_id_token
# TODO: Implement this method to handle invalid session tokens

# if request.xhr?
Expand Down
2 changes: 1 addition & 1 deletion shopify_app.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Gem::Specification.new do |s|
s.add_runtime_dependency("jwt", ">= 2.2.3")
s.add_runtime_dependency("rails", "> 5.2.1")
s.add_runtime_dependency("redirect_safely", "~> 1.0")
s.add_runtime_dependency("shopify_api", ">= 14.1.0", "< 15.0")
s.add_runtime_dependency("shopify_api", ">= 14.2.0", "< 15.0")
s.add_runtime_dependency("sprockets-rails", ">= 2.0.0")

s.add_development_dependency("byebug")
Expand Down
98 changes: 98 additions & 0 deletions test/shopify_app/admin_api/with_token_refetch_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# frozen_string_literal: true

require "test_helper"

class ShopifyApp::AdminAPI::WithTokenRefetchTest < ActiveSupport::TestCase
include ShopifyApp::AdminAPI::WithTokenRefetch

def setup
@session = ShopifyAPI::Auth::Session.new(id: "session-id", shop: "shop", access_token: "old", expires: 1.hour.ago)
@id_token = "an-id-token"

tomorrow = 1.day.from_now
@new_session = ShopifyAPI::Auth::Session.new(id: "session-id", shop: "shop", access_token: "new", expires: tomorrow)

@fake_admin_api = stub(:admin_api)
end

test "#with_token_refetch takes a block and returns its value" do
result = with_token_refetch(@session, @id_token) do
"returned by block"
end

assert_equal "returned by block", result
end

test "#with_token_refetch rescues Admin API HttpResponseError 401, performs token exchange and retries block" do
response = ShopifyAPI::Clients::HttpResponse.new(code: 401, body: { error: "oops" }.to_json, headers: {})
error = ShopifyAPI::Errors::HttpResponseError.new(response: response)
@fake_admin_api.stubs(:query).raises(error).then.returns("oh now we're good")

ShopifyApp::Logger.expects(:debug).with("Shopify API returned a 401 Unauthorized error, exchanging token " \
"and retrying with new session")

ShopifyApp::Auth::TokenExchange.expects(:perform).with(@id_token).returns(@new_session)

result = with_token_refetch(@session, @id_token) do
@fake_admin_api.query
end

assert_equal "oh now we're good", result
end

test "#with_token_refetch updates original session's attributes when token exchange is performed" do
response = ShopifyAPI::Clients::HttpResponse.new(code: 401, body: "", headers: {})
error = ShopifyAPI::Errors::HttpResponseError.new(response: response)
@fake_admin_api.stubs(:query).raises(error).then.returns("oh now we're good")

ShopifyApp::Auth::TokenExchange.stubs(:perform).with(@id_token).returns(@new_session)

with_token_refetch(@session, @id_token) do
@fake_admin_api.query
end

assert_equal @new_session.access_token, @session.access_token
assert_equal @new_session.expires, @session.expires
end

test "#with_token_refetch deletes existing token and re-raises when 401 persists" do
response = ShopifyAPI::Clients::HttpResponse.new(code: 401, body: "401 message", headers: {})
api_error = ShopifyAPI::Errors::HttpResponseError.new(response: response)

ShopifyApp::Auth::TokenExchange.stubs(:perform).with(@id_token).returns(@new_session)

@fake_admin_api.expects(:query).twice.raises(api_error)

ShopifyApp::Logger.expects(:debug).with("Shopify API returned a 401 Unauthorized error, exchanging token " \
"and retrying with new session")

ShopifyApp::Logger.expects(:debug).with("Shopify API returned a 401 Unauthorized error that was not corrected " \
"with token exchange, deleting current session and re-raising")
ShopifyApp::SessionRepository.expects(:delete_session).with("session-id")

reraised_error = assert_raises ShopifyAPI::Errors::HttpResponseError do
with_token_refetch(@session, @id_token) do
@fake_admin_api.query
end
end

assert_equal reraised_error, api_error
end

test "#with_token_refetch re-raises without deleting session when error is not a 401" do
response = ShopifyAPI::Clients::HttpResponse.new(code: 500, body: { error: "ooops" }.to_json, headers: {})
api_error = ShopifyAPI::Errors::HttpResponseError.new(response: response)

@fake_admin_api.expects(:query).raises(api_error)
ShopifyApp::SessionRepository.expects(:delete_session).never
ShopifyApp::Logger.expects(:debug).with(regexp_matches(/Encountered error: 500 \- .*ooops.*, re-raising/))

reraised_error = assert_raises ShopifyAPI::Errors::HttpResponseError do
with_token_refetch(@session, @id_token) do
@fake_admin_api.query
end
end

assert_equal reraised_error, api_error
end
end
Loading

0 comments on commit 94c3ac3

Please sign in to comment.