Skip to content

Commit 80a2054

Browse files
committed
Add DB migration template to generator
1 parent b3871b4 commit 80a2054

File tree

12 files changed

+146
-86
lines changed

12 files changed

+146
-86
lines changed

CHANGELOG.md

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,12 @@ Unreleased
33
- ⚠️ [Breaking] Removes `ShopifyApp::JWTMiddleware` and `ShopifyApp::JWT` See [Upgrading](/docs/Upgrading.md) for more migration. [1960](https://github.com/Shopify/shopify_app/pull/1960)
44
- ⚠️ [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)
55
- ⚠️ [Breaking] Bumps minimum supported Ruby version to 3.1 [#1959](https://github.com/Shopify/shopify_app/pull/1959)
6-
- Adds automatic offline access token refresh support for `ShopSessionStorage`. When using `with_shopify_session`, expired offline access tokens will automatically be refreshed using refresh tokens. [#XXXX](https://github.com/Shopify/shopify_app/pull/XXXX)
7-
- Adds `refresh_token_if_expired!` public method to `ShopSessionStorage` for manual token refresh
8-
- Adds `RefreshTokenExpiredError` exception raised when refresh token itself is expired
6+
- 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)
97
- `ShopSessionStorage` now automatically stores `access_scopes`, `expires_at`, `refresh_token`, and `refresh_token_expires_at` from auth sessions
108
- `UserSessionStorage` now automatically stores `access_scopes` and `expires_at` from auth sessions (refresh tokens not applicable for online/user tokens)
11-
- See [Sessions documentation](/docs/shopify_app/sessions.md#automatic-access-token-refresh) for migration guide
12-
- Deprecates `ShopSessionStorageWithScopes` and `UserSessionStorageWithScopes` in favor of `ShopSessionStorage` and `UserSessionStorage`, which now handle session attributes automatically. Will be removed in v23.0.0. [#XXXX](https://github.com/Shopify/shopify_app/pull/XXXX)
9+
- Adds `refresh_token_if_expired!` public method to `ShopSessionStorage` for manual token refresh
10+
- Adds `RefreshTokenExpiredError` exception raised when refresh token itself is expired
11+
- Mark deprecation for `ShopSessionStorageWithScopes` and `UserSessionStorageWithScopes` in favor of `ShopSessionStorage` and `UserSessionStorage`.
1312
- 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)
1413
- Handle invalid token when adding redirection headers [#1945](https://github.com/Shopify/shopify_app/pull/1945)
1514
- 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: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -79,18 +79,7 @@ class User < ActiveRecord::Base
7979
end
8080
```
8181

82-
3. **Optional but recommended:** Add columns to enable automatic token refresh for shops:
83-
```ruby
84-
class AddTokenRefreshFieldsToShops < ActiveRecord::Migration[7.0]
85-
def change
86-
add_column :shops, :expires_at, :datetime
87-
add_column :shops, :refresh_token, :string
88-
add_column :shops, :refresh_token_expires_at, :datetime
89-
end
90-
end
91-
```
92-
93-
With these columns, `ShopSessionStorage#with_shopify_session` will automatically refresh expired offline access tokens. See the [Sessions documentation](/docs/shopify_app/sessions.md#automatic-access-token-refresh) for more details.
82+
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.
9483

9584
**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.
9685

docs/shopify_app/sessions.md

Lines changed: 86 additions & 59 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

@@ -206,60 +207,18 @@ user.with_shopify_session do
206207
end
207208
```
208209

209-
##### Automatic Access Token Refresh
210+
**Automatic Token Refresh for Shop Sessions:**
210211

211-
`ShopSessionStorage` includes automatic token refresh for expired offline access tokens. When using `with_shopify_session` on a Shop model, the gem will automatically refresh the access token if it has expired, using the refresh token stored in the database.
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.
212213

213-
**Requirements:**
214-
- Shop model must include `ShopSessionStorage` concern
215-
- Database must have the following columns:
216-
- `expires_at` (datetime) - when the access token expires
217-
- `refresh_token` (string) - the refresh token
218-
- `refresh_token_expires_at` (datetime) - when the refresh token expires
214+
To disable automatic refresh, pass `auto_refresh: false`:
219215

220-
**Migration Example:**
221216
```ruby
222-
class AddTokenRefreshFieldsToShops < ActiveRecord::Migration[7.0]
223-
def change
224-
add_column :shops, :expires_at, :datetime
225-
add_column :shops, :refresh_token, :string
226-
add_column :shops, :refresh_token_expires_at, :datetime
227-
end
228-
end
229-
```
230-
231-
**Usage:**
232-
```ruby
233-
shop = Shop.find_by(shopify_domain: "example.myshopify.com")
234-
235-
# Automatic refresh (default behavior)
236-
shop.with_shopify_session do
237-
# If the token is expired, it will be automatically refreshed before making API calls
238-
ShopifyAPI::Product.all
239-
end
240-
241-
# Disable automatic refresh if needed
242217
shop.with_shopify_session(auto_refresh: false) do
243218
# Token will NOT be refreshed even if expired
244-
ShopifyAPI::Product.all
245-
end
246-
247-
# Manual refresh
248-
begin
249-
shop.refresh_token_if_expired!
250-
rescue ShopifyApp::RefreshTokenExpiredError
251-
# Handle case where refresh token itself has expired
252-
# App needs to go through OAuth flow again
253219
end
254220
```
255221

256-
**Error Handling:**
257-
- `ShopifyApp::RefreshTokenExpiredError` is raised when the refresh token itself is expired
258-
- When this happens, the shop must go through the OAuth flow again to get new tokens
259-
- The refresh process uses database row-level locking to prevent race conditions from concurrent requests
260-
261-
**Note:** 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.
262-
263222
#### Re-fetching an access token when API returns Unauthorized
264223

265224
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.
@@ -355,41 +314,109 @@ end
355314
```
356315

357316
## Expiry date
358-
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 user token when it is expired.
359-
This requires the `ShopifyAPI::Auth::Session` `expires` attribute to be stored.
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.
360319

361-
### Online access tokens
362-
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.
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
363323

364-
Online access tokens can not be refreshed, so when the token is expired, the user must go through the OAuth flow again to get a new token.
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
365326

366-
### Offline access tokens
327+
## Migrating to Expiring Offline Access Tokens
367328

368-
**Optional Configuration:** By default, offline access tokens do not expire. However, you can opt-in to expiring offline access tokens for enhanced security by configuring it through `ShopifyAPI::Context`:
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`:
354+
355+
```ruby
356+
# app/models/shop.rb
357+
class Shop < ActiveRecord::Base
358+
include ShopifyApp::ShopSessionStorage # Change from ShopSessionStorageWithScopes
359+
end
360+
```
361+
362+
`ShopSessionStorage` now automatically handles `access_scopes`, `expires_at`, `refresh_token`, and `refresh_token_expires_at` - no additional concerns needed.
363+
364+
**3. Configuration:**
369365

370366
```ruby
371367
# config/initializers/shopify_app.rb
372368
ShopifyApp.configure do |config|
373369
# ... other configuration
374370

375-
# Enable checking session expiry dates
371+
# Enable automatic reauthentication when session is expired
376372
config.check_session_expiry_date = true
377373
end
378374

379-
# For ShopifyAPI Context - enable expiring offline tokens
375+
# For ShopifyAPI Context - enable requesting expiring offline tokens
380376
ShopifyAPI::Context.setup(
381377
# ... other configuration
382-
offline_access_token_expires: true, # Opt-in to expiring offline tokens
378+
offline_access_token_expires: true, # Opt-in to start requesting expiring offline tokens
383379
)
384380
```
385381

386-
When expiring offline tokens are enabled, Shopify will issue offline access tokens with an expiration date and a refresh token. Your app can then automatically refresh these tokens when they expire.
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`:
387391

388-
**Database Setup:** When the `Shop` model includes the `ShopSessionStorage` concern, a DB migration can be generated with `rails generate shopify_app:shop_model --skip` to add the `expires_at`, `refresh_token`, and `refresh_token_expires_at` attributes to the model.
392+
```ruby
393+
shop = Shop.find_by(shopify_domain: "example.myshopify.com")
389394

390-
**Automatic Refresh:** Offline access tokens can be automatically refreshed using the stored refresh token when expired. See [Automatic Access Token Refresh](#automatic-access-token-refresh) for more details.
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
391418

392-
**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.
419+
**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.
393420

394421
## Migrating from shop-based to user-based token strategy
395422

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/session/shop_session_storage_with_scopes.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ module ShopSessionStorageWithScopes
88
included do
99
ShopifyApp::Logger.deprecated(
1010
"ShopSessionStorageWithScopes is deprecated and will be removed in v23.0.0. " \
11-
"Use ShopSessionStorage instead, which now handles access_scopes, expires_at, " \
12-
"refresh_token, and refresh_token_expires_at automatically.",
11+
"Use ShopSessionStorage instead, which now handles access_scopes, expires_at, " \
12+
"refresh_token, and refresh_token_expires_at automatically.",
1313
"23.0.0",
1414
)
1515
validates :shopify_domain, presence: true, uniqueness: { case_sensitive: false }

lib/shopify_app/session/user_session_storage_with_scopes.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ module UserSessionStorageWithScopes
88
included do
99
ShopifyApp::Logger.deprecated(
1010
"UserSessionStorageWithScopes is deprecated and will be removed in v23.0.0. " \
11-
"Use UserSessionStorage instead, which now handles access_scopes and expires_at automatically.",
11+
"Use UserSessionStorage instead, which now handles access_scopes and expires_at automatically.",
1212
"23.0.0",
1313
)
1414

0 commit comments

Comments
 (0)