Skip to content

Commit d51f23a

Browse files
Validate shop domains
1 parent 5f26141 commit d51f23a

16 files changed

Lines changed: 312 additions & 27 deletions

BREAKING_CHANGES_FOR_V17.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Breaking change notice for version 17.0.0
2+
3+
## Token exchange: `exchange_token` no longer accepts `shop`
4+
5+
`ShopifyAPI::Auth::TokenExchange.exchange_token` no longer accepts `shop:`. The shop is always taken from the session token’s `dest` claim (same host used for the OAuth request).
6+
7+
### Migration
8+
9+
Remove the `shop:` keyword from the call. Stop passing a shop string from query params when it can disagree with the token; rely on the session token from App Bridge instead.
10+
11+
```ruby
12+
# Before
13+
ShopifyAPI::Auth::TokenExchange.exchange_token(
14+
shop: shop,
15+
session_token: session_token,
16+
requested_token_type: ShopifyAPI::Auth::TokenExchange::RequestedTokenType::OFFLINE_ACCESS_TOKEN,
17+
)
18+
19+
# After
20+
ShopifyAPI::Auth::TokenExchange.exchange_token(
21+
session_token: session_token,
22+
requested_token_type: ShopifyAPI::Auth::TokenExchange::RequestedTokenType::OFFLINE_ACCESS_TOKEN,
23+
)
24+
```
25+
26+
See [OAuth token exchange documentation](docs/usage/oauth.md#perform-token-exchange).

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
Note: For changes to the API, see https://shopify.dev/changelog?filter=api
44
## Unreleased
5+
- [#1443](https://github.com/Shopify/shopify-api-ruby/pull/1443) Add `ShopifyAPI::Utils::ShopValidator` (module) with `sanitize_shop_domain` and `sanitize!`.
6+
- ⚠️ [Breaking] [#1443](https://github.com/Shopify/shopify-api-ruby/pull/1443) `ShopifyAPI::Auth::TokenExchange.exchange_token` no longer accepts a `shop` argument; the shop is always taken from the session token `dest` claim. See [BREAKING_CHANGES_FOR_V17.md](BREAKING_CHANGES_FOR_V17.md).
57

68
## 16.2.0 (2026-04-13)
79
- [#1442](https://github.com/Shopify/shopify-api-ruby/pull/1442) Add support for 2026-04 API version

Gemfile.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ PATH
33
specs:
44
shopify_api (16.2.0)
55
activesupport
6+
addressable (~> 2.7)
67
concurrent-ruby
78
hash_diff
89
httparty

docs/usage/oauth.md

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,7 @@ exchange a [session token](https://shopify.dev/docs/apps/auth/session-tokens) (S
7272
#### Input
7373
| Parameter | Type | Required? | Default Value | Notes |
7474
| -------------- | ---------------------- | :-------: | :-----------: | ----------------------------------------------------------------------------------------------------------- |
75-
| `shop` | `String` | Yes | - | A Shopify domain name in the form `{exampleshop}.myshopify.com`. |
76-
| `session_token` | `String` | Yes| - | The session token (Shopify Id Token) provided by App Bridge in either the request 'Authorization' header or URL param when the app is loaded in Admin. |
75+
| `session_token` | `String` | Yes| - | The session token (Shopify Id Token) provided by App Bridge in either the request 'Authorization' header or URL param when the app is loaded in Admin. Its `dest` claim determines which shop receives the token exchange request. |
7776
| `requested_token_type` | `TokenExchange::RequestedTokenType` | Yes | - | The type of token requested. Online: `TokenExchange::RequestedTokenType::ONLINE_ACCESS_TOKEN` or offline: `TokenExchange::RequestedTokenType::OFFLINE_ACCESS_TOKEN`. |
7877

7978
#### Output
@@ -83,14 +82,13 @@ your app should store this `Session` object to be used later [when making authen
8382
#### Example
8483
```ruby
8584

86-
# `shop` is the shop domain name - "this-is-my-example-shop.myshopify.com"
8785
# `session_token` is the session token provided by App Bridge either in:
8886
# - the request 'Authorization' header as `Bearer this-is-the-session_token`
8987
# - or as a URL param `id_token=this-is-the-session_token`
88+
# The shop is taken from the token's `dest` claim (see session token documentation).
9089

91-
def authenticate(shop, session_token)
90+
def authenticate(session_token)
9291
session = ShopifyAPI::Auth::TokenExchange.exchange_token(
93-
shop: shop,
9492
session_token: session_token,
9593
requested_token_type: ShopifyAPI::Auth::TokenExchange::RequestedTokenType::OFFLINE_ACCESS_TOKEN,
9694
# or if you're requesting an online access token:

lib/shopify_api/auth/client_credentials.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ def client_credentials(shop:)
2222
"ShopifyAPI::Context not setup, please call ShopifyAPI::Context.setup"
2323
end
2424

25-
shop_session = ShopifyAPI::Auth::Session.new(shop: shop)
25+
validated_shop = Utils::ShopValidator.sanitize!(shop)
26+
shop_session = ShopifyAPI::Auth::Session.new(shop: validated_shop)
2627
body = {
2728
client_id: ShopifyAPI::Context.api_key,
2829
client_secret: ShopifyAPI::Context.api_secret_key,
@@ -42,7 +43,7 @@ def client_credentials(shop:)
4243
response_hash = T.cast(response.body, T::Hash[String, T.untyped]).to_h
4344

4445
Session.from(
45-
shop: shop,
46+
shop: validated_shop,
4647
access_token_response: Oauth::AccessTokenResponse.from_hash(response_hash),
4748
)
4849
end

lib/shopify_api/auth/refresh_token.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ def refresh_access_token(shop:, refresh_token:)
2121
"ShopifyAPI::Context not setup, please call ShopifyAPI::Context.setup"
2222
end
2323

24-
shop_session = ShopifyAPI::Auth::Session.new(shop:)
24+
validated_shop = Utils::ShopValidator.sanitize!(shop)
25+
shop_session = ShopifyAPI::Auth::Session.new(shop: validated_shop)
2526
body = {
2627
client_id: ShopifyAPI::Context.api_key,
2728
client_secret: ShopifyAPI::Context.api_secret_key,
@@ -47,7 +48,7 @@ def refresh_access_token(shop:, refresh_token:)
4748
session_params = T.cast(response.body, T::Hash[String, T.untyped]).to_h
4849

4950
Session.from(
50-
shop:,
51+
shop: validated_shop,
5152
access_token_response: Oauth::AccessTokenResponse.from_hash(session_params),
5253
)
5354
end

lib/shopify_api/auth/token_exchange.rb

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,11 @@ class << self
2121

2222
sig do
2323
params(
24-
shop: String,
2524
session_token: String,
2625
requested_token_type: RequestedTokenType,
2726
).returns(ShopifyAPI::Auth::Session)
2827
end
29-
def exchange_token(shop:, session_token:, requested_token_type:)
28+
def exchange_token(session_token:, requested_token_type:)
3029
unless ShopifyAPI::Context.setup?
3130
raise ShopifyAPI::Errors::ContextNotSetupError,
3231
"ShopifyAPI::Context not setup, please call ShopifyAPI::Context.setup"
@@ -36,10 +35,11 @@ def exchange_token(shop:, session_token:, requested_token_type:)
3635
raise ShopifyAPI::Errors::UnsupportedOauthError,
3736
"Cannot perform OAuth Token Exchange for non embedded apps." unless ShopifyAPI::Context.embedded?
3837

39-
# Validate the session token content
40-
ShopifyAPI::Auth::JwtPayload.new(session_token)
38+
# Validate the session token and use the shop from the token's `dest` claim
39+
jwt_payload = ShopifyAPI::Auth::JwtPayload.new(session_token)
40+
dest_shop = jwt_payload.shop
4141

42-
shop_session = ShopifyAPI::Auth::Session.new(shop: shop)
42+
shop_session = ShopifyAPI::Auth::Session.new(shop: dest_shop)
4343
body = {
4444
client_id: ShopifyAPI::Context.api_key,
4545
client_secret: ShopifyAPI::Context.api_secret_key,
@@ -74,7 +74,7 @@ def exchange_token(shop:, session_token:, requested_token_type:)
7474
session_params = T.cast(response.body, T::Hash[String, T.untyped]).to_h
7575

7676
Session.from(
77-
shop: shop,
77+
shop: dest_shop,
7878
access_token_response: Oauth::AccessTokenResponse.from_hash(session_params),
7979
)
8080
end
@@ -91,7 +91,8 @@ def migrate_to_expiring_token(shop:, non_expiring_offline_token:)
9191
"ShopifyAPI::Context not setup, please call ShopifyAPI::Context.setup"
9292
end
9393

94-
shop_session = ShopifyAPI::Auth::Session.new(shop: shop)
94+
validated_shop = Utils::ShopValidator.sanitize!(shop)
95+
shop_session = ShopifyAPI::Auth::Session.new(shop: validated_shop)
9596
body = {
9697
client_id: ShopifyAPI::Context.api_key,
9798
client_secret: ShopifyAPI::Context.api_secret_key,
@@ -120,7 +121,7 @@ def migrate_to_expiring_token(shop:, non_expiring_offline_token:)
120121
session_params = T.cast(response.body, T::Hash[String, T.untyped]).to_h
121122

122123
Session.from(
123-
shop: shop,
124+
shop: validated_shop,
124125
access_token_response: Oauth::AccessTokenResponse.from_hash(session_params),
125126
)
126127
end

lib/shopify_api/clients/graphql/storefront.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@ def initialize(shop, private_token: nil, public_token: nil, api_version: nil)
1919
raise ArgumentError, "Storefront client requires either private_token or public_token to be provided"
2020
end
2121

22+
validated_shop = Utils::ShopValidator.sanitize!(shop)
2223
session = Auth::Session.new(
23-
id: shop,
24-
shop: shop,
24+
id: validated_shop,
25+
shop: validated_shop,
2526
access_token: "",
2627
is_online: false,
2728
)
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
module ShopifyAPI
5+
module Errors
6+
class InvalidShopError < StandardError
7+
end
8+
end
9+
end
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
require "addressable/uri"
5+
6+
module ShopifyAPI
7+
module Utils
8+
module ShopValidator
9+
TRUSTED_SHOPIFY_DOMAINS = T.let(
10+
[
11+
"shopify.com",
12+
"myshopify.io",
13+
"myshopify.com",
14+
"spin.dev",
15+
"shop.dev",
16+
].freeze,
17+
T::Array[String],
18+
)
19+
20+
class << self
21+
extend T::Sig
22+
23+
sig do
24+
params(
25+
shop_domain: String,
26+
myshopify_domain: T.nilable(String),
27+
).returns(T.nilable(String))
28+
end
29+
def sanitize_shop_domain(shop_domain, myshopify_domain: nil)
30+
uri = uri_from_shop_domain(shop_domain, myshopify_domain)
31+
return nil if uri.nil? || uri.host.nil? || uri.host.empty?
32+
33+
trusted_domains(myshopify_domain).each do |trusted_domain|
34+
host = T.cast(uri.host, String)
35+
uri_domain = uri.domain
36+
next if uri_domain.nil?
37+
38+
no_shop_name_in_subdomain = host == trusted_domain
39+
from_trusted_domain = trusted_domain == uri_domain
40+
41+
if unified_admin?(uri) && from_trusted_domain
42+
return myshopify_domain_from_unified_admin(uri)
43+
end
44+
return nil if no_shop_name_in_subdomain || host.empty?
45+
return host if from_trusted_domain
46+
end
47+
nil
48+
end
49+
50+
sig do
51+
params(
52+
shop: String,
53+
myshopify_domain: T.nilable(String),
54+
).returns(String)
55+
end
56+
def sanitize!(shop, myshopify_domain: nil)
57+
host = sanitize_shop_domain(shop, myshopify_domain: myshopify_domain)
58+
if host.nil? || host.empty?
59+
raise Errors::InvalidShopError,
60+
"shop must be a trusted Shopify domain (see ShopValidator::TRUSTED_SHOPIFY_DOMAINS), got: #{shop.inspect}"
61+
end
62+
63+
host
64+
end
65+
66+
private
67+
68+
sig { params(myshopify_domain: T.nilable(String)).returns(T::Array[String]) }
69+
def trusted_domains(myshopify_domain)
70+
trusted = TRUSTED_SHOPIFY_DOMAINS.dup
71+
if myshopify_domain && !myshopify_domain.to_s.empty?
72+
trusted << myshopify_domain
73+
trusted.uniq!
74+
end
75+
trusted
76+
end
77+
78+
sig do
79+
params(
80+
shop_domain: String,
81+
myshopify_domain: T.nilable(String),
82+
).returns(T.nilable(Addressable::URI))
83+
end
84+
def uri_from_shop_domain(shop_domain, myshopify_domain)
85+
name = shop_domain.to_s.downcase.strip
86+
return nil if name.empty?
87+
return nil if name.include?("@")
88+
89+
if myshopify_domain && !myshopify_domain.to_s.empty? &&
90+
!name.include?(myshopify_domain.to_s) && !name.include?(".")
91+
name += ".#{myshopify_domain}"
92+
end
93+
94+
uri = Addressable::URI.parse(name)
95+
if uri.scheme.nil?
96+
name = "https://#{name}"
97+
uri = Addressable::URI.parse(name)
98+
end
99+
100+
uri
101+
rescue Addressable::URI::InvalidURIError
102+
nil
103+
end
104+
105+
sig { params(uri: Addressable::URI).returns(T::Boolean) }
106+
def unified_admin?(uri)
107+
T.cast(uri.host, String).split(".").first == "admin"
108+
end
109+
110+
sig { params(uri: Addressable::URI).returns(String) }
111+
def myshopify_domain_from_unified_admin(uri)
112+
shop = uri.path.to_s.split("/").last
113+
"#{shop}.myshopify.com"
114+
end
115+
end
116+
end
117+
end
118+
end

0 commit comments

Comments
 (0)