Skip to content
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

Handle invalid session token #1823

Merged
merged 10 commits into from
Apr 19, 2024
8 changes: 5 additions & 3 deletions app/controllers/concerns/shopify_app/ensure_installed.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ module EnsureInstalled

before_action :check_shop_domain

unless ShopifyApp.configuration.use_new_embedded_auth_strategy?
# TODO: Add support to use new embedded auth strategy here when invalid
# session token can be handled by AppBridge app reload
if ShopifyApp.configuration.use_new_embedded_auth_strategy?
include ShopifyApp::TokenExchange
include ShopifyApp::EmbeddedApp
around_action :activate_shopify_session
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed that the docs mention EnsureInstalled provides the installed_shop_session method and EnsureHasSession provides current_shopify_session. I think TokenExchange#activate_shopify_session provides current_shopify_session, right? Should it be different for EnsureInstalled? Or maybe provide both methods?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yess, so EnsureInstalled concern itself implements the installed_shop_session method, so it'll still be able to be used. Having TokenExchange enabled, they'll just get both 'current_shopify_sessionandinstalled_shop_session` !

else
before_action :check_shop_known
before_action :validate_non_embedded_session
end
Expand Down
6 changes: 5 additions & 1 deletion app/controllers/shopify_app/sessions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ class SessionsController < ActionController::Base

layout false, only: :new

after_action only: [:new, :create] do |controller|
after_action only: [:new, :create, :patch_shopify_id_token] do |controller|
controller.response.headers.except!("X-Frame-Options")
end

Expand All @@ -19,6 +19,10 @@ def create
authenticate
end

def patch_shopify_id_token
render(layout: "shopify_app/layouts/app_bridge")
end

def top_level_interaction
@url = login_url_with_optional_shop(top_level: true)
validate_shop_presence
Expand Down
17 changes: 17 additions & 0 deletions app/views/shopify_app/layouts/app_bridge.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title><%= ShopifyApp.configuration.application_name %></title>
<%= yield :head %>
<script
data-api-key="<%= ShopifyApp.configuration.api_key %>"
src="https://cdn.shopify.com/shopifycloud/app-bridge.js">
</script>
<%= csrf_meta_tags %>
</head>

<body>
<%= yield %>
</body>
</html>
Empty file.
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
get login_url => :new, :as => :login
post login_url => :create, :as => :authenticate
get "logout" => :destroy, :as => :logout
get "patch_shopify_id_token" => :patch_shopify_id_token

# Kept to prevent apps relying on these routes from breaking
if login_url.gsub(%r{^/}, "") != "login"
Expand Down
46 changes: 29 additions & 17 deletions lib/shopify_app/controller_concerns/token_exchange.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,18 @@ module TokenExchange
extend ActiveSupport::Concern
include ShopifyApp::AdminAPI::WithTokenRefetch

INVALID_SHOPIFY_ID_TOKEN_ERRORS = [
ShopifyAPI::Errors::CookieNotFoundError,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would we ever have a cookie present for token exchange? If it's only meant to run for embedded apps there should never be any cookies involved, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea cookies are never involved.. but the way ShopifyAPI::current_session_id is setup, it'll throw a "CookieNotFoundError" if we pass in a nil auth header (which will be the case for the initial server load when we're not using id_token from URL params), so once I change this to use the new util method, we won't be catching this CookieNotFoundError but MissingJwtTokenError instead.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm that's fair. I guess we can't change this without it potentially breaking apps out there, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea exactly :(

ShopifyAPI::Errors::InvalidJwtTokenError,
].freeze

def activate_shopify_session(&block)
retrieve_session_from_token_exchange if current_shopify_session.blank? || should_exchange_expired_token?
begin
retrieve_session_from_token_exchange if current_shopify_session.blank? || should_exchange_expired_token?
rescue *INVALID_SHOPIFY_ID_TOKEN_ERRORS => e
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we rescue these errors for the entire activate_shopify_session method? with_token_refetch might also end up raising this error, since it performs token exchange.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was pairing with Mike and we thought that's not worth it since we already check for validity before we call token exchange, it'd just be adding extra work for the slightest chance that it might expire within that timeframe... There are leeways in-place to ensure we don't encounter these problems..?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh but what if we don't perform token exchange there because current_shopify_session exists and then do during with_token_refetch? In that case it wouldn't be double validation/rescue, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmmm I think this will cause a "Double render/redirect" error since this is around the controller action.. so if we handle the error and respond with retry or redirect to bounce page and the controller action also renders... 🤔

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the action raises a token exchange error it will likely not have rendered yet, but you're right that it could happen if the app doesn't follow the pattern of rendering/redirecting as the last thing in the action 🤔

I looked up and saw there's a performed? method we can call in the controller to avoid a double render/redirect. What if we bounce unless performed?

ShopifyApp::Logger.debug("Responding to invalid Shopify ID token: #{e.message}")
return respond_to_invalid_shopify_id_token
end

begin
ShopifyApp::Logger.debug("Activating Shopify session")
Expand Down Expand Up @@ -47,9 +57,6 @@ def current_shopify_domain
def retrieve_session_from_token_exchange
@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 shopify_id_token
Expand All @@ -61,23 +68,28 @@ def id_token_header
end

def respond_to_invalid_shopify_id_token
# TODO: Implement this method to handle invalid session tokens
return redirect_to_bounce_page if request.headers["HTTP_AUTHORIZATION"].blank?

# if request.xhr?
# response.set_header("X-Shopify-Retry-Invalid-Session-Request", 1)
# unauthorized_response = { message: :unauthorized }
# render(json: { errors: [unauthorized_response] }, status: :unauthorized)
# else
# patch_session_token_url = "#{ShopifyAPI::Context.host}/patch_session_token"
# patch_session_token_params = request.query_parameters.except(:id_token)
ShopifyApp::Logger.debug("Responding to invalid Shopify ID token with unauthorized response")
response.set_header("X-Shopify-Retry-Invalid-Session-Request", 1)
unauthorized_response = { message: :unauthorized }
render(json: { errors: [unauthorized_response] }, status: :unauthorized)
end

def redirect_to_bounce_page
ShopifyApp::Logger.debug("Redirecting to bounce page for patching Shopify ID token")
patch_shopify_id_token_url = "#{ShopifyApp.configuration.root_url}/patch_shopify_id_token"
patch_shopify_id_token_params = request.query_parameters.except(:id_token)

# bounce_url = "#{ShopifyAPI::Context.host}#{request.path}?#{patch_session_token_params.to_query}"
bounce_url = "#{request.path}?#{patch_shopify_id_token_params.to_query}"

# # App Bridge will trigger a fetch to the URL in shopify-reload, with a new session token in headers
# patch_session_token_params["shopify-reload"] = bounce_url
# App Bridge will trigger a fetch to the URL in shopify-reload, with a new session token in headers
patch_shopify_id_token_params["shopify-reload"] = bounce_url

# redirect_to("#{patch_session_token_url}?#{patch_session_token_params.to_query}", allow_other_host: true)
# end
redirect_to(
"#{patch_shopify_id_token_url}?#{patch_shopify_id_token_params.to_query}",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wish there was a way to use the Rails URL helper method patch_shopify_id_token_path here which already does most of the URL assembly work for us, but the way our tests are setup using with_routing it's not including engine's routes, so I think it would be a bit too much work for it to be worth it.

allow_other_host: true,
)
end

def online_token_configured?
Expand Down
5 changes: 5 additions & 0 deletions test/controllers/sessions_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,11 @@ class SessionsControllerTest < ActionController::TestCase
end
end

test "#patch_shopify_id_token renders the app bridge layout" do
get :patch_shopify_id_token, params: { shop: "my-shop" }
assert_template "shopify_app/layouts/app_bridge"
end

private

def assert_redirected_to_top_level(shop_domain, expected_url = nil)
Expand Down
5 changes: 5 additions & 0 deletions test/dummy/config/initializers/shopify_app.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,13 @@ def self.call
config.embedded_redirect_url = nil

config.shop_session_repository = ShopifyApp::InMemorySessionStore
config.user_session_repository = nil
config.after_authenticate_job = false
config.reauth_on_access_scope_changes = true
config.root_url = "/"
config.wip_new_embedded_auth_strategy = false
config.check_session_expiry_date = false
config.custom_post_authenticate_tasks = nil
end

setup_context
Expand Down
4 changes: 4 additions & 0 deletions test/routes/sessions_routes_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ class SessionsRoutesTest < ActionController::TestCase
assert_routing "/logout", { controller: "shopify_app/sessions", action: "destroy" }
end

test "patch_shopify_id_token routes to sessions#patch_shopify_id_token" do
rachel-carvalho marked this conversation as resolved.
Show resolved Hide resolved
assert_routing "/patch_shopify_id_token", { controller: "shopify_app/sessions", action: "patch_shopify_id_token" }
end

test "login route doesn't change with custom root URL because it is in an engine" do
ShopifyApp.configuration.root_url = "/new-root"
Rails.application.reload_routes!
Expand Down
40 changes: 40 additions & 0 deletions test/shopify_app/controller_concerns/token_exchange_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
require "test_helper"
require "action_controller"
require "action_controller/base"
require "json"

class ApiClass
def self.perform; end
Expand All @@ -17,6 +18,10 @@ def index
render(plain: "OK")
end

def reloaded_path
render(plain: "OK")
end

def make_api_call
ApiClass.perform
render(plain: "OK")
Expand Down Expand Up @@ -178,13 +183,48 @@ class TokenExchangeControllerTest < ActionController::TestCase
end
end

[
ShopifyAPI::Errors::InvalidJwtTokenError,
ShopifyAPI::Errors::CookieNotFoundError,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we shouldn't respond to invalid cookies with a bounce page now that we'll be using the method that exclusively checks shopify_id_token.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea once I use the new utility method, we won't be handling CookieNotFoundError error anymore, but it'll be checking for MissingJwtTokenError instead

].each do |invalid_shopify_id_token_error|
test "Redirects to bounce page if Shopify ID token is invalid with #{invalid_shopify_id_token_error}" do
ShopifyApp.configuration.root_url = "/my-root"
ShopifyAPI::Utils::SessionUtils.stubs(:current_session_id).raises(invalid_shopify_id_token_error)
request.headers["HTTP_AUTHORIZATION"] = nil

params = { shop: @shop, my_param: "for-keeps", id_token: "dont-include-this-id-token" }
expected_redirect_url = "/my-root/patch_shopify_id_token?my_param=for-keeps&shop=my-shop.myshopify.com"
expected_redirect_url += "&shopify-reload=%2Freloaded_path%3Fmy_param%3Dfor-keeps%26shop%3Dmy-shop.myshopify.com"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: maybe we could URL encode the param for better readability of the test?

Suggested change
expected_redirect_url = "/my-root/patch_shopify_id_token?my_param=for-keeps&shop=my-shop.myshopify.com"
expected_redirect_url += "&shopify-reload=%2Freloaded_path%3Fmy_param%3Dfor-keeps%26shop%3Dmy-shop.myshopify.com"
reload_url = CGI.escape("/reloaded_path?my_param=for-keeps&shop=#{@shop}")
expected_redirect_url = "/my-root/patch_shopify_id_token?my_param=for-keeps&shop=my-shop.myshopify.com&shopify-reload=#{reload_url}"

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤯 NICE!


with_application_test_routes do
get :reloaded_path, params: params
assert_redirected_to expected_redirect_url
end
end

test "Responds with unauthorized if Shopify Id token is invalid with #{invalid_shopify_id_token_error} and authorization header exists" do
ShopifyAPI::Utils::SessionUtils.stubs(:current_session_id).raises(invalid_shopify_id_token_error)
request.headers["HTTP_AUTHORIZATION"] = @id_token_in_header
expected_response = { errors: [{ message: :unauthorized }] }

with_application_test_routes do
get :make_api_call, params: { shop: @shop }

assert_response :unauthorized
assert_equal expected_response.to_json, response.body
assert_equal 1, response.headers["X-Shopify-Retry-Invalid-Session-Request"]
end
end
end

private

def with_application_test_routes
with_routing do |set|
set.draw do
get "/" => "token_exchange#index"
get "/make_api_call" => "token_exchange#make_api_call"
get "/reloaded_path" => "token_exchange#reloaded_path"
end
yield
end
Expand Down
Loading