shopify_api 15.0.0 → 16.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9730739b6693ca317bd357162beef7ad7424a5f584eadc345e8b53308724cd27
4
- data.tar.gz: 5d4773a92f4f3dda6615b7e6e47a50ee96bfbb0b8015f4dbd6e519d851da119f
3
+ metadata.gz: fcfda3eae9679802b0a7d174700dfbd87d43f31f2a06a94369f5466c52da9c25
4
+ data.tar.gz: cd43a51872ca579701c3592603c9a41b9662191a62420fdbe87e26cd71020e3f
5
5
  SHA512:
6
- metadata.gz: c5133fc7f61b53d0ee91c38848dad3f0afdc92070276caac0ad911ad0d122039c509ed88afb9a35f8ead5ee0332fbc7caa0782012d856c8886f426409d2826cd
7
- data.tar.gz: 5f874a62238c6aef8f3383ed876104d895118a9e767f43143a4458327b40dce5ffd891ea05908737633bea8ba958840c49920e557090b9b05732611369a3ec8f
6
+ metadata.gz: 33c6187ea237123c293e5597e184f633ddcfedb7433f7d3ad14141fb4b401cd618264f2fe3143982141b03ba7ee00aad7ec08b26a0b489b6c00b836edc61a8ec
7
+ data.tar.gz: 251fea50aaa98a67b55797934744bd9a57599084740530535dd6c98d9a8f1f2f886617d3dd66a99c7f877c508ac2afbcf3cf18a52758dfb9b2262c0314d06c8e
@@ -11,10 +11,9 @@ jobs:
11
11
  strategy:
12
12
  matrix:
13
13
  version:
14
- - 3.0
15
- - 3.1
16
14
  - 3.2
17
15
  - 3.3
16
+ - 3.4
18
17
  steps:
19
18
  - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
20
19
  - name: Set up Ruby ${{ matrix.version }}
@@ -0,0 +1,76 @@
1
+ # Breaking change notice for version 16.0.0
2
+
3
+ ## Minimum Ruby Version Requirement
4
+
5
+ The minimum required Ruby version has been updated from 3.0 to 3.2.
6
+
7
+ ### Why this change?
8
+
9
+ Ruby 3.0 and 3.1 have reached End of Life (EOL).
10
+
11
+ ### Migration Guide
12
+
13
+ If you're currently using Ruby 3.0 or 3.1, you'll need to upgrade to Ruby 3.2 or higher before upgrading to shopify-api-ruby v16.0.0.
14
+
15
+ **Note:** Ruby 3.2+ includes performance improvements and new features. Most applications should not require code changes beyond updating the Ruby version itself.
16
+ ## Removal of `Session#serialize` and `Session.deserialize` methods
17
+
18
+ The `Session#serialize` and `Session.deserialize` methods have been removed due to a security vulnerability. The `deserialize` method used `Oj.load` without safe mode, which allows instantiation of arbitrary Ruby objects.
19
+
20
+ These methods were originally created for session persistence when the library handled session storage. After session storage was deprecated in v12.3.0, applications became responsible for their own session persistence, making these methods unnecessary for their original purpose.
21
+
22
+ ### Why this change?
23
+
24
+ **No impact on most applications:** The `shopify_app gem` stores individual session attributes in database columns and reconstructs sessions using `Session.new()`, which is the recommended pattern.
25
+
26
+ ## Migration Guide
27
+
28
+ If your application was using `Session#serialize` and `Session.deserialize` for session persistence, you'll need to refactor to store individual session attributes and reconstruct sessions using `Session.new()`.
29
+
30
+ ### Previous implementation (removed in v16.0.0)
31
+
32
+ ```ruby
33
+ # Storing a session
34
+ session = ShopifyAPI::Auth::Session.new(
35
+ shop: "example.myshopify.com",
36
+ access_token: "shpat_xxxxx",
37
+ scope: "read_products,write_orders"
38
+ )
39
+
40
+ serialized_data = session.serialize
41
+ # Store serialized_data in Redis, database, etc.
42
+ redis.set("session:#{session.id}", serialized_data)
43
+
44
+ # Retrieving a session
45
+ serialized_data = redis.get("session:#{session_id}")
46
+ session = ShopifyAPI::Auth::Session.deserialize(serialized_data)
47
+ ```
48
+
49
+ ### New implementation (required in v16.0.0)
50
+
51
+ Store individual session attributes and reconstruct using `Session.new()`:
52
+
53
+ ## Reference: shopify_app gem implementation
54
+
55
+ The [shopify_app gem](https://github.com/Shopify/shopify_app) provides a reference implementation of session storage that follows these best practices:
56
+
57
+ **Shop Session Storage** ([source](https://github.com/Shopify/shopify_app/blob/main/lib/shopify_app/session/shop_session_storage.rb)):
58
+ ```ruby
59
+ # Stores attributes in database columns
60
+ def store(auth_session)
61
+ shop = find_or_initialize_by(shopify_domain: auth_session.shop)
62
+ shop.shopify_token = auth_session.access_token
63
+ shop.save!
64
+ end
65
+
66
+ # Reconstructs using Session.new()
67
+ def retrieve(id)
68
+ shop = find_by(id: id)
69
+ return unless shop
70
+
71
+ ShopifyAPI::Auth::Session.new(
72
+ shop: shop.shopify_domain,
73
+ access_token: shop.shopify_token
74
+ )
75
+ end
76
+ ```
data/CHANGELOG.md CHANGED
@@ -1,7 +1,12 @@
1
1
  # Changelog
2
2
 
3
3
  Note: For changes to the API, see https://shopify.dev/changelog?filter=api
4
- ## Unreleased
4
+ ## 16.0.0 (2025-12-10)
5
+ - ⚠️ [Breaking] Minimum required Ruby version is now 3.2. Ruby 3.0 and 3.1 are no longer supported.
6
+ - ⚠️ [Breaking] Removed `Session#serialize` and `Session.deserialize` methods due to security concerns (RCE vulnerability via `Oj.load`). These methods were not used internally by the library. If your application relies on session serialization, use `Session.new()` to reconstruct sessions from stored attributes instead.
7
+
8
+ - Add support for expiring offline access tokens with refresh tokens. See [OAuth documentation](docs/usage/oauth.md#expiring-offline-access-tokens) for details.
9
+ - Add `ShopifyAPI::Auth::TokenExchange.migrate_to_expiring_token` method to migrate existing non-expiring offline tokens to expiring tokens. See [migration documentation](docs/usage/oauth.md#migrating-non-expiring-tokens-to-expiring-tokens) for details.
5
10
 
6
11
  ### 15.0.0
7
12
 
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- shopify_api (15.0.0)
4
+ shopify_api (16.0.0)
5
5
  activesupport
6
6
  concurrent-ruby
7
7
  hash_diff
@@ -28,7 +28,8 @@ ShopifyAPI::Context.setup(
28
28
  scope: "read_orders,read_products,etc",
29
29
  is_embedded: true, # Set to true if you are building an embedded app
30
30
  is_private: false, # Set to true if you are building a private app
31
- api_version: "2021-01" # The version of the API you would like to use
31
+ api_version: "2021-01", # The version of the API you would like to use
32
+ expiring_offline_access_tokens: true # Set to true to enable expiring offline access tokens with refresh tokens
32
33
  )
33
34
  ```
34
35
 
data/docs/usage/oauth.md CHANGED
@@ -13,6 +13,9 @@ For more information on authenticating a Shopify app please see the [Types of Au
13
13
  - [Token Exchange](#token-exchange)
14
14
  - [Authorization Code Grant](#authorization-code-grant)
15
15
  - [Client Credentials Grant](#client-credentials-grant)
16
+ - [Expiring Offline Access Tokens](#expiring-offline-access-tokens)
17
+ - [Refreshing Access Tokens](#refreshing-access-tokens)
18
+ - [Migrating Non-Expiring Tokens to Expiring Tokens](#migrating-non-expiring-tokens-to-expiring-tokens)
16
19
  - [Using OAuth Session to make authenticated API calls](#using-oauth-session-to-make-authenticated-api-calls)
17
20
 
18
21
  ## Session Persistence
@@ -305,6 +308,132 @@ end
305
308
 
306
309
  ```
307
310
 
311
+ ## Expiring Offline Access Tokens
312
+
313
+
314
+ To start requesting expiring offline access tokens, set the `expiring_offline_access_tokens` parameter to `true` when setting up the Shopify context:
315
+
316
+ ```ruby
317
+ ShopifyAPI::Context.setup(
318
+ api_key: <SHOPIFY_API_KEY>,
319
+ api_secret_key: <SHOPIFY_API_SECRET>,
320
+ api_version: <SHOPIFY_API_VERSION>,
321
+ scope: <SHOPIFY_API_SCOPES>,
322
+ expiring_offline_access_tokens: true, # Enable expiring offline access tokens
323
+ ...
324
+ )
325
+ ```
326
+
327
+ When enabled:
328
+ - **Authorization Code Grant**: The OAuth flow will request expiring offline access tokens by sending `expiring: 1` parameter
329
+ - **Token Exchange**: When requesting offline access tokens via token exchange, the flow will request expiring tokens
330
+
331
+ The resulting `Session` object will contain:
332
+ - `access_token`: The access token that will eventually expire
333
+ - `expires`: The expiration time for the access token
334
+ - `refresh_token`: A token that can be used to refresh the access token
335
+ - `refresh_token_expires`: The expiration time for the refresh token
336
+
337
+ ### Refreshing Access Tokens
338
+
339
+ When your access token expires, you can use the refresh token to obtain a new access token using the `ShopifyAPI::Auth::RefreshToken.refresh_access_token` method.
340
+
341
+ #### Input
342
+ | Parameter | Type | Required? | Notes |
343
+ | -------------- | -------- | :-------: | ----------------------------------------------------------------------------------------------- |
344
+ | `shop` | `String` | Yes | A Shopify domain name in the form `{exampleshop}.myshopify.com`. |
345
+ | `refresh_token`| `String` | Yes | The refresh token from the session. |
346
+
347
+ #### Output
348
+ This method returns a new `ShopifyAPI::Auth::Session` object with a fresh access token and a new refresh token. Your app should store this new session to replace the expired one.
349
+
350
+ #### Example
351
+ ```ruby
352
+ def refresh_session(shop, refresh_token)
353
+ begin
354
+ # Refresh the access token using the refresh token
355
+ new_session = ShopifyAPI::Auth::RefreshToken.refresh_access_token(
356
+ shop: shop,
357
+ refresh_token: refresh_token
358
+ )
359
+
360
+ # Store the new session, replacing the old one
361
+ MyApp::SessionRepository.store_shop_session(new_session)
362
+ rescue ShopifyAPI::Errors::HttpResponseError => e
363
+ puts("Failed to refresh access token: #{e.message}")
364
+ raise e
365
+ end
366
+ end
367
+ ```
368
+ #### Checking Token Expiration
369
+ The `Session` object provides helper methods to check if tokens have expired:
370
+
371
+ ```ruby
372
+ session = MyApp::SessionRepository.retrieve_shop_session_by_shopify_domain(shop)
373
+
374
+ # Check if the access token has expired
375
+ if session.expired?
376
+ # Access token has expired, refresh it
377
+ new_session = ShopifyAPI::Auth::RefreshToken.refresh_access_token(
378
+ shop: session.shop,
379
+ refresh_token: session.refresh_token
380
+ )
381
+ MyApp::SessionRepository.store_shop_session(new_session)
382
+ end
383
+
384
+ # Check if the refresh token has expired
385
+ if session.refresh_token_expired?
386
+ # Refresh token has expired, need to re-authenticate with OAuth
387
+ end
388
+ ```
389
+
390
+ ### Migrating Non-Expiring Tokens to Expiring Tokens
391
+
392
+ If you have existing non-expiring offline access tokens and want to migrate them to expiring tokens, you can use the `ShopifyAPI::Auth::TokenExchange.migrate_to_expiring_token` method. This performs a token exchange that converts your non-expiring offline token into an expiring one with a refresh token.
393
+
394
+ > [!WARNING]
395
+ > This is a **one-time, irreversible migration** per shop. Once you migrate a shop's token to an expiring token, you cannot convert it back to a non-expiring token. The shop would need to reinstall your app with `expiring_offline_access_tokens: false` in your Context configuration to obtain a new non-expiring token.
396
+
397
+ #### Input
398
+ | Parameter | Type | Required? | Notes |
399
+ | -------------- | -------- | :-------: | ----------------------------------------------------------------------------------------------- |
400
+ | `shop` | `String` | Yes | A Shopify domain name in the form `{exampleshop}.myshopify.com`. |
401
+ | `non_expiring_offline_token` | `String` | Yes | The non-expiring offline access token to migrate. |
402
+
403
+ #### Output
404
+ This method returns a new `ShopifyAPI::Auth::Session` object with an expiring access token and refresh token. Your app should store this new session to replace the non-expiring one.
405
+
406
+ #### Example
407
+ ```ruby
408
+ def migrate_shop_to_expiring_offline_token(shop)
409
+ # Retrieve the existing non-expiring session
410
+ old_session = MyApp::SessionRepository.retrieve_shop_session_by_shopify_domain(shop)
411
+
412
+ # Migrate to expiring token
413
+ new_session = ShopifyAPI::Auth::TokenExchange.migrate_to_expiring_token(
414
+ shop: shop,
415
+ non_expiring_offline_token: old_session.access_token
416
+ )
417
+
418
+ # Store the new expiring session, replacing the old one
419
+ MyApp::SessionRepository.store_shop_session(new_session)
420
+ end
421
+ ```
422
+
423
+ #### Migration Strategy
424
+ When migrating your app to use expiring tokens, follow this order:
425
+
426
+ 1. **Update your database schema** to add `expires_at` (timestamp), `refresh_token` (string) and `refresh_token_expires` (timestamp) columns to your session storage
427
+ 2. **Implement refresh logic** in your app to handle token expiration using `ShopifyAPI::Auth::RefreshToken.refresh_access_token`
428
+ 3. **Enable expiring tokens in your Context setup** so new installations will request and receive expiring tokens:
429
+ ```ruby
430
+ ShopifyAPI::Context.setup(
431
+ expiring_offline_access_tokens: true,
432
+ # ... other config
433
+ )
434
+ ```
435
+ 4. **Migrate existing non-expiring tokens** for shops that have already installed your app using the migration method above
436
+
308
437
  ## Using OAuth Session to make authenticated API calls
309
438
  Once your OAuth flow is complete, and you have persisted your `Session` object, you may use that `Session` object to make authenticated API calls.
310
439
 
@@ -13,6 +13,8 @@ module ShopifyAPI
13
13
  const :expires_in, T.nilable(Integer)
14
14
  const :associated_user, T.nilable(AssociatedUser)
15
15
  const :associated_user_scope, T.nilable(String)
16
+ const :refresh_token, T.nilable(String)
17
+ const :refresh_token_expires_in, T.nilable(Integer)
16
18
 
17
19
  sig { returns(T::Boolean) }
18
20
  def online_token?
@@ -29,7 +31,9 @@ module ShopifyAPI
29
31
  session == other.session &&
30
32
  expires_in == other.expires_in &&
31
33
  associated_user == other.associated_user &&
32
- associated_user_scope == other.associated_user_scope
34
+ associated_user_scope == other.associated_user_scope &&
35
+ refresh_token == other.refresh_token &&
36
+ refresh_token_expires_in == other.refresh_token_expires_in
33
37
  end
34
38
  end
35
39
  end
@@ -71,7 +71,12 @@ module ShopifyAPI
71
71
  "Invalid state in OAuth callback." unless state == auth_query.state
72
72
 
73
73
  null_session = Auth::Session.new(shop: auth_query.shop)
74
- body = { client_id: Context.api_key, client_secret: Context.api_secret_key, code: auth_query.code }
74
+ body = {
75
+ client_id: Context.api_key,
76
+ client_secret: Context.api_secret_key,
77
+ code: auth_query.code,
78
+ expiring: Context.expiring_offline_access_tokens ? 1 : 0, # Only applicable for offline tokens
79
+ }
75
80
 
76
81
  client = Clients::HttpClient.new(session: null_session, base_path: "/admin/oauth")
77
82
  response = begin
@@ -100,7 +105,7 @@ module ShopifyAPI
100
105
  else
101
106
  SessionCookie.new(
102
107
  value: session.id,
103
- expires: session.online? ? session.expires : nil,
108
+ expires: session.expires ? session.expires : nil,
104
109
  )
105
110
  end
106
111
 
@@ -0,0 +1,57 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module ShopifyAPI
5
+ module Auth
6
+ module RefreshToken
7
+ extend T::Sig
8
+
9
+ class << self
10
+ extend T::Sig
11
+
12
+ sig do
13
+ params(
14
+ shop: String,
15
+ refresh_token: String,
16
+ ).returns(ShopifyAPI::Auth::Session)
17
+ end
18
+ def refresh_access_token(shop:, refresh_token:)
19
+ unless ShopifyAPI::Context.setup?
20
+ raise ShopifyAPI::Errors::ContextNotSetupError,
21
+ "ShopifyAPI::Context not setup, please call ShopifyAPI::Context.setup"
22
+ end
23
+
24
+ shop_session = ShopifyAPI::Auth::Session.new(shop:)
25
+ body = {
26
+ client_id: ShopifyAPI::Context.api_key,
27
+ client_secret: ShopifyAPI::Context.api_secret_key,
28
+ grant_type: "refresh_token",
29
+ refresh_token:,
30
+ }
31
+
32
+ client = Clients::HttpClient.new(session: shop_session, base_path: "/admin/oauth")
33
+ response = begin
34
+ client.request(
35
+ Clients::HttpRequest.new(
36
+ http_method: :post,
37
+ path: "access_token",
38
+ body:,
39
+ body_type: "application/json",
40
+ ),
41
+ )
42
+ rescue ShopifyAPI::Errors::HttpResponseError => error
43
+ ShopifyAPI::Context.logger.debug("Failed to refresh access token: #{error.message}")
44
+ raise error
45
+ end
46
+
47
+ session_params = T.cast(response.body, T::Hash[String, T.untyped]).to_h
48
+
49
+ Session.from(
50
+ shop:,
51
+ access_token_response: Oauth::AccessTokenResponse.from_hash(session_params),
52
+ )
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -30,6 +30,12 @@ module ShopifyAPI
30
30
  sig { returns(T.nilable(String)) }
31
31
  attr_accessor :shopify_session_id
32
32
 
33
+ sig { returns(T.nilable(String)) }
34
+ attr_accessor :refresh_token
35
+
36
+ sig { returns(T.nilable(Time)) }
37
+ attr_accessor :refresh_token_expires
38
+
33
39
  sig { returns(T::Boolean) }
34
40
  def online?
35
41
  @is_online
@@ -40,6 +46,11 @@ module ShopifyAPI
40
46
  @expires ? @expires < Time.now : false
41
47
  end
42
48
 
49
+ sig { returns(T::Boolean) }
50
+ def refresh_token_expired?
51
+ @refresh_token_expires ? @refresh_token_expires < Time.now + 60 : false
52
+ end
53
+
43
54
  sig do
44
55
  params(
45
56
  shop: String,
@@ -52,10 +63,12 @@ module ShopifyAPI
52
63
  is_online: T.nilable(T::Boolean),
53
64
  associated_user: T.nilable(AssociatedUser),
54
65
  shopify_session_id: T.nilable(String),
66
+ refresh_token: T.nilable(String),
67
+ refresh_token_expires: T.nilable(Time),
55
68
  ).void
56
69
  end
57
70
  def initialize(shop:, id: nil, state: nil, access_token: "", scope: [], associated_user_scope: nil, expires: nil,
58
- is_online: nil, associated_user: nil, shopify_session_id: nil)
71
+ is_online: nil, associated_user: nil, shopify_session_id: nil, refresh_token: nil, refresh_token_expires: nil)
59
72
  @id = T.let(id || SecureRandom.uuid, String)
60
73
  @shop = shop
61
74
  @state = state
@@ -68,6 +81,8 @@ module ShopifyAPI
68
81
  @associated_user = associated_user
69
82
  @is_online = T.let(is_online || !associated_user.nil?, T::Boolean)
70
83
  @shopify_session_id = shopify_session_id
84
+ @refresh_token = refresh_token
85
+ @refresh_token_expires = refresh_token_expires
71
86
  end
72
87
 
73
88
  class << self
@@ -105,6 +120,10 @@ module ShopifyAPI
105
120
  expires = Time.now + access_token_response.expires_in.to_i
106
121
  end
107
122
 
123
+ if access_token_response.refresh_token_expires_in
124
+ refresh_token_expires = Time.now + access_token_response.refresh_token_expires_in.to_i
125
+ end
126
+
108
127
  new(
109
128
  id: id,
110
129
  shop: shop,
@@ -115,31 +134,28 @@ module ShopifyAPI
115
134
  associated_user: associated_user,
116
135
  expires: expires,
117
136
  shopify_session_id: access_token_response.session,
137
+ refresh_token: access_token_response.refresh_token,
138
+ refresh_token_expires: refresh_token_expires,
118
139
  )
119
140
  end
120
-
121
- sig { params(str: String).returns(Session) }
122
- def deserialize(str)
123
- Oj.load(str)
124
- end
125
141
  end
126
142
 
127
143
  sig { params(other: Session).returns(Session) }
128
144
  def copy_attributes_from(other)
129
- JSON.parse(other.serialize).keys.each do |key|
130
- next if key.include?("^")
131
-
132
- variable_name = "@#{key}"
133
- instance_variable_set(variable_name, other.instance_variable_get(variable_name))
134
- end
145
+ @shop = other.shop
146
+ @state = other.state
147
+ @access_token = other.access_token
148
+ @scope = other.scope
149
+ @associated_user_scope = other.associated_user_scope
150
+ @expires = other.expires
151
+ @associated_user = other.associated_user
152
+ @is_online = other.online?
153
+ @shopify_session_id = other.shopify_session_id
154
+ @refresh_token = other.refresh_token
155
+ @refresh_token_expires = other.refresh_token_expires
135
156
  self
136
157
  end
137
158
 
138
- sig { returns(String) }
139
- def serialize
140
- Oj.dump(self)
141
- end
142
-
143
159
  alias_method :eql?, :==
144
160
  sig { params(other: T.nilable(Session)).returns(T::Boolean) }
145
161
  def ==(other)
@@ -153,8 +169,9 @@ module ShopifyAPI
153
169
  (!(expires.nil? ^ other.expires.nil?) && (expires.nil? || expires.to_i == other.expires.to_i)) &&
154
170
  online? == other.online? &&
155
171
  associated_user == other.associated_user &&
156
- shopify_session_id == other.shopify_session_id
157
-
172
+ shopify_session_id == other.shopify_session_id &&
173
+ refresh_token == other.refresh_token &&
174
+ refresh_token_expires&.to_i == other.refresh_token_expires&.to_i
158
175
  else
159
176
  false
160
177
  end
@@ -49,6 +49,10 @@ module ShopifyAPI
49
49
  requested_token_type: requested_token_type.serialize,
50
50
  }
51
51
 
52
+ if requested_token_type == RequestedTokenType::OFFLINE_ACCESS_TOKEN
53
+ body.merge!({ expiring: ShopifyAPI::Context.expiring_offline_access_tokens ? 1 : 0 })
54
+ end
55
+
52
56
  client = Clients::HttpClient.new(session: shop_session, base_path: "/admin/oauth")
53
57
  response = begin
54
58
  client.request(
@@ -74,6 +78,52 @@ module ShopifyAPI
74
78
  access_token_response: Oauth::AccessTokenResponse.from_hash(session_params),
75
79
  )
76
80
  end
81
+
82
+ sig do
83
+ params(
84
+ shop: String,
85
+ non_expiring_offline_token: String,
86
+ ).returns(ShopifyAPI::Auth::Session)
87
+ end
88
+ def migrate_to_expiring_token(shop:, non_expiring_offline_token:)
89
+ unless ShopifyAPI::Context.setup?
90
+ raise ShopifyAPI::Errors::ContextNotSetupError,
91
+ "ShopifyAPI::Context not setup, please call ShopifyAPI::Context.setup"
92
+ end
93
+
94
+ shop_session = ShopifyAPI::Auth::Session.new(shop: shop)
95
+ body = {
96
+ client_id: ShopifyAPI::Context.api_key,
97
+ client_secret: ShopifyAPI::Context.api_secret_key,
98
+ grant_type: TOKEN_EXCHANGE_GRANT_TYPE,
99
+ subject_token: non_expiring_offline_token,
100
+ subject_token_type: RequestedTokenType::OFFLINE_ACCESS_TOKEN.serialize,
101
+ requested_token_type: RequestedTokenType::OFFLINE_ACCESS_TOKEN.serialize,
102
+ expiring: "1",
103
+ }
104
+
105
+ client = Clients::HttpClient.new(session: shop_session, base_path: "/admin/oauth")
106
+ response = begin
107
+ client.request(
108
+ Clients::HttpRequest.new(
109
+ http_method: :post,
110
+ path: "access_token",
111
+ body: body,
112
+ body_type: "application/json",
113
+ ),
114
+ )
115
+ rescue ShopifyAPI::Errors::HttpResponseError => error
116
+ ShopifyAPI::Context.logger.debug("Failed to migrate to expiring offline token: #{error.message}")
117
+ raise error
118
+ end
119
+
120
+ session_params = T.cast(response.body, T::Hash[String, T.untyped]).to_h
121
+
122
+ Session.from(
123
+ shop: shop,
124
+ access_token_response: Oauth::AccessTokenResponse.from_hash(session_params),
125
+ )
126
+ end
77
127
  end
78
128
  end
79
129
  end
@@ -112,6 +112,8 @@ module ShopifyAPI
112
112
  def serialized_error(response)
113
113
  body = {}
114
114
  body["errors"] = response.body["errors"] if response.body["errors"]
115
+ body["error"] = response.body["error"] if response.body["error"]
116
+ body["error_description"] = response.body["error_description"] if response.body["error"]
115
117
 
116
118
  if response.headers["x-request-id"]
117
119
  id = T.must(response.headers["x-request-id"])[0]
@@ -25,6 +25,7 @@ module ShopifyAPI
25
25
  @rest_disabled = T.let(false, T.nilable(T::Boolean))
26
26
 
27
27
  @rest_resource_loader = T.let(nil, T.nilable(Zeitwerk::Loader))
28
+ @expiring_offline_access_tokens = T.let(false, T::Boolean)
28
29
 
29
30
  class << self
30
31
  extend T::Sig
@@ -47,6 +48,7 @@ module ShopifyAPI
47
48
  api_host: T.nilable(String),
48
49
  response_as_struct: T.nilable(T::Boolean),
49
50
  rest_disabled: T.nilable(T::Boolean),
51
+ expiring_offline_access_tokens: T.nilable(T::Boolean),
50
52
  ).void
51
53
  end
52
54
  def setup(
@@ -65,7 +67,8 @@ module ShopifyAPI
65
67
  old_api_secret_key: nil,
66
68
  api_host: nil,
67
69
  response_as_struct: false,
68
- rest_disabled: false
70
+ rest_disabled: false,
71
+ expiring_offline_access_tokens: false
69
72
  )
70
73
  unless ShopifyAPI::AdminVersions::SUPPORTED_ADMIN_VERSIONS.include?(api_version)
71
74
  raise Errors::UnsupportedVersionError,
@@ -86,6 +89,7 @@ module ShopifyAPI
86
89
  @old_api_secret_key = old_api_secret_key
87
90
  @response_as_struct = response_as_struct
88
91
  @rest_disabled = rest_disabled
92
+ @expiring_offline_access_tokens = T.must(expiring_offline_access_tokens)
89
93
  @log_level = if valid_log_level?(log_level)
90
94
  log_level.to_sym
91
95
  else
@@ -148,6 +152,9 @@ module ShopifyAPI
148
152
  sig { returns(T.nilable(String)) }
149
153
  attr_reader :private_shop, :user_agent_prefix, :old_api_secret_key, :host, :api_host
150
154
 
155
+ sig { returns(T::Boolean) }
156
+ attr_reader :expiring_offline_access_tokens
157
+
151
158
  sig { returns(T::Boolean) }
152
159
  def embedded?
153
160
  @is_embedded
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module ShopifyAPI
5
- VERSION = "15.0.0"
5
+ VERSION = "16.0.0"
6
6
  end
data/shopify_api.gemspec CHANGED
@@ -30,7 +30,7 @@ Gem::Specification.new do |s|
30
30
 
31
31
  s.license = "MIT"
32
32
 
33
- s.required_ruby_version = ">= 3.0"
33
+ s.required_ruby_version = ">= 3.2"
34
34
 
35
35
  s.add_runtime_dependency("activesupport")
36
36
  s.add_runtime_dependency("concurrent-ruby")
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: shopify_api
3
3
  version: !ruby/object:Gem::Version
4
- version: 15.0.0
4
+ version: 16.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shopify
@@ -178,6 +178,7 @@ files:
178
178
  - BREAKING_CHANGES_FOR_OLDER_VERSIONS.md
179
179
  - BREAKING_CHANGES_FOR_V10.md
180
180
  - BREAKING_CHANGES_FOR_V15.md
181
+ - BREAKING_CHANGES_FOR_V16.md
181
182
  - CHANGELOG.md
182
183
  - CODE_OF_CONDUCT.md
183
184
  - CONTRIBUTING.md
@@ -211,6 +212,7 @@ files:
211
212
  - lib/shopify_api/auth/oauth/access_token_response.rb
212
213
  - lib/shopify_api/auth/oauth/auth_query.rb
213
214
  - lib/shopify_api/auth/oauth/session_cookie.rb
215
+ - lib/shopify_api/auth/refresh_token.rb
214
216
  - lib/shopify_api/auth/session.rb
215
217
  - lib/shopify_api/auth/token_exchange.rb
216
218
  - lib/shopify_api/clients/graphql/admin.rb
@@ -1441,7 +1443,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
1441
1443
  requirements:
1442
1444
  - - ">="
1443
1445
  - !ruby/object:Gem::Version
1444
- version: '3.0'
1446
+ version: '3.2'
1445
1447
  required_rubygems_version: !ruby/object:Gem::Requirement
1446
1448
  requirements:
1447
1449
  - - ">="