Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
Unreleased
----------
- 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.
- Harden embedded app host validation to prevent parser-differential open redirects

23.0.2 (May 22, 2026)
Expand Down
12 changes: 5 additions & 7 deletions docs/Troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,20 +138,18 @@ For more details on how to handle embeded sessions, refer to [the session token
This can be caused by an infinite redirect due to a coding error
To investigate the cause, you can add a breakpoint or logging to the `rescue` clause of `ShopifyApp::CallbackController`.

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

## Controller Concerns
### Authenticated vs RequireKnownShop
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.

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.
### Authenticated vs Installation-Only Concerns
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.

#### Authenticated Requests
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.
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.

#### Unauthenticated Requests
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.
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.

## Debugging Tips

Expand Down
4 changes: 3 additions & 1 deletion docs/shopify_app/controller-concerns.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ end
```

## EnsureHasSession — Authenticated Requests
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.
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. Prefer this concern over composing lower-level session concerns directly.

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.

In addition to session management, this concern handles localization, CSRF protection, embedded app settings, and billing enforcement.

Expand Down
2 changes: 2 additions & 0 deletions docs/shopify_app/sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,8 @@ class MyController < ApplicationController
end
```

In token exchange authenticated controllers using `EnsureHasSession`, `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; the concern uses the configured invalid-token response path to get a fresh token before authenticated action code continues. 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 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.
This block should be used to wrap the code that makes API queries, so your business logic won't be retried.

Expand Down
10 changes: 9 additions & 1 deletion lib/shopify_app/controller_concerns/login_protection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -205,8 +205,16 @@ def fullpage_redirect_to(url)
end
end

def requested_shopify_domain
sanitized_shop_name
end

def authenticated_shopify_domain
current_shopify_session&.shop
end

def current_shopify_domain
shopify_domain = sanitized_shop_name || current_shopify_session&.shop
shopify_domain = requested_shopify_domain || authenticated_shopify_domain
ShopifyApp::Logger.info("Installed store - #{shopify_domain} deduced from user session")
shopify_domain
end
Expand Down
30 changes: 29 additions & 1 deletion lib/shopify_app/controller_concerns/token_exchange.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ module TokenExchange
def activate_shopify_session(&block)
retrieve_session_from_token_exchange if current_shopify_session.blank? || should_exchange_expired_token?

return if reject_mismatched_requested_shopify_domain

ShopifyApp::Logger.debug("Activating Shopify session")
ShopifyAPI::Context.activate_session(current_shopify_session)
with_token_refetch(current_shopify_session, shopify_id_token, &block)
Expand Down Expand Up @@ -46,14 +48,40 @@ def current_shopify_session_id
)
end

def requested_shopify_domain

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

AI:

This split keeps requested shop context available for bootstrap and routing, while aligning authenticated helpers with verified token/session context.

sanitized_shop_name
end

def authenticated_shopify_domain
authenticated_shopify_domain_from_token
rescue *INVALID_SHOPIFY_ID_TOKEN_ERRORS => e
respond_to_invalid_shopify_id_token(e)
end

def current_shopify_domain
sanitized_shop_name || current_shopify_session&.shop
authenticated_shopify_domain_from_token
rescue *INVALID_SHOPIFY_ID_TOKEN_ERRORS => e
respond_to_invalid_shopify_id_token(e)
end

private

def authenticated_shopify_domain_from_token
current_shopify_session&.shop || jwt_shopify_domain
end

def reject_mismatched_requested_shopify_domain
requested_domain = requested_shopify_domain
return false if requested_domain.blank?

authenticated_domain = authenticated_shopify_domain_from_token
return false if authenticated_domain.blank? || authenticated_domain == requested_domain

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

AI:

This keeps the added validation scoped to cases where both contexts are available, so existing missing/invalid token bounce and retry behavior remains unchanged.


ShopifyApp::Logger.debug("Shop context validation failed")
head(:unauthorized)
true
end

def retrieve_session_from_token_exchange
@current_shopify_session = nil
ShopifyApp::Auth::TokenExchange.perform(shopify_id_token)
Expand Down
25 changes: 25 additions & 0 deletions test/shopify_app/controller_concerns/login_protection_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
require "action_controller"
require "action_controller/base"
require "action_view/testing/resolvers"
require "json"

class LoginProtectionController < ActionController::Base
include ShopifyApp::EmbeddedApp
Expand Down Expand Up @@ -31,6 +32,13 @@ def redirect
fullpage_redirect_to("https://example.com")
end

def shop_context
render(json: {
requested_shopify_domain: requested_shopify_domain,
authenticated_shopify_domain: authenticated_shopify_domain,
})
end

def raise_unauthorized
unauthorized_response = ShopifyAPI::Clients::HttpResponse.new(code: 401, headers: {}, body: "")
raise ShopifyAPI::Errors::HttpResponseError.new(response: unauthorized_response), "unauthorized"
Expand Down Expand Up @@ -515,6 +523,22 @@ class LoginProtectionControllerTest < ActionController::TestCase
end
end

test "shop domain helpers expose requested and authenticated domains separately" do
requested_shop = "other-shop.myshopify.com"

with_application_test_routes do
assert @controller.respond_to?(:requested_shopify_domain, true)
assert @controller.respond_to?(:authenticated_shopify_domain, true)

get :shop_context, params: { shop: requested_shop }

assert_response :ok
context = JSON.parse(response.body)
assert_equal requested_shop, context["requested_shopify_domain"]
assert_equal @shop, context["authenticated_shopify_domain"]
end
end

test "#fullpage_redirect_to sends a post message to that shop in the shop param" do
with_application_test_routes do
example_shop = "shop.myshopify.com"
Expand Down Expand Up @@ -650,6 +674,7 @@ def with_application_test_routes
get "/" => "login_protection#index"
get "/second_login" => "login_protection#second_login"
get "/redirect" => "login_protection#redirect"
get "/shop_context" => "login_protection#shop_context"
get "/raise_unauthorized" => "login_protection#raise_unauthorized"
get "/raise_not_found" => "login_protection#raise_not_found"
get "/index_with_headers" => "login_protection#index_with_headers"
Expand Down
Loading
Loading