Skip to content

Commit 0d89f9b

Browse files
authored
Merge pull request #1423 from Shopify/zl/add_expiring_token_support
[Can't merge yet] Add expiring offline access token support
2 parents ffa5573 + a1eefae commit 0d89f9b

18 files changed

+1091
-18
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
- ⚠️ [Breaking] Minimum required Ruby version is now 3.2. Ruby 3.0 and 3.1 are no longer supported.
66
- ⚠️ [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.
77

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.
10+
811
### 15.0.0
912

1013
- ⚠️ [Breaking] Removed deprecated `ShopifyAPI::Webhooks::Handler` interface. Apps must migrate to `ShopifyAPI::Webhooks::WebhookHandler` which provides `webhook_id` and `api_version` in addition to `topic`, `shop`, and `body`. See [BREAKING_CHANGES_FOR_V15.md](BREAKING_CHANGES_FOR_V15.md) for migration guide.

docs/getting_started.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ ShopifyAPI::Context.setup(
2828
scope: "read_orders,read_products,etc",
2929
is_embedded: true, # Set to true if you are building an embedded app
3030
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
3233
)
3334
```
3435

docs/usage/oauth.md

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ For more information on authenticating a Shopify app please see the [Types of Au
1313
- [Token Exchange](#token-exchange)
1414
- [Authorization Code Grant](#authorization-code-grant)
1515
- [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)
1619
- [Using OAuth Session to make authenticated API calls](#using-oauth-session-to-make-authenticated-api-calls)
1720

1821
## Session Persistence
@@ -305,6 +308,132 @@ end
305308

306309
```
307310

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+
308437
## Using OAuth Session to make authenticated API calls
309438
Once your OAuth flow is complete, and you have persisted your `Session` object, you may use that `Session` object to make authenticated API calls.
310439

lib/shopify_api/auth/oauth.rb

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,12 @@ def validate_auth_callback(cookies:, auth_query:)
7171
"Invalid state in OAuth callback." unless state == auth_query.state
7272

7373
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+
}
7580

7681
client = Clients::HttpClient.new(session: null_session, base_path: "/admin/oauth")
7782
response = begin
@@ -100,7 +105,7 @@ def validate_auth_callback(cookies:, auth_query:)
100105
else
101106
SessionCookie.new(
102107
value: session.id,
103-
expires: session.online? ? session.expires : nil,
108+
expires: session.expires ? session.expires : nil,
104109
)
105110
end
106111

lib/shopify_api/auth/oauth/access_token_response.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ class AccessTokenResponse < T::Struct
1313
const :expires_in, T.nilable(Integer)
1414
const :associated_user, T.nilable(AssociatedUser)
1515
const :associated_user_scope, T.nilable(String)
16+
const :refresh_token, T.nilable(String)
17+
const :refresh_token_expires_in, T.nilable(Integer)
1618

1719
sig { returns(T::Boolean) }
1820
def online_token?
@@ -29,7 +31,9 @@ def ==(other)
2931
session == other.session &&
3032
expires_in == other.expires_in &&
3133
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
3337
end
3438
end
3539
end
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

lib/shopify_api/auth/session.rb

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ class Session
3030
sig { returns(T.nilable(String)) }
3131
attr_accessor :shopify_session_id
3232

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+
3339
sig { returns(T::Boolean) }
3440
def online?
3541
@is_online
@@ -40,6 +46,11 @@ def expired?
4046
@expires ? @expires < Time.now : false
4147
end
4248

49+
sig { returns(T::Boolean) }
50+
def refresh_token_expired?
51+
@refresh_token_expires ? @refresh_token_expires < Time.now + 60 : false
52+
end
53+
4354
sig do
4455
params(
4556
shop: String,
@@ -52,10 +63,12 @@ def expired?
5263
is_online: T.nilable(T::Boolean),
5364
associated_user: T.nilable(AssociatedUser),
5465
shopify_session_id: T.nilable(String),
66+
refresh_token: T.nilable(String),
67+
refresh_token_expires: T.nilable(Time),
5568
).void
5669
end
5770
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)
5972
@id = T.let(id || SecureRandom.uuid, String)
6073
@shop = shop
6174
@state = state
@@ -68,6 +81,8 @@ def initialize(shop:, id: nil, state: nil, access_token: "", scope: [], associat
6881
@associated_user = associated_user
6982
@is_online = T.let(is_online || !associated_user.nil?, T::Boolean)
7083
@shopify_session_id = shopify_session_id
84+
@refresh_token = refresh_token
85+
@refresh_token_expires = refresh_token_expires
7186
end
7287

7388
class << self
@@ -105,6 +120,10 @@ def from(shop:, access_token_response:)
105120
expires = Time.now + access_token_response.expires_in.to_i
106121
end
107122

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+
108127
new(
109128
id: id,
110129
shop: shop,
@@ -115,6 +134,8 @@ def from(shop:, access_token_response:)
115134
associated_user: associated_user,
116135
expires: expires,
117136
shopify_session_id: access_token_response.session,
137+
refresh_token: access_token_response.refresh_token,
138+
refresh_token_expires: refresh_token_expires,
118139
)
119140
end
120141
end
@@ -130,6 +151,8 @@ def copy_attributes_from(other)
130151
@associated_user = other.associated_user
131152
@is_online = other.online?
132153
@shopify_session_id = other.shopify_session_id
154+
@refresh_token = other.refresh_token
155+
@refresh_token_expires = other.refresh_token_expires
133156
self
134157
end
135158

@@ -146,8 +169,9 @@ def ==(other)
146169
(!(expires.nil? ^ other.expires.nil?) && (expires.nil? || expires.to_i == other.expires.to_i)) &&
147170
online? == other.online? &&
148171
associated_user == other.associated_user &&
149-
shopify_session_id == other.shopify_session_id
150-
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
151175
else
152176
false
153177
end

0 commit comments

Comments
 (0)