Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ Note: For changes to the API, see https://shopify.dev/changelog?filter=api
- ⚠️ [Breaking] Minimum required Ruby version is now 3.2. Ruby 3.0 and 3.1 are no longer supported.
- ⚠️ [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.

- Add support for expiring offline access tokens with refresh tokens. See [OAuth documentation](docs/usage/oauth.md#expiring-offline-access-tokens) for details.
- 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.

### 15.0.0

- ⚠️ [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.
Expand Down
3 changes: 2 additions & 1 deletion docs/getting_started.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ ShopifyAPI::Context.setup(
scope: "read_orders,read_products,etc",
is_embedded: true, # Set to true if you are building an embedded app
is_private: false, # Set to true if you are building a private app
api_version: "2021-01" # The version of the API you would like to use
api_version: "2021-01", # The version of the API you would like to use
expiring_offline_access_tokens: true # Set to true to enable expiring offline access tokens with refresh tokens
)
```

Expand Down
129 changes: 129 additions & 0 deletions docs/usage/oauth.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ For more information on authenticating a Shopify app please see the [Types of Au
- [Token Exchange](#token-exchange)
- [Authorization Code Grant](#authorization-code-grant)
- [Client Credentials Grant](#client-credentials-grant)
- [Expiring Offline Access Tokens](#expiring-offline-access-tokens)
- [Refreshing Access Tokens](#refreshing-access-tokens)
- [Migrating Non-Expiring Tokens to Expiring Tokens](#migrating-non-expiring-tokens-to-expiring-tokens)
- [Using OAuth Session to make authenticated API calls](#using-oauth-session-to-make-authenticated-api-calls)

## Session Persistence
Expand Down Expand Up @@ -305,6 +308,132 @@ end

```

## Expiring Offline Access Tokens


To start requesting expiring offline access tokens, set the `expiring_offline_access_tokens` parameter to `true` when setting up the Shopify context:

```ruby
ShopifyAPI::Context.setup(
api_key: <SHOPIFY_API_KEY>,
api_secret_key: <SHOPIFY_API_SECRET>,
api_version: <SHOPIFY_API_VERSION>,
scope: <SHOPIFY_API_SCOPES>,
expiring_offline_access_tokens: true, # Enable expiring offline access tokens
...
)
```

When enabled:
- **Authorization Code Grant**: The OAuth flow will request expiring offline access tokens by sending `expiring: 1` parameter
- **Token Exchange**: When requesting offline access tokens via token exchange, the flow will request expiring tokens

The resulting `Session` object will contain:
- `access_token`: The access token that will eventually expire
- `expires`: The expiration time for the access token
- `refresh_token`: A token that can be used to refresh the access token
- `refresh_token_expires`: The expiration time for the refresh token

### Refreshing Access Tokens

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.

#### Input
| Parameter | Type | Required? | Notes |
| -------------- | -------- | :-------: | ----------------------------------------------------------------------------------------------- |
| `shop` | `String` | Yes | A Shopify domain name in the form `{exampleshop}.myshopify.com`. |
| `refresh_token`| `String` | Yes | The refresh token from the session. |

#### Output
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.

#### Example
```ruby
def refresh_session(shop, refresh_token)
begin
# Refresh the access token using the refresh token
new_session = ShopifyAPI::Auth::RefreshToken.refresh_access_token(
shop: shop,
refresh_token: refresh_token
)

# Store the new session, replacing the old one
MyApp::SessionRepository.store_shop_session(new_session)
rescue ShopifyAPI::Errors::HttpResponseError => e
puts("Failed to refresh access token: #{e.message}")
raise e
end
end
```
#### Checking Token Expiration
The `Session` object provides helper methods to check if tokens have expired:

```ruby
session = MyApp::SessionRepository.retrieve_shop_session_by_shopify_domain(shop)

# Check if the access token has expired
if session.expired?
# Access token has expired, refresh it
new_session = ShopifyAPI::Auth::RefreshToken.refresh_access_token(
shop: session.shop,
refresh_token: session.refresh_token
)
MyApp::SessionRepository.store_shop_session(new_session)
end

# Check if the refresh token has expired
if session.refresh_token_expired?
# Refresh token has expired, need to re-authenticate with OAuth
end
```

### Migrating Non-Expiring Tokens to Expiring Tokens

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.

> [!WARNING]
> 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.

#### Input
| Parameter | Type | Required? | Notes |
| -------------- | -------- | :-------: | ----------------------------------------------------------------------------------------------- |
| `shop` | `String` | Yes | A Shopify domain name in the form `{exampleshop}.myshopify.com`. |
| `non_expiring_offline_token` | `String` | Yes | The non-expiring offline access token to migrate. |

#### Output
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.

#### Example
```ruby
def migrate_shop_to_expiring_offline_token(shop)
# Retrieve the existing non-expiring session
old_session = MyApp::SessionRepository.retrieve_shop_session_by_shopify_domain(shop)

# Migrate to expiring token
new_session = ShopifyAPI::Auth::TokenExchange.migrate_to_expiring_token(
shop: shop,
non_expiring_offline_token: old_session.access_token
)

# Store the new expiring session, replacing the old one
MyApp::SessionRepository.store_shop_session(new_session)
end
```

#### Migration Strategy
When migrating your app to use expiring tokens, follow this order:

1. **Update your database schema** to add `expires_at` (timestamp), `refresh_token` (string) and `refresh_token_expires` (timestamp) columns to your session storage
2. **Implement refresh logic** in your app to handle token expiration using `ShopifyAPI::Auth::RefreshToken.refresh_access_token`
3. **Enable expiring tokens in your Context setup** so new installations will request and receive expiring tokens:
```ruby
ShopifyAPI::Context.setup(
expiring_offline_access_tokens: true,
# ... other config
)
```
4. **Migrate existing non-expiring tokens** for shops that have already installed your app using the migration method above

## Using OAuth Session to make authenticated API calls
Once your OAuth flow is complete, and you have persisted your `Session` object, you may use that `Session` object to make authenticated API calls.

Expand Down
9 changes: 7 additions & 2 deletions lib/shopify_api/auth/oauth.rb
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,12 @@ def validate_auth_callback(cookies:, auth_query:)
"Invalid state in OAuth callback." unless state == auth_query.state

null_session = Auth::Session.new(shop: auth_query.shop)
body = { client_id: Context.api_key, client_secret: Context.api_secret_key, code: auth_query.code }
body = {
client_id: Context.api_key,
client_secret: Context.api_secret_key,
code: auth_query.code,
expiring: Context.expiring_offline_access_tokens ? 1 : 0, # Only applicable for offline tokens
}

client = Clients::HttpClient.new(session: null_session, base_path: "/admin/oauth")
response = begin
Expand Down Expand Up @@ -100,7 +105,7 @@ def validate_auth_callback(cookies:, auth_query:)
else
SessionCookie.new(
value: session.id,
expires: session.online? ? session.expires : nil,
expires: session.expires ? session.expires : nil,
)
end

Expand Down
6 changes: 5 additions & 1 deletion lib/shopify_api/auth/oauth/access_token_response.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ class AccessTokenResponse < T::Struct
const :expires_in, T.nilable(Integer)
const :associated_user, T.nilable(AssociatedUser)
const :associated_user_scope, T.nilable(String)
const :refresh_token, T.nilable(String)
const :refresh_token_expires_in, T.nilable(Integer)

sig { returns(T::Boolean) }
def online_token?
Expand All @@ -29,7 +31,9 @@ def ==(other)
session == other.session &&
expires_in == other.expires_in &&
associated_user == other.associated_user &&
associated_user_scope == other.associated_user_scope
associated_user_scope == other.associated_user_scope &&
refresh_token == other.refresh_token &&
refresh_token_expires_in == other.refresh_token_expires_in
end
end
end
Expand Down
57 changes: 57 additions & 0 deletions lib/shopify_api/auth/refresh_token.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# typed: strict
# frozen_string_literal: true

module ShopifyAPI
module Auth
module RefreshToken
extend T::Sig

class << self
extend T::Sig

sig do
params(
shop: String,
refresh_token: String,
).returns(ShopifyAPI::Auth::Session)
end
def refresh_access_token(shop:, refresh_token:)
unless ShopifyAPI::Context.setup?
raise ShopifyAPI::Errors::ContextNotSetupError,
"ShopifyAPI::Context not setup, please call ShopifyAPI::Context.setup"
end

shop_session = ShopifyAPI::Auth::Session.new(shop:)
body = {
client_id: ShopifyAPI::Context.api_key,
client_secret: ShopifyAPI::Context.api_secret_key,
grant_type: "refresh_token",
refresh_token:,
}

client = Clients::HttpClient.new(session: shop_session, base_path: "/admin/oauth")
response = begin
client.request(
Clients::HttpRequest.new(
http_method: :post,
path: "access_token",
body:,
body_type: "application/json",
),
)
rescue ShopifyAPI::Errors::HttpResponseError => error
ShopifyAPI::Context.logger.debug("Failed to refresh access token: #{error.message}")
raise error
end

session_params = T.cast(response.body, T::Hash[String, T.untyped]).to_h

Session.from(
shop:,
access_token_response: Oauth::AccessTokenResponse.from_hash(session_params),
)
end
end
end
end
end
30 changes: 27 additions & 3 deletions lib/shopify_api/auth/session.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ class Session
sig { returns(T.nilable(String)) }
attr_accessor :shopify_session_id

sig { returns(T.nilable(String)) }
attr_accessor :refresh_token

sig { returns(T.nilable(Time)) }
attr_accessor :refresh_token_expires

sig { returns(T::Boolean) }
def online?
@is_online
Expand All @@ -40,6 +46,11 @@ def expired?
@expires ? @expires < Time.now : false
end

sig { returns(T::Boolean) }
def refresh_token_expired?
@refresh_token_expires ? @refresh_token_expires < Time.now + 60 : false
end

sig do
params(
shop: String,
Expand All @@ -52,10 +63,12 @@ def expired?
is_online: T.nilable(T::Boolean),
associated_user: T.nilable(AssociatedUser),
shopify_session_id: T.nilable(String),
refresh_token: T.nilable(String),
refresh_token_expires: T.nilable(Time),
).void
end
def initialize(shop:, id: nil, state: nil, access_token: "", scope: [], associated_user_scope: nil, expires: nil,
is_online: nil, associated_user: nil, shopify_session_id: nil)
is_online: nil, associated_user: nil, shopify_session_id: nil, refresh_token: nil, refresh_token_expires: nil)
@id = T.let(id || SecureRandom.uuid, String)
@shop = shop
@state = state
Expand All @@ -68,6 +81,8 @@ def initialize(shop:, id: nil, state: nil, access_token: "", scope: [], associat
@associated_user = associated_user
@is_online = T.let(is_online || !associated_user.nil?, T::Boolean)
@shopify_session_id = shopify_session_id
@refresh_token = refresh_token
@refresh_token_expires = refresh_token_expires
end

class << self
Expand Down Expand Up @@ -105,6 +120,10 @@ def from(shop:, access_token_response:)
expires = Time.now + access_token_response.expires_in.to_i
end

if access_token_response.refresh_token_expires_in
refresh_token_expires = Time.now + access_token_response.refresh_token_expires_in.to_i
end

new(
id: id,
shop: shop,
Expand All @@ -115,6 +134,8 @@ def from(shop:, access_token_response:)
associated_user: associated_user,
expires: expires,
shopify_session_id: access_token_response.session,
refresh_token: access_token_response.refresh_token,
refresh_token_expires: refresh_token_expires,
)
end
end
Expand All @@ -130,6 +151,8 @@ def copy_attributes_from(other)
@associated_user = other.associated_user
@is_online = other.online?
@shopify_session_id = other.shopify_session_id
@refresh_token = other.refresh_token
@refresh_token_expires = other.refresh_token_expires
self
end

Expand All @@ -146,8 +169,9 @@ def ==(other)
(!(expires.nil? ^ other.expires.nil?) && (expires.nil? || expires.to_i == other.expires.to_i)) &&
online? == other.online? &&
associated_user == other.associated_user &&
shopify_session_id == other.shopify_session_id

shopify_session_id == other.shopify_session_id &&
refresh_token == other.refresh_token &&
refresh_token_expires&.to_i == other.refresh_token_expires&.to_i
else
false
end
Expand Down
Loading