Skip to content

Commit eda8616

Browse files
Merge pull request #1443 from Shopify/river/use-jwt-dest-for-token-exchange
Add shop validation
2 parents 5f26141 + 3e01209 commit eda8616

File tree

16 files changed

+332
-28
lines changed

16 files changed

+332
-28
lines changed

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+
- [#1443](https://github.com/Shopify/shopify-api-ruby/pull/1443) `ShopifyAPI::Auth::TokenExchange.exchange_token` always uses the session token's `dest` claim, instead of the `shop` parameter, that is now deprecated. It will show a deprecation warning and the argument will be removed in the next major version.
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: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,9 @@ 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`. |
77+
| `shop` | `String` | No | `nil` | **Deprecated**, will be removed in v17.0.0. Ignored for the request host; the shop always comes from the session token `dest` claim. If passed, logs a deprecation warning. |
7878

7979
#### Output
8080
This method returns the new `ShopifyAPI::Auth::Session` object from the token exchange,
@@ -83,14 +83,13 @@ your app should store this `Session` object to be used later [when making authen
8383
#### Example
8484
```ruby
8585

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

91-
def authenticate(shop, session_token)
91+
def authenticate(session_token)
9292
session = ShopifyAPI::Auth::TokenExchange.exchange_token(
93-
shop: shop,
9493
session_token: session_token,
9594
requested_token_type: ShopifyAPI::Auth::TokenExchange::RequestedTokenType::OFFLINE_ACCESS_TOKEN,
9695
# 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: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,12 @@ class << self
2121

2222
sig do
2323
params(
24-
shop: String,
2524
session_token: String,
2625
requested_token_type: RequestedTokenType,
26+
shop: T.nilable(String),
2727
).returns(ShopifyAPI::Auth::Session)
2828
end
29-
def exchange_token(shop:, session_token:, requested_token_type:)
29+
def exchange_token(session_token:, requested_token_type:, shop: nil)
3030
unless ShopifyAPI::Context.setup?
3131
raise ShopifyAPI::Errors::ContextNotSetupError,
3232
"ShopifyAPI::Context not setup, please call ShopifyAPI::Context.setup"
@@ -36,10 +36,19 @@ def exchange_token(shop:, session_token:, requested_token_type:)
3636
raise ShopifyAPI::Errors::UnsupportedOauthError,
3737
"Cannot perform OAuth Token Exchange for non embedded apps." unless ShopifyAPI::Context.embedded?
3838

39-
# Validate the session token content
40-
ShopifyAPI::Auth::JwtPayload.new(session_token)
39+
# Validate the session token and use the shop from the token's `dest` claim
40+
jwt_payload = ShopifyAPI::Auth::JwtPayload.new(session_token)
41+
dest_shop = jwt_payload.shop
42+
43+
if shop
44+
ShopifyAPI::Logger.deprecated(
45+
"The `shop` parameter for `exchange_token` is deprecated and will be removed in v17. " \
46+
"The shop is now always taken from the session token's `dest` claim.",
47+
"17.0.0",
48+
)
49+
end
4150

42-
shop_session = ShopifyAPI::Auth::Session.new(shop: shop)
51+
shop_session = ShopifyAPI::Auth::Session.new(shop: dest_shop)
4352
body = {
4453
client_id: ShopifyAPI::Context.api_key,
4554
client_secret: ShopifyAPI::Context.api_secret_key,
@@ -74,7 +83,7 @@ def exchange_token(shop:, session_token:, requested_token_type:)
7483
session_params = T.cast(response.body, T::Hash[String, T.untyped]).to_h
7584

7685
Session.from(
77-
shop: shop,
86+
shop: dest_shop,
7887
access_token_response: Oauth::AccessTokenResponse.from_hash(session_params),
7988
)
8089
end
@@ -91,7 +100,8 @@ def migrate_to_expiring_token(shop:, non_expiring_offline_token:)
91100
"ShopifyAPI::Context not setup, please call ShopifyAPI::Context.setup"
92101
end
93102

94-
shop_session = ShopifyAPI::Auth::Session.new(shop: shop)
103+
validated_shop = Utils::ShopValidator.sanitize!(shop)
104+
shop_session = ShopifyAPI::Auth::Session.new(shop: validated_shop)
95105
body = {
96106
client_id: ShopifyAPI::Context.api_key,
97107
client_secret: ShopifyAPI::Context.api_secret_key,
@@ -120,7 +130,7 @@ def migrate_to_expiring_token(shop:, non_expiring_offline_token:)
120130
session_params = T.cast(response.body, T::Hash[String, T.untyped]).to_h
121131

122132
Session.from(
123-
shop: shop,
133+
shop: validated_shop,
124134
access_token_response: Oauth::AccessTokenResponse.from_hash(session_params),
125135
)
126136
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

shopify_api.gemspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ Gem::Specification.new do |s|
3333
s.required_ruby_version = ">= 3.2"
3434

3535
s.add_runtime_dependency("activesupport")
36+
s.add_runtime_dependency("addressable", "~> 2.7")
3637
s.add_runtime_dependency("concurrent-ruby")
3738
s.add_runtime_dependency("hash_diff")
3839
s.add_runtime_dependency("httparty")

0 commit comments

Comments
 (0)