Skip to content

Commit b7df56e

Browse files
authored
Merge pull request #2027 from Shopify/zl/add_expiring_offline_token_support
Add expiring offline token support
2 parents d5cffaa + 4be299c commit b7df56e

File tree

18 files changed

+1009
-37
lines changed

18 files changed

+1009
-37
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ Unreleased
99
- ⚠️ [Breaking] Removes `ShopifyApp::JWTMiddleware` and `ShopifyApp::JWT` See [Upgrading](/docs/Upgrading.md) for more migration. [1960](https://github.com/Shopify/shopify_app/pull/1960)
1010
- ⚠️ [Breaking] Removed deprecated `CallbackController` methods. `perform_after_authenticate_job`, `install_webhooks`, and `perform_post_authenticate_jobs` have been removed. [#1961](https://github.com/Shopify/shopify_app/pull/1961)
1111
- ⚠️ [Breaking] Bumps minimum supported Ruby version to 3.1 [#1959](https://github.com/Shopify/shopify_app/pull/1959)
12+
- Adds support for expiring offline access tokens with automatic refresh for `ShopSessionStorage`. Apps can now opt-in to expiring offline tokens via `ShopifyAPI::Context.setup(offline_access_token_expires: true)`, and `ShopSessionStorage` will automatically refresh expired tokens when using `with_shopify_session`. **See [migration guide](/docs/shopify_app/sessions.md#migrating-to-expiring-offline-access-tokens) for setup instructions.** [#2027](https://github.com/Shopify/shopify_app/pull/2027)
13+
- `ShopSessionStorage` now automatically stores `access_scopes`, `expires_at`, `refresh_token`, and `refresh_token_expires_at` from auth sessions
14+
- `UserSessionStorage` now automatically stores `access_scopes` and `expires_at` from auth sessions (refresh tokens not applicable for online/user tokens)
15+
- Adds `refresh_token_if_expired!` public method to `ShopSessionStorage` for manual token refresh
16+
- Adds `RefreshTokenExpiredError` exception raised when refresh token itself is expired
17+
- Mark deprecation for `ShopSessionStorageWithScopes` and `UserSessionStorageWithScopes` in favor of `ShopSessionStorage` and `UserSessionStorage`.
1218
- Adds a `script_tag_manager` that will automatically create script tags when the app is installed. [1948](https://github.com/Shopify/shopify_app/pull/1948)
1319
- Handle invalid token when adding redirection headers [#1945](https://github.com/Shopify/shopify_app/pull/1945)
1420
- Handle invalid record error for concurrent token exchange calls [#1966](https://github.com/Shopify/shopify_app/pull/1966)

README.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,6 @@ You can find documentation on gem usage, concepts, mixins, installation, and mor
189189
* [Controller Concerns](/docs/shopify_app/controller-concerns.md)
190190
* [Generators](/docs/shopify_app/generators.md)
191191
* [Sessions](/docs/shopify_app/sessions.md)
192-
* [Handling changes in access scopes](/docs/shopify_app/handling-access-scopes-changes.md)
193192
* [Testing](/docs/shopify_app/testing.md)
194193
* [Webhooks](/docs/shopify_app/webhooks.md)
195194
* [Content Security Policy](/docs/shopify_app/content-security-policy.md)
@@ -243,8 +242,7 @@ ShopifyApp.configure do |config|
243242
config.embedded_app = true
244243
config.new_embedded_auth_strategy = true
245244

246-
# If your app is configured to use online sessions, you can enable session expiry date check so a new access token
247-
# is fetched automatically when the session expires.
245+
# You can enable session expiry date check so a new access token is fetched automatically when the session expires.
248246
# See expiry date check docs: https://github.com/Shopify/shopify_app/blob/main/docs/shopify_app/sessions.md#expiry-date
249247
config.check_session_expiry_date = true
250248
...

docs/Upgrading.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,42 @@ require 'shopify_app/jobs/webhooks_manager_job'
8080

8181
- **sprockets-rails**: Now a required runtime dependency. Most Rails apps already include this, but if your app uses an alternative asset pipeline (e.g., Propshaft), you may need to add `sprockets-rails` to your Gemfile.
8282

83+
#### (v23.0.0) - ShopSessionStorageWithScopes and UserSessionStorageWithScopes are deprecated
84+
85+
`ShopSessionStorageWithScopes` and `UserSessionStorageWithScopes` are now marked as deprecated and will be removed in v24.0.0 in favor of `ShopSessionStorage` and `UserSessionStorage`, which handle all session attributes automatically (including `access_scopes`, `expires_at`, `refresh_token`, and `refresh_token_expires_at` for shops).
86+
87+
**Migration:**
88+
89+
1. Update your Shop model to use `ShopSessionStorage`:
90+
```ruby
91+
# Before
92+
class Shop < ActiveRecord::Base
93+
include ShopifyApp::ShopSessionStorageWithScopes
94+
end
95+
96+
# After
97+
class Shop < ActiveRecord::Base
98+
include ShopifyApp::ShopSessionStorage
99+
end
100+
```
101+
102+
2. Update your User model to use `UserSessionStorage`:
103+
```ruby
104+
# Before
105+
class User < ActiveRecord::Base
106+
include ShopifyApp::UserSessionStorageWithScopes
107+
end
108+
109+
# After
110+
class User < ActiveRecord::Base
111+
include ShopifyApp::UserSessionStorage
112+
end
113+
```
114+
115+
3. **Optional:** You can now opt-in to using expiring offline access tokens with automatic refresh. See the [Sessions documentation](/docs/shopify_app/sessions.md#offline-access-tokens) for setup instructions.
116+
117+
**Note:** If you had custom `access_scopes=` or `access_scopes` methods in your models, these are no longer needed. The base concerns now handle these attributes automatically.
118+
83119
#### (v23.0.0) - Deprecated methods in CallbackController
84120
The following methods from `ShopifyApp::CallbackController` have been deprecated in `v23.0.0`
85121
- `perform_after_authenticate_job`

docs/shopify_app/sessions.md

Lines changed: 149 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Sessions are used to make contextual API calls for either a shop (offline sessio
2323
- [Access scopes](#access-scopes)
2424
- [`ShopifyApp::ShopSessionStorageWithScopes`](#shopifyappshopsessionstoragewithscopes)
2525
- [`ShopifyApp::UserSessionStorageWithScopes`](#shopifyappusersessionstoragewithscopes)
26+
- [Migrating to Expiring Offline Access Tokens](#migrating-to-expiring-offline-access-tokens)
2627
- [Migrating from shop-based to user-based token strategy](#migrating-from-shop-based-to-user-based-token-strategy)
2728
- [Migrating from `ShopifyApi::Auth::SessionStorage` to `ShopifyApp::SessionStorage`](#migrating-from-shopifyapiauthsessionstorage-to-shopifyappsessionstorage)
2829

@@ -129,11 +130,11 @@ These methods are already implemented as a part of the `User` and `Shop` models
129130
Simply include these concerns if you want to use the implementation, and overwrite methods for custom implementation
130131

131132
- `Shop` storage
132-
- [ShopSessionStorageWithScopes](https://github.com/Shopify/shopify_app/blob/main/lib/shopify_app/session/shop_session_storage_with_scopes.rb)
133+
- [ShopSessionStorageWithScopes](https://github.com/Shopify/shopify_app/blob/main/lib/shopify_app/session/shop_session_storage_with_scopes.rb) (Deprecated in 23.0.0)
133134
- [ShopSessionStorage](https://github.com/Shopify/shopify_app/blob/main/lib/shopify_app/session/shop_session_storage.rb)
134135

135136
- `User` storage
136-
- [UserSessionStorageWithScopes](https://github.com/Shopify/shopify_app/blob/main/lib/shopify_app/session/user_session_storage_with_scopes.rb)
137+
- [UserSessionStorageWithScopes](https://github.com/Shopify/shopify_app/blob/main/lib/shopify_app/session/user_session_storage_with_scopes.rb) (Deprecated in 23.0.0)
137138
- [UserSessionStorage](https://github.com/Shopify/shopify_app/blob/main/lib/shopify_app/session/user_session_storage.rb)
138139

139140
### Loading Sessions
@@ -206,6 +207,18 @@ user.with_shopify_session do
206207
end
207208
```
208209

210+
**Automatic Token Refresh for Shop Sessions:**
211+
212+
When using `Shop` models with [expiring offline access tokens](#migrating-to-expiring-offline-access-tokens) configured, `with_shopify_session` will automatically refresh expired tokens before executing the block. This ensures your API calls always use valid credentials without manual intervention.
213+
214+
To disable automatic refresh, pass `auto_refresh: false`:
215+
216+
```ruby
217+
shop.with_shopify_session(auto_refresh: false) do
218+
# Token will NOT be refreshed even if expired
219+
end
220+
```
221+
209222
#### Re-fetching an access token when API returns Unauthorized
210223

211224
When using `ShopifyApp::EnsureHasSession` and the `new_embedded_auth_strategy` configuration, any **unhandled** Unauthorized `ShopifyAPI::Errors::HttpResponseError` will cause the app to perform token exchange to fetch a new access token from Shopify and the action to be executed again. This will update and store the new access token to the current session instance.
@@ -300,39 +313,153 @@ class MyController < ApplicationController
300313
end
301314
```
302315

303-
## Access scopes
304-
If you want to customize how access scopes are stored for shops and users, you can implement the `access_scopes` getters and setters in the models that include `ShopifyApp::ShopSessionStorageWithScopes` and `ShopifyApp::UserSessionStorageWithScopes` as shown:
316+
## Expiry date
317+
When the configuration flag `check_session_expiry_date` is set to true, the session expiry date will be checked to trigger a re-auth and get a fresh token when it is expired.
318+
This requires the `ShopifyAPI::Auth::Session` `expires` attribute to be stored.
319+
320+
**Online access tokens (User sessions):**
321+
- When the `User` model includes the `UserSessionStorage` concern, a DB migration can be generated with `rails generate shopify_app:user_model --skip` to add the `expires_at` attribute to the model
322+
- Online access tokens cannot be refreshed, so when the token is expired, the user must go through the OAuth flow again to get a new token
323+
324+
**Offline access tokens (Shop sessions):**
325+
- Offline access tokens can optionally be configured to expire and support automatic refresh. See [Migrating to Expiring Offline Access Tokens](#migrating-to-expiring-offline-access-tokens) for detailed setup instructions
326+
327+
## Migrating to Expiring Offline Access Tokens
328+
329+
You can opt-in to expiring offline access tokens for enhanced security. When enabled, Shopify will issue offline access tokens with an expiration date and a refresh token. `ShopSessionStorage` will then automatically refresh expired tokens when using `with_shopify_session`.
330+
331+
**1. Database Migration:**
332+
333+
Run the shop model generator (use `--skip` to avoid regenerating the Shop model if it already exists):
334+
335+
```bash
336+
rails generate shopify_app:shop_model --skip
337+
```
338+
339+
The generator will prompt you to create a migration that adds the `expires_at`, `refresh_token`, and `refresh_token_expires_at` columns. Alternatively, you can create the migration manually:
340+
341+
```ruby
342+
class AddShopAccessTokenExpiryColumns < ActiveRecord::Migration[7.0]
343+
def change
344+
add_column :shops, :expires_at, :datetime
345+
add_column :shops, :refresh_token, :string
346+
add_column :shops, :refresh_token_expires_at, :datetime
347+
end
348+
end
349+
```
350+
351+
**2. Update Model Concern:**
352+
353+
If your Shop model is using the deprecated `ShopSessionStorageWithScopes` concern, update it to use `ShopSessionStorage`:
305354

306-
### `ShopifyApp::ShopSessionStorageWithScopes`
307355
```ruby
356+
# app/models/shop.rb
308357
class Shop < ActiveRecord::Base
309-
include ShopifyApp::ShopSessionStorageWithScopes
358+
include ShopifyApp::ShopSessionStorage # Change from ShopSessionStorageWithScopes
359+
end
360+
```
310361

311-
def access_scopes=(scopes)
312-
# Store access scopes
313-
end
314-
def access_scopes
315-
# Find access scopes
316-
end
362+
`ShopSessionStorage` now automatically handles `access_scopes`, `expires_at`, `refresh_token`, and `refresh_token_expires_at` - no additional concerns needed.
363+
364+
**3. Configuration:**
365+
366+
```ruby
367+
# config/initializers/shopify_app.rb
368+
ShopifyApp.configure do |config|
369+
# ... other configuration
370+
371+
# Enable automatic reauthentication when session is expired
372+
config.check_session_expiry_date = true
317373
end
374+
375+
# For ShopifyAPI Context - enable requesting expiring offline tokens
376+
ShopifyAPI::Context.setup(
377+
# ... other configuration
378+
expiring_offline_access_tokens: true, # Opt-in to start requesting expiring offline tokens
379+
)
318380
```
319381

320-
### `ShopifyApp::UserSessionStorageWithScopes`
382+
**4. Refreshing Expired Tokens:**
383+
384+
With the configuration enabled, expired tokens are automatically handled differently based on the flow:
385+
386+
**For user-facing requests (OAuth/Token Exchange flow):**
387+
When `check_session_expiry_date` is enabled, expired sessions trigger automatic re-authentication through the OAuth flow. This happens transparently when using controller concerns like `EnsureHasSession`.
388+
389+
**For background jobs and non-user interactions:**
390+
Tokens are automatically refreshed when using `with_shopify_session` from `ShopSessionStorage`:
391+
321392
```ruby
322-
class User < ActiveRecord::Base
323-
include ShopifyApp::UserSessionStorageWithScopes
393+
shop = Shop.find_by(shopify_domain: "example.myshopify.com")
324394

325-
def access_scopes=(scopes)
326-
# Store access scopes
327-
end
328-
def access_scopes
329-
# Find access scopes
395+
# Automatic refresh (default behavior)
396+
shop.with_shopify_session do
397+
# If the token is expired, it will be automatically refreshed before making API calls
398+
end
399+
400+
# Disable automatic refresh if needed
401+
shop.with_shopify_session(auto_refresh: false) do
402+
# Token will NOT be refreshed even if expired
403+
end
404+
405+
# Manual refresh
406+
begin
407+
shop.refresh_token_if_expired!
408+
rescue ShopifyApp::RefreshTokenExpiredError
409+
# Handle case where refresh token itself has expired
410+
# App needs to go through OAuth flow again
411+
end
412+
```
413+
414+
**Error Handling:**
415+
- `ShopifyApp::RefreshTokenExpiredError` is raised when the refresh token itself is expired
416+
- When this happens, the user must interact with the app to go through the OAuth flow again to get new tokens
417+
- The refresh process uses database row-level locking to prevent race conditions from concurrent requests
418+
419+
**5. Migrating Existing Shop Installations:**
420+
421+
⚠️ **Important:** When you enable `offline_access_token_expires: true`, only **new shop installations** will automatically receive expiring tokens during the OAuth flow. Existing shop installations with non-expiring tokens will continue using their current tokens until manually migrated.
422+
423+
To migrate existing shops to expiring tokens, use the `ShopifyAPI::Auth::TokenExchange.migrate_to_expiring_token` method. Here's an example background job to migrate all existing shops:
424+
425+
```ruby
426+
# app/jobs/migrate_shops_to_expiring_tokens_job.rb
427+
class MigrateShopsToExpiringTokensJob < ActiveJob::Base
428+
queue_as :default
429+
430+
def perform
431+
# Find shops that haven't been migrated yet (no refresh_token or expires_at)
432+
shops_to_migrate = Shop.where(expires_at: nil, refresh_token: nil, refresh_token_expires_at: nil)
433+
434+
shops_to_migrate.find_each do |shop|
435+
begin
436+
# Migrate to expiring token
437+
new_session = ShopifyAPI::Auth::TokenExchange.migrate_to_expiring_token(
438+
shop: shop.shopify_domain,
439+
non_expiring_offline_token: shop.shopify_token
440+
)
441+
442+
# Store the new session with expiring token and refresh token
443+
Shop.store(new_session)
444+
445+
Rails.logger.info("Successfully migrated #{shop.shopify_domain} to expiring token")
446+
rescue ShopifyAPI::Errors::HttpResponseError => e
447+
# Handle migration errors (e.g., shop uninstalled, network issues)
448+
Rails.logger.error("Failed to migrate #{shop.shopify_domain}: #{e.message}")
449+
rescue => e
450+
Rails.logger.error("Unexpected error migrating #{shop.shopify_domain}: #{e.message}")
451+
end
452+
end
330453
end
331454
end
332455
```
333456

334-
## Expiry date
335-
When the configuration flag `check_session_expiry_date` is set to true, the user session expiry date will be checked to trigger a re-auth and get a fresh user token when it is expired. This requires the `ShopifyAPI::Auth::Session` `expires` attribute to be stored. When the `User` model includes the `UserSessionStorageWithScopes` concern, a DB migration can be generated with `rails generate shopify_app:user_model --skip` to add the `expires_at` attribute to the model.
457+
**Migration notes:**
458+
- This is a **one-time, irreversible operation** per shop
459+
- The shop must have the app installed and have a valid access token
460+
- After migration, the shop's offline token will have an expiration date and a refresh token
461+
462+
**Note:** If you choose not to enable expiring offline tokens, the `expires_at`, `refresh_token`, and `refresh_token_expires_at` columns will remain `NULL` and no automatic refresh will occur. Refresh tokens are only available for offline (shop) access tokens. Online (user) access tokens do not support refresh and must be re-authorized through OAuth when expired.
336463

337464
## Migrating from shop-based to user-based token strategy
338465

lib/generators/shopify_app/shop_model/shop_model_generator.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,24 @@ def create_shop_with_access_scopes_migration
4040
end
4141
end
4242

43+
def create_shop_with_token_refresh_migration
44+
token_refresh_prompt = <<~PROMPT
45+
To support expiring offline access token with refresh, your Shop model needs to store \
46+
token expiration dates and refresh tokens.
47+
48+
The following migration will add `expires_at`, `refresh_token`, and \
49+
`refresh_token_expires_at` columns to the Shop model. \
50+
Do you want to include this migration? [y/n]
51+
PROMPT
52+
53+
if new_shopify_cli_app? || Rails.env.test? || yes?(token_refresh_prompt)
54+
migration_template(
55+
"db/migrate/add_shop_access_token_expiry_columns.erb",
56+
"db/migrate/add_shop_access_token_expiry_columns.rb",
57+
)
58+
end
59+
end
60+
4361
def update_shopify_app_initializer
4462
gsub_file("config/initializers/shopify_app.rb", "ShopifyApp::InMemoryShopSessionStore", "Shop")
4563
end
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
class AddShopAccessTokenExpiryColumns < ActiveRecord::Migration[<%= rails_migration_version %>]
2+
def change
3+
add_column :shops, :expires_at, :datetime
4+
add_column :shops, :refresh_token, :string
5+
add_column :shops, :refresh_token_expires_at, :datetime
6+
end
7+
end

lib/generators/shopify_app/shop_model/templates/shop.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# frozen_string_literal: true
22

33
class Shop < ActiveRecord::Base
4-
include ShopifyApp::ShopSessionStorageWithScopes
4+
include ShopifyApp::ShopSessionStorage
55

66
def api_version
77
ShopifyApp.configuration.api_version

lib/generators/shopify_app/user_model/templates/user.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# frozen_string_literal: true
22

33
class User < ActiveRecord::Base
4-
include ShopifyApp::UserSessionStorageWithScopes
4+
include ShopifyApp::UserSessionStorage
55

66
def api_version
77
ShopifyApp.configuration.api_version

lib/shopify_app/errors.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,6 @@ class MissingWebhookJobError < StandardError; end
3131
class ShopifyDomainNotFound < StandardError; end
3232

3333
class ShopifyHostNotFound < StandardError; end
34+
35+
class RefreshTokenExpiredError < StandardError; end
3436
end

0 commit comments

Comments
 (0)