Skip to content
Merged
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ Unreleased
- ⚠️ [Breaking] Removes `ShopifyApp::JWTMiddleware` and `ShopifyApp::JWT` See [Upgrading](/docs/Upgrading.md) for more migration. [1960](https://github.com/Shopify/shopify_app/pull/1960)
- ⚠️ [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)
- ⚠️ [Breaking] Bumps minimum supported Ruby version to 3.1 [#1959](https://github.com/Shopify/shopify_app/pull/1959)
- 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)
- `ShopSessionStorage` now automatically stores `access_scopes`, `expires_at`, `refresh_token`, and `refresh_token_expires_at` from auth sessions
- `UserSessionStorage` now automatically stores `access_scopes` and `expires_at` from auth sessions (refresh tokens not applicable for online/user tokens)
- Adds `refresh_token_if_expired!` public method to `ShopSessionStorage` for manual token refresh
- Adds `RefreshTokenExpiredError` exception raised when refresh token itself is expired
- Mark deprecation for `ShopSessionStorageWithScopes` and `UserSessionStorageWithScopes` in favor of `ShopSessionStorage` and `UserSessionStorage`.
- 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)
- Handle invalid token when adding redirection headers [#1945](https://github.com/Shopify/shopify_app/pull/1945)
- Handle invalid record error for concurrent token exchange calls [#1966](https://github.com/Shopify/shopify_app/pull/1966)
Expand Down
4 changes: 1 addition & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,6 @@ You can find documentation on gem usage, concepts, mixins, installation, and mor
* [Controller Concerns](/docs/shopify_app/controller-concerns.md)
* [Generators](/docs/shopify_app/generators.md)
* [Sessions](/docs/shopify_app/sessions.md)
* [Handling changes in access scopes](/docs/shopify_app/handling-access-scopes-changes.md)
* [Testing](/docs/shopify_app/testing.md)
* [Webhooks](/docs/shopify_app/webhooks.md)
* [Content Security Policy](/docs/shopify_app/content-security-policy.md)
Expand Down Expand Up @@ -243,8 +242,7 @@ ShopifyApp.configure do |config|
config.embedded_app = true
config.new_embedded_auth_strategy = true

# If your app is configured to use online sessions, you can enable session expiry date check so a new access token
# is fetched automatically when the session expires.
# You can enable session expiry date check so a new access token is fetched automatically when the session expires.
# See expiry date check docs: https://github.com/Shopify/shopify_app/blob/main/docs/shopify_app/sessions.md#expiry-date
config.check_session_expiry_date = true
...
Expand Down
36 changes: 36 additions & 0 deletions docs/Upgrading.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,42 @@ require 'shopify_app/jobs/webhooks_manager_job'

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

#### (v23.0.0) - ShopSessionStorageWithScopes and UserSessionStorageWithScopes are deprecated

`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).

**Migration:**

1. Update your Shop model to use `ShopSessionStorage`:
```ruby
# Before
class Shop < ActiveRecord::Base
include ShopifyApp::ShopSessionStorageWithScopes
end

# After
class Shop < ActiveRecord::Base
include ShopifyApp::ShopSessionStorage
end
```

2. Update your User model to use `UserSessionStorage`:
```ruby
# Before
class User < ActiveRecord::Base
include ShopifyApp::UserSessionStorageWithScopes
end

# After
class User < ActiveRecord::Base
include ShopifyApp::UserSessionStorage
end
```

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.

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

#### (v23.0.0) - Deprecated methods in CallbackController
The following methods from `ShopifyApp::CallbackController` have been deprecated in `v23.0.0`
- `perform_after_authenticate_job`
Expand Down
171 changes: 149 additions & 22 deletions docs/shopify_app/sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Sessions are used to make contextual API calls for either a shop (offline sessio
- [Access scopes](#access-scopes)
- [`ShopifyApp::ShopSessionStorageWithScopes`](#shopifyappshopsessionstoragewithscopes)
- [`ShopifyApp::UserSessionStorageWithScopes`](#shopifyappusersessionstoragewithscopes)
- [Migrating to Expiring Offline Access Tokens](#migrating-to-expiring-offline-access-tokens)
- [Migrating from shop-based to user-based token strategy](#migrating-from-shop-based-to-user-based-token-strategy)
- [Migrating from `ShopifyApi::Auth::SessionStorage` to `ShopifyApp::SessionStorage`](#migrating-from-shopifyapiauthsessionstorage-to-shopifyappsessionstorage)

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

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

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

### Loading Sessions
Expand Down Expand Up @@ -206,6 +207,18 @@ user.with_shopify_session do
end
```

**Automatic Token Refresh for Shop Sessions:**

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.

To disable automatic refresh, pass `auto_refresh: false`:

```ruby
shop.with_shopify_session(auto_refresh: false) do
# Token will NOT be refreshed even if expired
end
```

#### Re-fetching an access token when API returns Unauthorized

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.
Expand Down Expand Up @@ -300,39 +313,153 @@ class MyController < ApplicationController
end
```

## Access scopes
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:
## Expiry date
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.
This requires the `ShopifyAPI::Auth::Session` `expires` attribute to be stored.

**Online access tokens (User sessions):**
- 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
- 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

**Offline access tokens (Shop sessions):**
- 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

## Migrating to Expiring Offline Access Tokens

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`.

**1. Database Migration:**

Run the shop model generator (use `--skip` to avoid regenerating the Shop model if it already exists):

```bash
rails generate shopify_app:shop_model --skip
```

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:

```ruby
class AddShopAccessTokenExpiryColumns < ActiveRecord::Migration[7.0]
def change
add_column :shops, :expires_at, :datetime
add_column :shops, :refresh_token, :string
add_column :shops, :refresh_token_expires_at, :datetime
end
end
```

**2. Update Model Concern:**

If your Shop model is using the deprecated `ShopSessionStorageWithScopes` concern, update it to use `ShopSessionStorage`:

### `ShopifyApp::ShopSessionStorageWithScopes`
```ruby
# app/models/shop.rb
class Shop < ActiveRecord::Base
include ShopifyApp::ShopSessionStorageWithScopes
include ShopifyApp::ShopSessionStorage # Change from ShopSessionStorageWithScopes
end
```

def access_scopes=(scopes)
# Store access scopes
end
def access_scopes
# Find access scopes
end
`ShopSessionStorage` now automatically handles `access_scopes`, `expires_at`, `refresh_token`, and `refresh_token_expires_at` - no additional concerns needed.

**3. Configuration:**

```ruby
# config/initializers/shopify_app.rb
ShopifyApp.configure do |config|
# ... other configuration

# Enable automatic reauthentication when session is expired
config.check_session_expiry_date = true
end

# For ShopifyAPI Context - enable requesting expiring offline tokens
ShopifyAPI::Context.setup(
# ... other configuration
expiring_offline_access_tokens: true, # Opt-in to start requesting expiring offline tokens
)
```

### `ShopifyApp::UserSessionStorageWithScopes`
**4. Refreshing Expired Tokens:**

With the configuration enabled, expired tokens are automatically handled differently based on the flow:

**For user-facing requests (OAuth/Token Exchange flow):**
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`.

**For background jobs and non-user interactions:**
Tokens are automatically refreshed when using `with_shopify_session` from `ShopSessionStorage`:

```ruby
class User < ActiveRecord::Base
include ShopifyApp::UserSessionStorageWithScopes
shop = Shop.find_by(shopify_domain: "example.myshopify.com")

def access_scopes=(scopes)
# Store access scopes
end
def access_scopes
# Find access scopes
# Automatic refresh (default behavior)
shop.with_shopify_session do
# If the token is expired, it will be automatically refreshed before making API calls
end

# Disable automatic refresh if needed
shop.with_shopify_session(auto_refresh: false) do
# Token will NOT be refreshed even if expired
end

# Manual refresh
begin
shop.refresh_token_if_expired!
rescue ShopifyApp::RefreshTokenExpiredError
# Handle case where refresh token itself has expired
# App needs to go through OAuth flow again
end
```

**Error Handling:**
- `ShopifyApp::RefreshTokenExpiredError` is raised when the refresh token itself is expired
- When this happens, the user must interact with the app to go through the OAuth flow again to get new tokens
- The refresh process uses database row-level locking to prevent race conditions from concurrent requests

**5. Migrating Existing Shop Installations:**

⚠️ **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.

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:

```ruby
# app/jobs/migrate_shops_to_expiring_tokens_job.rb
class MigrateShopsToExpiringTokensJob < ActiveJob::Base
queue_as :default

def perform
# Find shops that haven't been migrated yet (no refresh_token or expires_at)
shops_to_migrate = Shop.where(expires_at: nil, refresh_token: nil, refresh_token_expires_at: nil)

shops_to_migrate.find_each do |shop|
begin
# Migrate to expiring token
new_session = ShopifyAPI::Auth::TokenExchange.migrate_to_expiring_token(
shop: shop.shopify_domain,
non_expiring_offline_token: shop.shopify_token
)

# Store the new session with expiring token and refresh token
Shop.store(new_session)

Rails.logger.info("Successfully migrated #{shop.shopify_domain} to expiring token")
rescue ShopifyAPI::Errors::HttpResponseError => e
# Handle migration errors (e.g., shop uninstalled, network issues)
Rails.logger.error("Failed to migrate #{shop.shopify_domain}: #{e.message}")
rescue => e
Rails.logger.error("Unexpected error migrating #{shop.shopify_domain}: #{e.message}")
end
end
end
end
```

## Expiry date
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.
**Migration notes:**
- This is a **one-time, irreversible operation** per shop
- The shop must have the app installed and have a valid access token
- After migration, the shop's offline token will have an expiration date and a refresh token

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

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

Expand Down
18 changes: 18 additions & 0 deletions lib/generators/shopify_app/shop_model/shop_model_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,24 @@ def create_shop_with_access_scopes_migration
end
end

def create_shop_with_token_refresh_migration
token_refresh_prompt = <<~PROMPT
To support expiring offline access token with refresh, your Shop model needs to store \
token expiration dates and refresh tokens.
The following migration will add `expires_at`, `refresh_token`, and \
`refresh_token_expires_at` columns to the Shop model. \
Do you want to include this migration? [y/n]
PROMPT

if new_shopify_cli_app? || Rails.env.test? || yes?(token_refresh_prompt)
migration_template(
"db/migrate/add_shop_access_token_expiry_columns.erb",
"db/migrate/add_shop_access_token_expiry_columns.rb",
)
end
end

def update_shopify_app_initializer
gsub_file("config/initializers/shopify_app.rb", "ShopifyApp::InMemoryShopSessionStore", "Shop")
end
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
class AddShopAccessTokenExpiryColumns < ActiveRecord::Migration[<%= rails_migration_version %>]
def change
add_column :shops, :expires_at, :datetime
add_column :shops, :refresh_token, :string
add_column :shops, :refresh_token_expires_at, :datetime
end
end
2 changes: 1 addition & 1 deletion lib/generators/shopify_app/shop_model/templates/shop.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# frozen_string_literal: true

class Shop < ActiveRecord::Base
include ShopifyApp::ShopSessionStorageWithScopes
include ShopifyApp::ShopSessionStorage

def api_version
ShopifyApp.configuration.api_version
Expand Down
2 changes: 1 addition & 1 deletion lib/generators/shopify_app/user_model/templates/user.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# frozen_string_literal: true

class User < ActiveRecord::Base
include ShopifyApp::UserSessionStorageWithScopes
include ShopifyApp::UserSessionStorage

def api_version
ShopifyApp.configuration.api_version
Expand Down
2 changes: 2 additions & 0 deletions lib/shopify_app/errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,6 @@ class MissingWebhookJobError < StandardError; end
class ShopifyDomainNotFound < StandardError; end

class ShopifyHostNotFound < StandardError; end

class RefreshTokenExpiredError < StandardError; end
end
Loading