Skip to content

Commit 17f77bc

Browse files
committed
Add DB migration template to generator
1 parent b3871b4 commit 17f77bc

File tree

11 files changed

+131
-82
lines changed

11 files changed

+131
-82
lines changed

CHANGELOG.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,11 @@ 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.** [#XXXX](https://github.com/Shopify/shopify_app/pull/XXXX)
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
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
1211
- 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)
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)

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: 73 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,96 @@ 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.
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 Setup:**
332+
333+
Run the shop model generator (use `--skip` to avoid regenerating the Shop model if it already exists):
360334

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.
335+
```bash
336+
rails generate shopify_app:shop_model --skip
337+
```
363338

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.
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:
365340

366-
### Offline access tokens
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+
```
367350

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`:
351+
**2. Configuration:**
369352

370353
```ruby
371354
# config/initializers/shopify_app.rb
372355
ShopifyApp.configure do |config|
373356
# ... other configuration
374357

375-
# Enable checking session expiry dates
358+
# Enable automatic reauthentication when session is expired
376359
config.check_session_expiry_date = true
377360
end
378361

379-
# For ShopifyAPI Context - enable expiring offline tokens
362+
# For ShopifyAPI Context - enable requesting expiring offline tokens
380363
ShopifyAPI::Context.setup(
381364
# ... other configuration
382-
offline_access_token_expires: true, # Opt-in to expiring offline tokens
365+
offline_access_token_expires: true, # Opt-in to start requesting expiring offline tokens
383366
)
384367
```
385368

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.
369+
**3. Refreshing Expired Tokens:**
370+
371+
With the configuration enabled, expired tokens are automatically handled differently based on the flow:
387372

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.
373+
**For user-facing requests (OAuth/Token Exchange flow):**
374+
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`.
375+
376+
**For background jobs and non-user interactions:**
377+
Tokens are automatically refreshed when using `with_shopify_session`:
378+
379+
```ruby
380+
shop = Shop.find_by(shopify_domain: "example.myshopify.com")
381+
382+
# Automatic refresh (default behavior)
383+
shop.with_shopify_session do
384+
# If the token is expired, it will be automatically refreshed before making API calls
385+
end
389386

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.
387+
# Disable automatic refresh if needed
388+
shop.with_shopify_session(auto_refresh: false) do
389+
# Token will NOT be refreshed even if expired
390+
end
391+
392+
# Manual refresh
393+
begin
394+
shop.refresh_token_if_expired!
395+
rescue ShopifyApp::RefreshTokenExpiredError
396+
# Handle case where refresh token itself has expired
397+
# App needs to go through OAuth flow again
398+
end
399+
```
400+
401+
**Error Handling:**
402+
- `ShopifyApp::RefreshTokenExpiredError` is raised when the refresh token itself is expired
403+
- When this happens, the shop must go through the OAuth flow again to get new tokens
404+
- The refresh process uses database row-level locking to prevent race conditions from concurrent requests
391405

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.
406+
**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.
393407

394408
## Migrating from shop-based to user-based token strategy
395409

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

test/generators/shop_model_generator_test.rb

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ class ShopModelGeneratorTest < Rails::Generators::TestCase
1616
run_generator
1717
assert_file "app/models/shop.rb" do |shop|
1818
assert_match "class Shop < ActiveRecord::Base", shop
19-
assert_match "include ShopifyApp::ShopSessionStorageWithScopes", shop
19+
assert_match "include ShopifyApp::ShopSessionStorage", shop
2020
assert_match(/def api_version\n\s*ShopifyApp\.configuration\.api_version\n\s*end/, shop)
2121
end
2222
end
@@ -46,6 +46,28 @@ class ShopModelGeneratorTest < Rails::Generators::TestCase
4646
end
4747
end
4848

49+
test "create shop with access token expiry columns migration in a test environment" do
50+
run_generator
51+
assert_migration "db/migrate/add_shop_access_token_expiry_columns.rb" do |migration|
52+
assert_match "add_column :shops, :expires_at, :datetime", migration
53+
assert_match "add_column :shops, :refresh_token, :string", migration
54+
assert_match "add_column :shops, :refresh_token_expires_at, :datetime", migration
55+
end
56+
end
57+
58+
test "create shop with access token expiry columns migration with --new-shopify-cli-app flag provided" do
59+
Rails.env = "mock_environment"
60+
61+
run_generator ["--new-shopify-cli-app"]
62+
Rails.env = "test" # Change this back for subsequent tests
63+
64+
assert_migration "db/migrate/add_shop_access_token_expiry_columns.rb" do |migration|
65+
assert_match "add_column :shops, :expires_at, :datetime", migration
66+
assert_match "add_column :shops, :refresh_token, :string", migration
67+
assert_match "add_column :shops, :refresh_token_expires_at, :datetime", migration
68+
end
69+
end
70+
4971
test "updates the shopify_app initializer" do
5072
run_generator
5173
assert_file "config/initializers/shopify_app.rb" do |file|

0 commit comments

Comments
 (0)