Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

Note: For changes to the API, see https://shopify.dev/changelog?filter=api
## Unreleased
- [#1443](https://github.com/Shopify/shopify-api-ruby/pull/1443) Add `ShopifyAPI::Utils::ShopValidator` (module) with `sanitize_shop_domain` and `sanitize!`.
- [#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.

## 16.2.0 (2026-04-13)
- [#1442](https://github.com/Shopify/shopify-api-ruby/pull/1442) Add support for 2026-04 API version
Expand Down
1 change: 1 addition & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ PATH
specs:
shopify_api (16.2.0)
activesupport
addressable (~> 2.7)
concurrent-ruby
hash_diff
httparty
Expand Down
9 changes: 4 additions & 5 deletions docs/usage/oauth.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,9 @@ exchange a [session token](https://shopify.dev/docs/apps/auth/session-tokens) (S
#### Input
| Parameter | Type | Required? | Default Value | Notes |
| -------------- | ---------------------- | :-------: | :-----------: | ----------------------------------------------------------------------------------------------------------- |
| `shop` | `String` | Yes | - | A Shopify domain name in the form `{exampleshop}.myshopify.com`. |
| `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. |
| `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. |
| `requested_token_type` | `TokenExchange::RequestedTokenType` | Yes | - | The type of token requested. Online: `TokenExchange::RequestedTokenType::ONLINE_ACCESS_TOKEN` or offline: `TokenExchange::RequestedTokenType::OFFLINE_ACCESS_TOKEN`. |
| `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. |

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

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

def authenticate(shop, session_token)
def authenticate(session_token)
session = ShopifyAPI::Auth::TokenExchange.exchange_token(
shop: shop,
session_token: session_token,
requested_token_type: ShopifyAPI::Auth::TokenExchange::RequestedTokenType::OFFLINE_ACCESS_TOKEN,
# or if you're requesting an online access token:
Expand Down
5 changes: 3 additions & 2 deletions lib/shopify_api/auth/client_credentials.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ def client_credentials(shop:)
"ShopifyAPI::Context not setup, please call ShopifyAPI::Context.setup"
end

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

Session.from(
shop: shop,
shop: validated_shop,
access_token_response: Oauth::AccessTokenResponse.from_hash(response_hash),
)
end
Expand Down
5 changes: 3 additions & 2 deletions lib/shopify_api/auth/refresh_token.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ def refresh_access_token(shop:, refresh_token:)
"ShopifyAPI::Context not setup, please call ShopifyAPI::Context.setup"
end

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

Session.from(
shop:,
shop: validated_shop,
access_token_response: Oauth::AccessTokenResponse.from_hash(session_params),
)
end
Expand Down
26 changes: 18 additions & 8 deletions lib/shopify_api/auth/token_exchange.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,12 @@ class << self

sig do
params(
shop: String,
session_token: String,
requested_token_type: RequestedTokenType,
shop: T.nilable(String),
).returns(ShopifyAPI::Auth::Session)
end
def exchange_token(shop:, session_token:, requested_token_type:)
Comment thread
lizkenyon marked this conversation as resolved.
def exchange_token(session_token:, requested_token_type:, shop: nil)
unless ShopifyAPI::Context.setup?
raise ShopifyAPI::Errors::ContextNotSetupError,
"ShopifyAPI::Context not setup, please call ShopifyAPI::Context.setup"
Expand All @@ -36,10 +36,19 @@ def exchange_token(shop:, session_token:, requested_token_type:)
raise ShopifyAPI::Errors::UnsupportedOauthError,
"Cannot perform OAuth Token Exchange for non embedded apps." unless ShopifyAPI::Context.embedded?

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

if shop
ShopifyAPI::Logger.deprecated(
"The `shop` parameter for `exchange_token` is deprecated and will be removed in v17. " \
"The shop is now always taken from the session token's `dest` claim.",
"17.0.0",
)
end

shop_session = ShopifyAPI::Auth::Session.new(shop: shop)
shop_session = ShopifyAPI::Auth::Session.new(shop: dest_shop)
Comment thread
gonzaloriestra marked this conversation as resolved.
body = {
client_id: ShopifyAPI::Context.api_key,
client_secret: ShopifyAPI::Context.api_secret_key,
Expand Down Expand Up @@ -74,7 +83,7 @@ def exchange_token(shop:, session_token:, requested_token_type:)
session_params = T.cast(response.body, T::Hash[String, T.untyped]).to_h

Session.from(
shop: shop,
shop: dest_shop,
access_token_response: Oauth::AccessTokenResponse.from_hash(session_params),
)
end
Expand All @@ -91,7 +100,8 @@ def migrate_to_expiring_token(shop:, non_expiring_offline_token:)
"ShopifyAPI::Context not setup, please call ShopifyAPI::Context.setup"
end

shop_session = ShopifyAPI::Auth::Session.new(shop: shop)
validated_shop = Utils::ShopValidator.sanitize!(shop)
shop_session = ShopifyAPI::Auth::Session.new(shop: validated_shop)
body = {
client_id: ShopifyAPI::Context.api_key,
client_secret: ShopifyAPI::Context.api_secret_key,
Expand Down Expand Up @@ -120,7 +130,7 @@ def migrate_to_expiring_token(shop:, non_expiring_offline_token:)
session_params = T.cast(response.body, T::Hash[String, T.untyped]).to_h

Session.from(
shop: shop,
shop: validated_shop,
access_token_response: Oauth::AccessTokenResponse.from_hash(session_params),
)
end
Expand Down
5 changes: 3 additions & 2 deletions lib/shopify_api/clients/graphql/storefront.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ def initialize(shop, private_token: nil, public_token: nil, api_version: nil)
raise ArgumentError, "Storefront client requires either private_token or public_token to be provided"
end

validated_shop = Utils::ShopValidator.sanitize!(shop)
session = Auth::Session.new(
id: shop,
shop: shop,
id: validated_shop,
shop: validated_shop,
access_token: "",
is_online: false,
)
Expand Down
9 changes: 9 additions & 0 deletions lib/shopify_api/errors/invalid_shop_error.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# typed: strict
# frozen_string_literal: true

module ShopifyAPI
module Errors
class InvalidShopError < StandardError
end
end
end
118 changes: 118 additions & 0 deletions lib/shopify_api/utils/shop_validator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# typed: strict
# frozen_string_literal: true

require "addressable/uri"

module ShopifyAPI
module Utils
module ShopValidator
TRUSTED_SHOPIFY_DOMAINS = T.let(
[
"shopify.com",
"myshopify.io",
"myshopify.com",
"spin.dev",
"shop.dev",
].freeze,
T::Array[String],
)

class << self
extend T::Sig

sig do
params(
shop_domain: String,
myshopify_domain: T.nilable(String),
).returns(T.nilable(String))
end
def sanitize_shop_domain(shop_domain, myshopify_domain: nil)
uri = uri_from_shop_domain(shop_domain, myshopify_domain)
return nil if uri.nil? || uri.host.nil? || uri.host.empty?

trusted_domains(myshopify_domain).each do |trusted_domain|
host = T.cast(uri.host, String)
uri_domain = uri.domain
next if uri_domain.nil?

no_shop_name_in_subdomain = host == trusted_domain
from_trusted_domain = trusted_domain == uri_domain

if unified_admin?(uri) && from_trusted_domain
return myshopify_domain_from_unified_admin(uri)
end
return nil if no_shop_name_in_subdomain || host.empty?
return host if from_trusted_domain
end
nil
end

sig do
params(
shop: String,
myshopify_domain: T.nilable(String),
).returns(String)
end
def sanitize!(shop, myshopify_domain: nil)
host = sanitize_shop_domain(shop, myshopify_domain: myshopify_domain)
if host.nil? || host.empty?
raise Errors::InvalidShopError,
"shop must be a trusted Shopify domain (see ShopValidator::TRUSTED_SHOPIFY_DOMAINS), got: #{shop.inspect}"
end

host
end

private

sig { params(myshopify_domain: T.nilable(String)).returns(T::Array[String]) }
def trusted_domains(myshopify_domain)
trusted = TRUSTED_SHOPIFY_DOMAINS.dup
if myshopify_domain && !myshopify_domain.to_s.empty?
trusted << myshopify_domain
trusted.uniq!
end
trusted
end

sig do
params(
shop_domain: String,
myshopify_domain: T.nilable(String),
).returns(T.nilable(Addressable::URI))
end
def uri_from_shop_domain(shop_domain, myshopify_domain)
name = shop_domain.to_s.downcase.strip
return nil if name.empty?
return nil if name.include?("@")

if myshopify_domain && !myshopify_domain.to_s.empty? &&
!name.include?(myshopify_domain.to_s) && !name.include?(".")
name += ".#{myshopify_domain}"
end

uri = Addressable::URI.parse(name)
if uri.scheme.nil?
name = "https://#{name}"
uri = Addressable::URI.parse(name)
end

uri
rescue Addressable::URI::InvalidURIError
nil
end

sig { params(uri: Addressable::URI).returns(T::Boolean) }
def unified_admin?(uri)
T.cast(uri.host, String).split(".").first == "admin"
end

sig { params(uri: Addressable::URI).returns(String) }
def myshopify_domain_from_unified_admin(uri)
shop = uri.path.to_s.split("/").last
"#{shop}.myshopify.com"
end
end
end
end
end
1 change: 1 addition & 0 deletions shopify_api.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ Gem::Specification.new do |s|
s.required_ruby_version = ">= 3.2"

s.add_runtime_dependency("activesupport")
s.add_runtime_dependency("addressable", "~> 2.7")
s.add_runtime_dependency("concurrent-ruby")
s.add_runtime_dependency("hash_diff")
s.add_runtime_dependency("httparty")
Expand Down
6 changes: 6 additions & 0 deletions test/auth/client_credentials_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ def test_client_credentials_context_not_setup
end
end

def test_client_credentials_rejects_non_shopify_domain
assert_raises(ShopifyAPI::Errors::InvalidShopError) do
ShopifyAPI::Auth::ClientCredentials.client_credentials(shop: "attacker.example")
end
end

def test_client_credentials_offline_token
stub_request(:post, "https://#{@shop}/admin/oauth/access_token")
.with(body: @client_credentials_request)
Expand Down
9 changes: 9 additions & 0 deletions test/auth/refresh_token_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@ def setup
}
end

def test_refresh_access_token_rejects_non_shopify_domain
assert_raises(ShopifyAPI::Errors::InvalidShopError) do
ShopifyAPI::Auth::RefreshToken.refresh_access_token(
shop: "attacker.example",
refresh_token: @refresh_token,
)
end
end

def test_refresh_access_token_success
stub_request(:post, "https://#{@shop}/admin/oauth/access_token")
.with(body: @refresh_token_request)
Expand Down
Loading
Loading