Skip to content

Commit 0e5e485

Browse files
committed
Harden token exchange shop context handling
1 parent 231cf4f commit 0e5e485

8 files changed

Lines changed: 287 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
Unreleased
22
----------
3+
- Token-exchange requests whose `shop` query parameter does not match the authenticated shop are now rejected with 401. `current_shopify_domain` no longer reflects the `shop` parameter; use `requested_shopify_domain` when you need the requested/bootstrap shop value.
34
- Harden embedded app host validation to prevent parser-differential open redirects
45

56
23.0.2 (May 22, 2026)

docs/Troubleshooting.md

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -138,20 +138,18 @@ For more details on how to handle embeded sessions, refer to [the session token
138138
This can be caused by an infinite redirect due to a coding error
139139
To investigate the cause, you can add a breakpoint or logging to the `rescue` clause of `ShopifyApp::CallbackController`.
140140

141-
One possible cause is that for XHR requests, the `Authenticated` concern should be used, rather than `RequireKnownShop`.
141+
One possible cause is that for XHR requests, the `EnsureHasSession` concern should be used, rather than installation-only concerns such as `EnsureInstalled`.
142142
See below for further details.
143143

144144
## Controller Concerns
145-
### Authenticated vs RequireKnownShop
146-
The gem heavily relies on the `current_shopify_domain` helper to contextualize a request to a given Shopify shop. This helper is set in different and conflicting ways if the request is authenticated or not.
147-
148-
Because of these conflicting approaches the `Authenticated` (for use in authenticated requests) and `RequireKnownShop` (for use in unauthenticated requests) controller concerns must *never* be included within the same controller.
145+
### Authenticated vs Installation-Only Concerns
146+
The gem relies on shop domain helpers to contextualize a request to a given Shopify shop. Authenticated and unauthenticated requests use different trust sources, so keep those concerns separate.
149147

150148
#### Authenticated Requests
151-
For authenticated requests, use the [`Authenticated` controller concern](https://github.com/Shopify/shopify_app/blob/main/app/controllers/concerns/shopify_app/authenticated.rb). The `current_shopify_domain` is set from the JWT for these requests.
149+
For authenticated requests, use the [`EnsureHasSession` controller concern](https://github.com/Shopify/shopify_app/blob/main/app/controllers/concerns/shopify_app/ensure_has_session.rb). With token exchange, `current_shopify_domain` is set from the verified ID token/session, and request shop context is validated before the action runs. Use `authenticated_shopify_domain` when you specifically need that trusted domain value.
152150

153151
#### Unauthenticated Requests
154-
For unauthenticated requests, use the [`RequireKnownShop` controller concern](https://github.com/Shopify/shopify_app/blob/main/app/controllers/concerns/shopify_app/require_known_shop.rb). The `current_shopify_domain` is set from the query string parameters that are passed.
152+
For unauthenticated installation or app-shell requests, use the [`EnsureInstalled` controller concern](https://github.com/Shopify/shopify_app/blob/main/app/controllers/concerns/shopify_app/ensure_installed.rb). The requested shop is set from the query string parameters that are passed. In token exchange controllers, use `requested_shopify_domain` only for bootstrap or routing use cases, not tenant authorization.
155153

156154
## Debugging Tips
157155

docs/shopify_app/controller-concerns.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ end
2121
## EnsureHasSession — Authenticated Requests
2222
Use this concern for any controller action that needs to make authenticated Shopify API calls or access shop/user data. It verifies the requester's identity using either session tokens (embedded apps) or encrypted cookies (non-embedded apps), and works with both online (user) and offline (shop) access tokens.
2323

24+
When using the token exchange auth strategy, `current_shopify_domain` resolves to the authenticated shop from the verified ID token/session. Missing or invalid ID tokens use the configured invalid-token response path to get a fresh token. Request shop context is validated against the authenticated context before the action runs. If your app needs the `shop` query string for pre-auth bootstrap or routing, use `requested_shopify_domain` or an installation-only concern; do not use requested shop context for authorization or tenant scoping.
25+
2426
In addition to session management, this concern handles localization, CSRF protection, embedded app settings, and billing enforcement.
2527

2628
## EnsureInstalled — Installation Check Only

docs/shopify_app/sessions.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,8 @@ class MyController < ApplicationController
239239
end
240240
```
241241

242+
In token exchange authenticated controllers, `current_shopify_domain` and `authenticated_shopify_domain` resolve to the shop from the verified ID token/session. Embedded document requests can arrive without a usable token, for example after server-side redirects; missing or invalid ID tokens use the configured invalid-token response path to get a fresh token. Request shop context is validated against the authenticated context before the action runs. `requested_shopify_domain` resolves the sanitized `shop` query parameter for bootstrap or routing use cases only; do not use it for authorization, tenant lookup, or choosing a stored access token. If you call these helpers before or outside `activate_shopify_session`, return immediately when `performed?` is true because an invalid ID token can render the configured retry response.
243+
242244
If the error is being rescued in the action, it's still possible to make use of `with_token_refetch` provided by `EnsureHasSession` so that a new access token is fetched and the code is executed again with it. This will also update the session parameter with the new attributes.
243245
This block should be used to wrap the code that makes API queries, so your business logic won't be retried.
244246

lib/shopify_app/controller_concerns/login_protection.rb

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,8 +205,16 @@ def fullpage_redirect_to(url)
205205
end
206206
end
207207

208+
def requested_shopify_domain
209+
sanitized_shop_name
210+
end
211+
212+
def authenticated_shopify_domain
213+
current_shopify_session&.shop
214+
end
215+
208216
def current_shopify_domain
209-
shopify_domain = sanitized_shop_name || current_shopify_session&.shop
217+
shopify_domain = requested_shopify_domain || authenticated_shopify_domain
210218
ShopifyApp::Logger.info("Installed store - #{shopify_domain} deduced from user session")
211219
shopify_domain
212220
end

lib/shopify_app/controller_concerns/token_exchange.rb

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ module TokenExchange
1919
def activate_shopify_session(&block)
2020
retrieve_session_from_token_exchange if current_shopify_session.blank? || should_exchange_expired_token?
2121

22+
return if reject_mismatched_requested_shopify_domain
23+
2224
ShopifyApp::Logger.debug("Activating Shopify session")
2325
ShopifyAPI::Context.activate_session(current_shopify_session)
2426
with_token_refetch(current_shopify_session, shopify_id_token, &block)
@@ -46,14 +48,40 @@ def current_shopify_session_id
4648
)
4749
end
4850

51+
def requested_shopify_domain
52+
sanitized_shop_name
53+
end
54+
55+
def authenticated_shopify_domain
56+
authenticated_shopify_domain_from_token
57+
rescue *INVALID_SHOPIFY_ID_TOKEN_ERRORS => e
58+
respond_to_invalid_shopify_id_token(e)
59+
end
60+
4961
def current_shopify_domain
50-
sanitized_shop_name || current_shopify_session&.shop
62+
authenticated_shopify_domain_from_token
5163
rescue *INVALID_SHOPIFY_ID_TOKEN_ERRORS => e
5264
respond_to_invalid_shopify_id_token(e)
5365
end
5466

5567
private
5668

69+
def authenticated_shopify_domain_from_token
70+
current_shopify_session&.shop || jwt_shopify_domain
71+
end
72+
73+
def reject_mismatched_requested_shopify_domain
74+
requested_domain = requested_shopify_domain
75+
return false if requested_domain.blank?
76+
77+
authenticated_domain = authenticated_shopify_domain_from_token
78+
return false if authenticated_domain.blank? || authenticated_domain == requested_domain
79+
80+
ShopifyApp::Logger.debug("Shop context validation failed")
81+
head(:unauthorized)
82+
true
83+
end
84+
5785
def retrieve_session_from_token_exchange
5886
@current_shopify_session = nil
5987
ShopifyApp::Auth::TokenExchange.perform(shopify_id_token)

test/shopify_app/controller_concerns/login_protection_test.rb

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
require "action_controller"
55
require "action_controller/base"
66
require "action_view/testing/resolvers"
7+
require "json"
78

89
class LoginProtectionController < ActionController::Base
910
include ShopifyApp::EmbeddedApp
@@ -31,6 +32,13 @@ def redirect
3132
fullpage_redirect_to("https://example.com")
3233
end
3334

35+
def shop_context
36+
render(json: {
37+
requested_shopify_domain: requested_shopify_domain,
38+
authenticated_shopify_domain: authenticated_shopify_domain,
39+
})
40+
end
41+
3442
def raise_unauthorized
3543
unauthorized_response = ShopifyAPI::Clients::HttpResponse.new(code: 401, headers: {}, body: "")
3644
raise ShopifyAPI::Errors::HttpResponseError.new(response: unauthorized_response), "unauthorized"
@@ -515,6 +523,22 @@ class LoginProtectionControllerTest < ActionController::TestCase
515523
end
516524
end
517525

526+
test "shop domain helpers expose requested and authenticated domains separately" do
527+
requested_shop = "other-shop.myshopify.com"
528+
529+
with_application_test_routes do
530+
assert @controller.respond_to?(:requested_shopify_domain, true)
531+
assert @controller.respond_to?(:authenticated_shopify_domain, true)
532+
533+
get :shop_context, params: { shop: requested_shop }
534+
535+
assert_response :ok
536+
context = JSON.parse(response.body)
537+
assert_equal requested_shop, context["requested_shopify_domain"]
538+
assert_equal @shop, context["authenticated_shopify_domain"]
539+
end
540+
end
541+
518542
test "#fullpage_redirect_to sends a post message to that shop in the shop param" do
519543
with_application_test_routes do
520544
example_shop = "shop.myshopify.com"
@@ -650,6 +674,7 @@ def with_application_test_routes
650674
get "/" => "login_protection#index"
651675
get "/second_login" => "login_protection#second_login"
652676
get "/redirect" => "login_protection#redirect"
677+
get "/shop_context" => "login_protection#shop_context"
653678
get "/raise_unauthorized" => "login_protection#raise_unauthorized"
654679
get "/raise_not_found" => "login_protection#raise_not_found"
655680
get "/index_with_headers" => "login_protection#index_with_headers"

0 commit comments

Comments
 (0)