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 +4 -4
- data/.github/workflows/build.yml +1 -2
- data/BREAKING_CHANGES_FOR_V16.md +76 -0
- data/CHANGELOG.md +6 -1
- data/Gemfile.lock +1 -1
- data/docs/getting_started.md +2 -1
- data/docs/usage/oauth.md +129 -0
- data/lib/shopify_api/auth/oauth/access_token_response.rb +5 -1
- data/lib/shopify_api/auth/oauth.rb +7 -2
- data/lib/shopify_api/auth/refresh_token.rb +57 -0
- data/lib/shopify_api/auth/session.rb +36 -19
- data/lib/shopify_api/auth/token_exchange.rb +50 -0
- data/lib/shopify_api/clients/http_client.rb +2 -0
- data/lib/shopify_api/context.rb +8 -1
- data/lib/shopify_api/version.rb +1 -1
- data/shopify_api.gemspec +1 -1
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fcfda3eae9679802b0a7d174700dfbd87d43f31f2a06a94369f5466c52da9c25
|
|
4
|
+
data.tar.gz: cd43a51872ca579701c3592603c9a41b9662191a62420fdbe87e26cd71020e3f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 33c6187ea237123c293e5597e184f633ddcfedb7433f7d3ad14141fb4b401cd618264f2fe3143982141b03ba7ee00aad7ec08b26a0b489b6c00b836edc61a8ec
|
|
7
|
+
data.tar.gz: 251fea50aaa98a67b55797934744bd9a57599084740530535dd6c98d9a8f1f2f886617d3dd66a99c7f877c508ac2afbcf3cf18a52758dfb9b2262c0314d06c8e
|
data/.github/workflows/build.yml
CHANGED
|
@@ -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
|
-
##
|
|
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
data/docs/getting_started.md
CHANGED
|
@@ -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 = {
|
|
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.
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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]
|
data/lib/shopify_api/context.rb
CHANGED
|
@@ -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
|
data/lib/shopify_api/version.rb
CHANGED
data/shopify_api.gemspec
CHANGED
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:
|
|
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.
|
|
1446
|
+
version: '3.2'
|
|
1445
1447
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
1446
1448
|
requirements:
|
|
1447
1449
|
- - ">="
|