Skip to content

Commit 88100b6

Browse files
authored
Merge branch 'main' into 2025-04-REST
2 parents ebe524c + 59ebb5e commit 88100b6

File tree

6 files changed

+130
-29
lines changed

6 files changed

+130
-29
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ Note: For changes to the API, see https://shopify.dev/changelog?filter=api
55

66
- [#1362](https://github.com/Shopify/shopify-api-ruby/pull/1362) Add support for client credentials grant
77
- [#1372](https://github.com/Shopify/shopify-api-ruby/pull/1372) Add support for 2025-04 API version
8+
- [#1369](https://github.com/Shopify/shopify-api-ruby/pull/1369) Make `sub` and `sid` jwt claims optional (Checkout ui extension support)
9+
- [#1370](https://github.com/Shopify/shopify-api-ruby/pull/1370) Add support for Shopify internal hosts
10+
811

912
## 14.8.0
1013

lib/shopify_api/auth/jwt_payload.rb

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,14 @@ class JwtPayload
1010
JWT_EXPIRATION_LEEWAY = JWT_LEEWAY
1111

1212
sig { returns(String) }
13-
attr_reader :iss, :dest, :aud, :sub, :jti, :sid
13+
attr_reader :iss, :dest, :aud, :jti
1414

1515
sig { returns(Integer) }
1616
attr_reader :exp, :nbf, :iat
1717

18+
sig { returns(T.nilable(String)) }
19+
attr_reader :sub, :sid
20+
1821
alias_method :expire_at, :exp
1922

2023
sig { params(token: String).void }
@@ -30,12 +33,12 @@ def initialize(token)
3033
@iss = T.let(payload_hash["iss"], String)
3134
@dest = T.let(payload_hash["dest"], String)
3235
@aud = T.let(payload_hash["aud"], String)
33-
@sub = T.let(payload_hash["sub"], String)
36+
@sub = T.let(payload_hash["sub"], T.nilable(String))
3437
@exp = T.let(payload_hash["exp"], Integer)
3538
@nbf = T.let(payload_hash["nbf"], Integer)
3639
@iat = T.let(payload_hash["iat"], Integer)
3740
@jti = T.let(payload_hash["jti"], String)
38-
@sid = T.let(payload_hash["sid"], String)
41+
@sid = T.let(payload_hash["sid"], T.nilable(String))
3942

4043
raise ShopifyAPI::Errors::InvalidJwtTokenError,
4144
"Session token had invalid API key" unless @aud == Context.api_key
@@ -47,19 +50,9 @@ def shop
4750
end
4851
alias_method :shopify_domain, :shop
4952

50-
sig { returns(Integer) }
53+
sig { returns(T.nilable(Integer)) }
5154
def shopify_user_id
52-
@sub.to_i
53-
end
54-
55-
# TODO: Remove before releasing v11
56-
sig { params(shop: String).returns(T::Boolean) }
57-
def validate_shop(shop)
58-
Context.logger.warn(
59-
"Deprecation notice: ShopifyAPI::Auth::JwtPayload.validate_shop no longer checks the given shop and always " \
60-
"returns true. It will be removed in v11.",
61-
)
62-
true
55+
@sub.to_i if user_id_sub? && admin_session_token?
6356
end
6457

6558
alias_method :eql?, :==
@@ -86,6 +79,16 @@ def decode_token(token, api_secret_key)
8679
rescue JWT::DecodeError => err
8780
raise ShopifyAPI::Errors::InvalidJwtTokenError, "Error decoding session token: #{err.message}"
8881
end
82+
83+
sig { returns(T::Boolean) }
84+
def admin_session_token?
85+
@iss.end_with?("/admin")
86+
end
87+
88+
sig { returns(T::Boolean) }
89+
def user_id_sub?
90+
@sub&.match?(/\A\d+\z/) || false
91+
end
8992
end
9093
end
9194
end

lib/shopify_api/auth/oauth.rb

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ def begin_auth(shop:, redirect_path:, is_online: true, scope_override: nil)
4646
}
4747

4848
query_string = URI.encode_www_form(query)
49+
auth_route = auth_base_uri(shop) + "/oauth/authorize?#{query_string}"
4950

50-
auth_route = "https://#{shop}/admin/oauth/authorize?#{query_string}"
5151
{ auth_route: auth_route, cookie: cookie }
5252
end
5353

@@ -106,6 +106,20 @@ def validate_auth_callback(cookies:, auth_query:)
106106

107107
{ session: session, cookie: cookie }
108108
end
109+
110+
private
111+
112+
sig { params(shop: String).returns(String) }
113+
def auth_base_uri(shop)
114+
return "https://#{shop}/admin" unless defined?(DevServer)
115+
116+
# For first-party apps in development only, we leverage DevServer to build the admin base URI
117+
admin_web = T.unsafe(Object.const_get("DevServer")).new("web") # rubocop:disable Sorbet/ConstantsFromStrings
118+
admin_host = admin_web.host!(nonstandard_host_prefix: "admin")
119+
shop_name = shop.split(".").first
120+
121+
"https://#{admin_host}/store/#{shop_name}"
122+
end
109123
end
110124
end
111125
end

lib/shopify_api/clients/http_client.rb

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,17 @@ def request(request, response_as_struct: false)
4040
headers["Content-Type"] = T.must(request.body_type) if request.body_type
4141
headers = headers.merge(T.must(request.extra_headers)) if request.extra_headers
4242

43+
parsed_uri = URI(request_url(request))
44+
45+
headers = append_first_party_development_headers(headers, parsed_uri)
46+
4347
tries = 0
4448
response = HttpResponse.new(code: 0, headers: {}, body: "")
4549
while tries < request.tries
4650
tries += 1
4751
res = T.cast(HTTParty.send(
4852
request.http_method,
49-
request_url(request),
53+
parsed_uri.to_s,
5054
headers: headers,
5155
query: request.query,
5256
body: request.body.class == Hash ? T.unsafe(request.body).to_json : request.body,
@@ -115,6 +119,27 @@ def serialized_error(response)
115119
end
116120
body.to_json
117121
end
122+
123+
private
124+
125+
sig do
126+
params(
127+
headers: T::Hash[T.any(Symbol, String), T.untyped],
128+
parsed_uri: URI::Generic,
129+
).returns(T::Hash[T.any(Symbol, String), T.untyped])
130+
end
131+
def append_first_party_development_headers(headers, parsed_uri)
132+
return headers unless defined?(DevServer)
133+
return headers unless headers["Host"]&.include?(".my.shop.dev") || parsed_uri.host&.include?(".my.shop.dev")
134+
135+
# These headers are only used for first party applications in development mode
136+
headers["x-forwarded-host"] = headers["Host"] || parsed_uri.host
137+
headers["Host"] = T.unsafe(
138+
Object.const_get("DevServer::Core"), # rubocop:disable Sorbet/ConstantsFromStrings
139+
).new.host!(:app)
140+
141+
headers
142+
end
118143
end
119144
end
120145
end

lib/shopify_api/utils/session_utils.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ def session_id_from_shopify_id_token(id_token:, online:)
4949
shop = payload.shop
5050

5151
if online
52-
jwt_session_id(shop, payload.sub)
52+
jwt_session_id(shop, T.must(payload.sub))
5353
else
5454
offline_session_id(shop)
5555
end

test/auth/jwt_payload_test.rb

Lines changed: 67 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ module Auth
88
class JwtPayloadTest < Test::Unit::TestCase
99
def setup
1010
super
11-
@jwt_payload = {
11+
@admin_jwt_payload = {
1212
iss: "https://test-shop.myshopify.io/admin",
1313
dest: "https://test-shop.myshopify.io",
1414
aud: ShopifyAPI::Context.api_key,
@@ -19,12 +19,23 @@ def setup
1919
jti: "4321",
2020
sid: "abc123",
2121
}
22+
23+
@checkout_ui_extension_jwt_payload = {
24+
iss: "https://test-shop.myshopify.io/checkouts",
25+
dest: "test-shop.myshopify.io",
26+
aud: ShopifyAPI::Context.api_key,
27+
sub: "gid://shopify/Customer/123456789",
28+
exp: (Time.now + 10).to_i,
29+
nbf: 1234,
30+
iat: 1234,
31+
jti: "4321",
32+
}
2233
end
2334

2435
def test_decode_jwt_payload_succeeds_with_valid_token
25-
jwt_token = JWT.encode(@jwt_payload, ShopifyAPI::Context.api_secret_key, "HS256")
36+
jwt_token = JWT.encode(@admin_jwt_payload, ShopifyAPI::Context.api_secret_key, "HS256")
2637
decoded = ShopifyAPI::Auth::JwtPayload.new(jwt_token)
27-
assert_equal(@jwt_payload,
38+
assert_equal(@admin_jwt_payload,
2839
{
2940
iss: decoded.iss,
3041
dest: decoded.dest,
@@ -38,14 +49,14 @@ def test_decode_jwt_payload_succeeds_with_valid_token
3849
})
3950

4051
# Helper methods
41-
assert_equal(decoded.expire_at, @jwt_payload[:exp])
52+
assert_equal(decoded.expire_at, @admin_jwt_payload[:exp])
4253
assert_equal("test-shop.myshopify.io", decoded.shopify_domain)
4354
assert_equal("test-shop.myshopify.io", decoded.shop)
4455
assert_equal(1, decoded.shopify_user_id)
4556
end
4657

4758
def test_decode_jwt_payload_succeeds_with_spin_domain
48-
payload = @jwt_payload.dup
59+
payload = @admin_jwt_payload.dup
4960
payload[:iss] = "https://test-shop.other.spin.dev/admin"
5061
payload[:dest] = "https://test-shop.other.spin.dev"
5162
jwt_token = JWT.encode(payload, ShopifyAPI::Context.api_secret_key, "HS256")
@@ -68,14 +79,14 @@ def test_decode_jwt_payload_succeeds_with_spin_domain
6879
end
6980

7081
def test_decode_jwt_payload_fails_with_wrong_key
71-
jwt_token = JWT.encode(@jwt_payload, "Wrong", "HS256")
82+
jwt_token = JWT.encode(@admin_jwt_payload, "Wrong", "HS256")
7283
assert_raises(ShopifyAPI::Errors::InvalidJwtTokenError) do
7384
ShopifyAPI::Auth::JwtPayload.new(jwt_token)
7485
end
7586
end
7687

7788
def test_decode_jwt_payload_fails_with_expired_token
78-
payload = @jwt_payload.dup
89+
payload = @admin_jwt_payload.dup
7990
payload[:exp] = (Time.now - 40).to_i
8091
jwt_token = JWT.encode(payload, ShopifyAPI::Context.api_secret_key, "HS256")
8192
assert_raises(ShopifyAPI::Errors::InvalidJwtTokenError) do
@@ -84,7 +95,7 @@ def test_decode_jwt_payload_fails_with_expired_token
8495
end
8596

8697
def test_decode_jwt_payload_fails_if_not_activated_yet
87-
payload = @jwt_payload.dup
98+
payload = @admin_jwt_payload.dup
8899
payload[:nbf] = (Time.now + 12).to_i
89100
jwt_token = JWT.encode(payload, ShopifyAPI::Context.api_secret_key, "HS256")
90101
assert_raises(ShopifyAPI::Errors::InvalidJwtTokenError) do
@@ -93,7 +104,7 @@ def test_decode_jwt_payload_fails_if_not_activated_yet
93104
end
94105

95106
def test_decode_jwt_payload_fails_with_invalid_api_key
96-
jwt_token = JWT.encode(@jwt_payload, ShopifyAPI::Context.api_secret_key, "HS256")
107+
jwt_token = JWT.encode(@admin_jwt_payload, ShopifyAPI::Context.api_secret_key, "HS256")
97108

98109
modify_context(api_key: "invalid")
99110

@@ -103,7 +114,7 @@ def test_decode_jwt_payload_fails_with_invalid_api_key
103114
end
104115

105116
def test_decode_jwt_payload_succeeds_with_expiration_in_the_past_within_10s_leeway
106-
payload = @jwt_payload.merge(exp: Time.now.to_i - 8)
117+
payload = @admin_jwt_payload.merge(exp: Time.now.to_i - 8)
107118
jwt_token = JWT.encode(payload, ShopifyAPI::Context.api_secret_key, "HS256")
108119

109120
decoded = ShopifyAPI::Auth::JwtPayload.new(jwt_token)
@@ -122,7 +133,7 @@ def test_decode_jwt_payload_succeeds_with_expiration_in_the_past_within_10s_leew
122133
end
123134

124135
def test_decode_jwt_payload_succeeds_with_not_before_in_the_future_within_10s_leeway
125-
payload = @jwt_payload.merge(nbf: Time.now.to_i + 8)
136+
payload = @admin_jwt_payload.merge(nbf: Time.now.to_i + 8)
126137
jwt_token = JWT.encode(payload, ShopifyAPI::Context.api_secret_key, "HS256")
127138

128139
decoded = ShopifyAPI::Auth::JwtPayload.new(jwt_token)
@@ -139,6 +150,51 @@ def test_decode_jwt_payload_succeeds_with_not_before_in_the_future_within_10s_le
139150
sid: decoded.sid,
140151
})
141152
end
153+
154+
def test_decode_jwt_payload_coming_from_checkout_ui_extension
155+
payload = @checkout_ui_extension_jwt_payload.dup
156+
jwt_token = JWT.encode(payload, ShopifyAPI::Context.api_secret_key, "HS256")
157+
decoded = ShopifyAPI::Auth::JwtPayload.new(jwt_token)
158+
assert_equal(payload,
159+
{
160+
iss: decoded.iss,
161+
dest: decoded.dest,
162+
aud: decoded.aud,
163+
sub: decoded.sub,
164+
exp: decoded.exp,
165+
nbf: decoded.nbf,
166+
iat: decoded.iat,
167+
jti: decoded.jti,
168+
})
169+
170+
assert_equal(decoded.expire_at, @checkout_ui_extension_jwt_payload[:exp])
171+
assert_equal("test-shop.myshopify.io", decoded.shopify_domain)
172+
assert_equal("test-shop.myshopify.io", decoded.shop)
173+
assert_nil(decoded.shopify_user_id)
174+
end
175+
176+
def test_decode_jwt_payload_coming_from_checkout_ui_extension_without_user_logged_in
177+
payload = @checkout_ui_extension_jwt_payload.dup
178+
payload[:sub] = nil
179+
jwt_token = JWT.encode(payload, ShopifyAPI::Context.api_secret_key, "HS256")
180+
decoded = ShopifyAPI::Auth::JwtPayload.new(jwt_token)
181+
assert_equal(payload,
182+
{
183+
iss: decoded.iss,
184+
dest: decoded.dest,
185+
aud: decoded.aud,
186+
sub: decoded.sub,
187+
exp: decoded.exp,
188+
nbf: decoded.nbf,
189+
iat: decoded.iat,
190+
jti: decoded.jti,
191+
})
192+
193+
assert_equal(decoded.expire_at, @checkout_ui_extension_jwt_payload[:exp])
194+
assert_equal("test-shop.myshopify.io", decoded.shopify_domain)
195+
assert_equal("test-shop.myshopify.io", decoded.shop)
196+
assert_nil(decoded.shopify_user_id)
197+
end
142198
end
143199
end
144200
end

0 commit comments

Comments
 (0)