diff --git a/CHANGELOG.md b/CHANGELOG.md index f6462f84a..946e96158 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/getting_started.md b/docs/getting_started.md index 58fc4833f..2db381b47 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -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 ) ``` diff --git a/docs/usage/oauth.md b/docs/usage/oauth.md index aec85c50e..0e22cff04 100644 --- a/docs/usage/oauth.md +++ b/docs/usage/oauth.md @@ -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 @@ -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: , + api_secret_key: , + api_version: , + scope: , + 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. diff --git a/lib/shopify_api/auth/oauth.rb b/lib/shopify_api/auth/oauth.rb index eedd19aa4..75897ddab 100644 --- a/lib/shopify_api/auth/oauth.rb +++ b/lib/shopify_api/auth/oauth.rb @@ -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 @@ -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 diff --git a/lib/shopify_api/auth/oauth/access_token_response.rb b/lib/shopify_api/auth/oauth/access_token_response.rb index 504cf3557..a921f9e38 100644 --- a/lib/shopify_api/auth/oauth/access_token_response.rb +++ b/lib/shopify_api/auth/oauth/access_token_response.rb @@ -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? @@ -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 diff --git a/lib/shopify_api/auth/refresh_token.rb b/lib/shopify_api/auth/refresh_token.rb new file mode 100644 index 000000000..0d52fd032 --- /dev/null +++ b/lib/shopify_api/auth/refresh_token.rb @@ -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 diff --git a/lib/shopify_api/auth/session.rb b/lib/shopify_api/auth/session.rb index 3642f4ded..0b7d1f9a3 100644 --- a/lib/shopify_api/auth/session.rb +++ b/lib/shopify_api/auth/session.rb @@ -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 @@ -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, @@ -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 @@ -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 @@ -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, @@ -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 @@ -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 @@ -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 diff --git a/lib/shopify_api/auth/token_exchange.rb b/lib/shopify_api/auth/token_exchange.rb index 134ecd6ff..4741ae0d8 100644 --- a/lib/shopify_api/auth/token_exchange.rb +++ b/lib/shopify_api/auth/token_exchange.rb @@ -49,6 +49,10 @@ def exchange_token(shop:, session_token:, requested_token_type:) requested_token_type: requested_token_type.serialize, } + if requested_token_type == RequestedTokenType::OFFLINE_ACCESS_TOKEN + body.merge!({ expiring: ShopifyAPI::Context.expiring_offline_access_tokens ? 1 : 0 }) + end + client = Clients::HttpClient.new(session: shop_session, base_path: "/admin/oauth") response = begin client.request( @@ -74,6 +78,52 @@ def exchange_token(shop:, session_token:, requested_token_type:) access_token_response: Oauth::AccessTokenResponse.from_hash(session_params), ) end + + sig do + params( + shop: String, + non_expiring_offline_token: String, + ).returns(ShopifyAPI::Auth::Session) + end + def migrate_to_expiring_token(shop:, non_expiring_offline_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: shop) + body = { + client_id: ShopifyAPI::Context.api_key, + client_secret: ShopifyAPI::Context.api_secret_key, + grant_type: TOKEN_EXCHANGE_GRANT_TYPE, + subject_token: non_expiring_offline_token, + subject_token_type: RequestedTokenType::OFFLINE_ACCESS_TOKEN.serialize, + requested_token_type: RequestedTokenType::OFFLINE_ACCESS_TOKEN.serialize, + expiring: "1", + } + + 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, + body_type: "application/json", + ), + ) + rescue ShopifyAPI::Errors::HttpResponseError => error + ShopifyAPI::Context.logger.debug("Failed to migrate to expiring offline token: #{error.message}") + raise error + end + + session_params = T.cast(response.body, T::Hash[String, T.untyped]).to_h + + Session.from( + shop: shop, + access_token_response: Oauth::AccessTokenResponse.from_hash(session_params), + ) + end end end end diff --git a/lib/shopify_api/clients/http_client.rb b/lib/shopify_api/clients/http_client.rb index 6b9d50d42..6aeff5050 100644 --- a/lib/shopify_api/clients/http_client.rb +++ b/lib/shopify_api/clients/http_client.rb @@ -112,6 +112,8 @@ def request_url(request) def serialized_error(response) body = {} body["errors"] = response.body["errors"] if response.body["errors"] + body["error"] = response.body["error"] if response.body["error"] + body["error_description"] = response.body["error_description"] if response.body["error"] if response.headers["x-request-id"] id = T.must(response.headers["x-request-id"])[0] diff --git a/lib/shopify_api/context.rb b/lib/shopify_api/context.rb index df459f86d..e7294ab5d 100644 --- a/lib/shopify_api/context.rb +++ b/lib/shopify_api/context.rb @@ -25,6 +25,7 @@ class Context @rest_disabled = T.let(false, T.nilable(T::Boolean)) @rest_resource_loader = T.let(nil, T.nilable(Zeitwerk::Loader)) + @expiring_offline_access_tokens = T.let(false, T::Boolean) class << self extend T::Sig @@ -47,6 +48,7 @@ class << self api_host: T.nilable(String), response_as_struct: T.nilable(T::Boolean), rest_disabled: T.nilable(T::Boolean), + expiring_offline_access_tokens: T.nilable(T::Boolean), ).void end def setup( @@ -65,7 +67,8 @@ def setup( old_api_secret_key: nil, api_host: nil, response_as_struct: false, - rest_disabled: false + rest_disabled: false, + expiring_offline_access_tokens: false ) unless ShopifyAPI::AdminVersions::SUPPORTED_ADMIN_VERSIONS.include?(api_version) raise Errors::UnsupportedVersionError, @@ -86,6 +89,7 @@ def setup( @old_api_secret_key = old_api_secret_key @response_as_struct = response_as_struct @rest_disabled = rest_disabled + @expiring_offline_access_tokens = T.must(expiring_offline_access_tokens) @log_level = if valid_log_level?(log_level) log_level.to_sym else @@ -148,6 +152,9 @@ def private? sig { returns(T.nilable(String)) } attr_reader :private_shop, :user_agent_prefix, :old_api_secret_key, :host, :api_host + sig { returns(T::Boolean) } + attr_reader :expiring_offline_access_tokens + sig { returns(T::Boolean) } def embedded? @is_embedded diff --git a/test/auth/oauth/access_token_response_test.rb b/test/auth/oauth/access_token_response_test.rb index ad2bdbcb6..6e4a7f93a 100644 --- a/test/auth/oauth/access_token_response_test.rb +++ b/test/auth/oauth/access_token_response_test.rb @@ -37,6 +37,214 @@ def test_online_token_is_true_when_associated_user_is_present assert(token_response.online_token?) end + + def test_equality_with_all_fields_matching + access_token = "access_token" + scope = "scope1, scope2" + session = "session_id" + expires_in = 3600 + associated_user = ShopifyAPI::Auth::AssociatedUser.new( + id: 902541635, + first_name: "first", + last_name: "last", + email: "firstlast@example.com", + email_verified: true, + account_owner: true, + locale: "en", + collaborator: false, + ) + associated_user_scope = "user_scope" + refresh_token = "refresh_token_value" + refresh_token_expires_in = 2000 + + token_response1 = ShopifyAPI::Auth::Oauth::AccessTokenResponse.new( + access_token:, + scope:, + session:, + expires_in:, + associated_user:, + associated_user_scope:, + refresh_token:, + refresh_token_expires_in:, + ) + token_response2 = ShopifyAPI::Auth::Oauth::AccessTokenResponse.new( + access_token:, + scope:, + session:, + expires_in:, + associated_user:, + associated_user_scope:, + refresh_token:, + refresh_token_expires_in:, + ) + + assert_equal(token_response1, token_response2) + end + + def test_inequality_with_different_access_token + scope = "scope1, scope2" + + token_response1 = ShopifyAPI::Auth::Oauth::AccessTokenResponse.new( + access_token: "access_token_1", + scope:, + ) + token_response2 = ShopifyAPI::Auth::Oauth::AccessTokenResponse.new( + access_token: "access_token_2", + scope:, + ) + + refute_equal(token_response1, token_response2) + end + + def test_inequality_with_different_scope + access_token = "access_token" + + token_response1 = ShopifyAPI::Auth::Oauth::AccessTokenResponse.new( + access_token:, + scope: "scope1, scope2", + ) + token_response2 = ShopifyAPI::Auth::Oauth::AccessTokenResponse.new( + access_token:, + scope: "scope3, scope4", + ) + + refute_equal(token_response1, token_response2) + end + + def test_inequality_with_different_session + access_token = "access_token" + scope = "scope1, scope2" + + token_response1 = ShopifyAPI::Auth::Oauth::AccessTokenResponse.new( + access_token:, + scope:, + session: "session_1", + ) + token_response2 = ShopifyAPI::Auth::Oauth::AccessTokenResponse.new( + access_token:, + scope:, + session: "session_2", + ) + + refute_equal(token_response1, token_response2) + end + + def test_inequality_with_different_expires_in + access_token = "access_token" + scope = "scope1, scope2" + + token_response1 = ShopifyAPI::Auth::Oauth::AccessTokenResponse.new( + access_token:, + scope:, + expires_in: 3600, + ) + token_response2 = ShopifyAPI::Auth::Oauth::AccessTokenResponse.new( + access_token:, + scope:, + expires_in: 7200, + ) + + refute_equal(token_response1, token_response2) + end + + def test_inequality_with_different_associated_user + access_token = "access_token" + scope = "scope1, scope2" + associated_user1 = ShopifyAPI::Auth::AssociatedUser.new( + id: 1, + first_name: "first", + last_name: "last", + email: "firstlast@example.com", + email_verified: true, + account_owner: true, + locale: "en", + collaborator: false, + ) + associated_user2 = ShopifyAPI::Auth::AssociatedUser.new( + id: 2, + first_name: "other", + last_name: "user", + email: "otheruser@example.com", + email_verified: true, + account_owner: false, + locale: "en", + collaborator: true, + ) + + token_response1 = ShopifyAPI::Auth::Oauth::AccessTokenResponse.new( + access_token:, + scope:, + associated_user: associated_user1, + ) + token_response2 = ShopifyAPI::Auth::Oauth::AccessTokenResponse.new( + access_token:, + scope:, + associated_user: associated_user2, + ) + + refute_equal(token_response1, token_response2) + end + + def test_inequality_with_different_associated_user_scope + access_token = "access_token" + scope = "scope1, scope2" + + token_response1 = ShopifyAPI::Auth::Oauth::AccessTokenResponse.new( + access_token:, + scope:, + associated_user_scope: "user_scope_1", + ) + token_response2 = ShopifyAPI::Auth::Oauth::AccessTokenResponse.new( + access_token:, + scope:, + associated_user_scope: "user_scope_2", + ) + + refute_equal(token_response1, token_response2) + end + + def test_refresh_token_can_be_set + token_response = ShopifyAPI::Auth::Oauth::AccessTokenResponse.new( + access_token: "token", + scope: "scope1, scope2", + expires_in: 1000, + refresh_token: "refresh_token_value", + refresh_token_expires_in: 2000, + ) + + assert_equal("refresh_token_value", token_response.refresh_token) + assert_equal(2000, token_response.refresh_token_expires_in) + end + + def test_inequality_with_different_refresh_token + access_token = "access_token" + scope = "scope1, scope2" + + token_response1 = ShopifyAPI::Auth::Oauth::AccessTokenResponse.new(access_token:, scope:, + refresh_token: "refresh_token_1") + token_response2 = ShopifyAPI::Auth::Oauth::AccessTokenResponse.new(access_token:, scope:, + refresh_token: "refresh_token_2") + + refute_equal(token_response1, token_response2) + end + + def test_inequality_with_different_refresh_token_expires_in + access_token = "access_token" + scope = "scope1, scope2" + + token_response1 = ShopifyAPI::Auth::Oauth::AccessTokenResponse.new( + access_token:, + scope:, + refresh_token_expires_in: 123, + ) + token_response2 = ShopifyAPI::Auth::Oauth::AccessTokenResponse.new( + access_token:, + scope:, + refresh_token_expires_in: 321, + ) + + refute_equal(token_response1, token_response2) + end end end end diff --git a/test/auth/oauth_test.rb b/test/auth/oauth_test.rb index d8ed15c41..ad26e30a3 100644 --- a/test/auth/oauth_test.rb +++ b/test/auth/oauth_test.rb @@ -40,12 +40,24 @@ def setup client_id: ShopifyAPI::Context.api_key, client_secret: ShopifyAPI::Context.api_secret_key, code: @callback_code, + expiring: 0, } + @expiring_access_token_request = @access_token_request.merge({ expiring: 1 }) + @offline_token_response = { access_token: SecureRandom.alphanumeric(10), scope: "scope1,scope2", } + + @expiring_offline_token_response = @offline_token_response.merge( + { + expires_in: 1000, + refresh_token: SecureRandom.alphanumeric(10), + refresh_token_expires_in: 2000, + }, + ) + @online_token_response = { access_token: SecureRandom.alphanumeric(10), scope: "scope1,scope2", @@ -123,7 +135,7 @@ def test_begin_auth_private_app end def test_validate_auth_callback_offline - modify_context(is_embedded: false) + modify_context(is_embedded: false, expiring_offline_access_tokens: false) stub_request(:post, "https://#{@shop}/admin/oauth/access_token") .with(body: @access_token_request) @@ -155,7 +167,7 @@ def test_validate_auth_callback_offline_embedded ) expected_cookie = ShopifyAPI::Auth::Oauth::SessionCookie.new(value: "", expires: @stubbed_time_now) - modify_context(is_embedded: true) + modify_context(is_embedded: true, expiring_offline_access_tokens: false) got = Time.stub(:now, @stubbed_time_now) do ShopifyAPI::Auth::Oauth.validate_auth_callback(cookies: @cookies, auth_query: @auth_query) @@ -164,6 +176,34 @@ def test_validate_auth_callback_offline_embedded verify_oauth_complete(got: got, expected_session: expected_session, expected_cookie: expected_cookie) end + def test_validate_auth_callback_offline_token_with_expiring_token_enabled + modify_context(expiring_offline_access_tokens: true) + + stub_request(:post, "https://#{@shop}/admin/oauth/access_token") + .with(body: @expiring_access_token_request) + .to_return(body: @expiring_offline_token_response.to_json, headers: { content_type: "application/json" }) + + expected_session = ShopifyAPI::Auth::Session.new( + id: "offline_#{@shop}", + shop: @shop, + access_token: @offline_token_response[:access_token], + scope: @offline_token_response[:scope], + expires: @stubbed_time_now + @online_token_response[:expires_in].to_i, + refresh_token: @expiring_offline_token_response[:refresh_token], + refresh_token_expires: @stubbed_time_now + @expiring_offline_token_response[:refresh_token_expires_in].to_i, + ) + expected_cookie = ShopifyAPI::Auth::Oauth::SessionCookie.new( + value: "offline_#{@shop}", + expires: expected_session.expires, + ) + + got = Time.stub(:now, @stubbed_time_now) do + ShopifyAPI::Auth::Oauth.validate_auth_callback(cookies: @cookies, auth_query: @auth_query) + end + + verify_oauth_complete(got:, expected_session:, expected_cookie:) + end + def test_validate_auth_callback_online stub_request(:post, "https://#{@shop}/admin/oauth/access_token") .with(body: @access_token_request) diff --git a/test/auth/refresh_token_test.rb b/test/auth/refresh_token_test.rb new file mode 100644 index 000000000..9d5a7391d --- /dev/null +++ b/test/auth/refresh_token_test.rb @@ -0,0 +1,92 @@ +# typed: false +# frozen_string_literal: true + +require_relative "../test_helper" + +module ShopifyAPITest + module Auth + class RefreshTokenTest < Test::Unit::TestCase + def setup + super() + + @stubbed_time_now = Time.now + @shop = "test-shop.myshopify.com" + @refresh_token = "refresh_token_#{SecureRandom.alphanumeric(10)}" + + @refresh_token_request = { + client_id: ShopifyAPI::Context.api_key, + client_secret: ShopifyAPI::Context.api_secret_key, + grant_type: "refresh_token", + refresh_token: @refresh_token, + } + + @refresh_token_response = { + access_token: "access_token_#{SecureRandom.alphanumeric(10)}", + expires_in: 1000, + refresh_token: "refresh_token_#{SecureRandom.alphanumeric(10)}", + refresh_token_expires_in: 3000, + scope: "write_products,read_orders", + } + end + + def test_refresh_access_token_success + stub_request(:post, "https://#{@shop}/admin/oauth/access_token") + .with(body: @refresh_token_request) + .to_return( + body: @refresh_token_response.to_json, + headers: { "Content-Type" => "application/json" }, + ) + + expected_session = ShopifyAPI::Auth::Session.new( + id: "offline_#{@shop}", + shop: @shop, + access_token: @refresh_token_response[:access_token], + scope: @refresh_token_response[:scope], + is_online: false, + expires: @stubbed_time_now + @refresh_token_response[:expires_in].to_i, + refresh_token: @refresh_token_response[:refresh_token], + refresh_token_expires: @stubbed_time_now + @refresh_token_response[:refresh_token_expires_in].to_i, + ) + + session = Time.stub(:now, @stubbed_time_now) do + ShopifyAPI::Auth::RefreshToken.refresh_access_token( + shop: @shop, + refresh_token: @refresh_token, + ) + end + + assert_equal(expected_session, session) + end + + def test_refresh_access_token_context_not_setup + modify_context(api_key: "", api_secret_key: "", host: "") + + assert_raises(ShopifyAPI::Errors::ContextNotSetupError) do + ShopifyAPI::Auth::RefreshToken.refresh_access_token( + shop: @shop, + refresh_token: @refresh_token, + ) + end + end + + def test_refresh_access_token_unauthorized + stub_request(:post, "https://#{@shop}/admin/oauth/access_token") + .with(body: @refresh_token_request) + .to_return( + status: 401, + body: { error: "unauthorized" }.to_json, + headers: { "Content-Type" => "application/json" }, + ) + + ShopifyAPI::Context.logger.expects(:debug).with(regexp_matches(/Failed to refresh access token/)) + + assert_raises(ShopifyAPI::Errors::HttpResponseError) do + ShopifyAPI::Auth::RefreshToken.refresh_access_token( + shop: @shop, + refresh_token: @refresh_token, + ) + end + end + end + end +end diff --git a/test/auth/session_test.rb b/test/auth/session_test.rb index 065b43433..bb61775c8 100644 --- a/test/auth/session_test.rb +++ b/test/auth/session_test.rb @@ -52,6 +52,36 @@ def test_expired_with_passed_expiry_date assert(session.expired?) end + def test_refresh_token_expired_with_no_expiry_date + session = ShopifyAPI::Auth::Session.new(shop: "test-shop", refresh_token_expires: nil) + + refute(session.refresh_token_expired?) + end + + def test_refresh_token_expired_with_future_expiry_date + session = ShopifyAPI::Auth::Session.new(shop: "test-shop", refresh_token_expires: Time.now + 1 * 60 * 60) + + refute(session.refresh_token_expired?) + end + + def test_refresh_token_expired_with_passed_expiry_date + session = ShopifyAPI::Auth::Session.new(shop: "test-shop", refresh_token_expires: Time.now - 1) + + assert(session.refresh_token_expired?) + end + + def test_refresh_token_expiring_within_buffer + session = ShopifyAPI::Auth::Session.new(shop: "test-shop", refresh_token_expires: Time.now + 59) + + assert(session.refresh_token_expired?) + end + + def test_refresh_token_expiring_immediately_before_buffer + session = ShopifyAPI::Auth::Session.new(shop: "test-shop", refresh_token_expires: Time.now + 61) + + refute(session.refresh_token_expired?) + end + def test_temp session = ShopifyAPI::Auth::Session.new(shop: "test-shop1", access_token: "token1") @@ -96,6 +126,8 @@ def test_from_with_offline_access_token_response_with_no_expires_in associated_user: nil, expires: nil, shopify_session_id: response.session, + refresh_token: nil, + refresh_token_expires: nil, ) session = ShopifyAPI::Auth::Session.from(shop: shop, access_token_response: response) @@ -121,6 +153,36 @@ def test_from_with_offline_access_token_response_with_expires_in associated_user: nil, expires: Time.now + response.expires_in, shopify_session_id: response.session, + refresh_token: nil, + refresh_token_expires: nil, + ) + + session = ShopifyAPI::Auth::Session.from(shop: shop, access_token_response: response) + assert_equal(expected_session, session) + end + + def test_from_with_expiring_offline_access_token_response + shop = "test-shop" + response = ShopifyAPI::Auth::Oauth::AccessTokenResponse.new( + access_token: "token", + scope: "scope1, scope2", + expires_in: 1000, + refresh_token: "refresh_token", + refresh_token_expires_in: 2000, + ) + + expected_session = ShopifyAPI::Auth::Session.new( + id: "offline_#{shop}", + shop: shop, + access_token: response.access_token, + scope: response.scope, + is_online: false, + associated_user_scope: nil, + associated_user: nil, + expires: Time.now + response.expires_in, + shopify_session_id: response.session, + refresh_token: response.refresh_token, + refresh_token_expires: Time.now + response.refresh_token_expires_in, ) session = ShopifyAPI::Auth::Session.from(shop: shop, access_token_response: response) @@ -158,6 +220,8 @@ def test_from_with_online_access_token_response associated_user: associated_user, expires: time_now + response.expires_in, shopify_session_id: response.session, + refresh_token: nil, + refresh_token_expires: nil, ) session = Time.stub(:now, time_now) do @@ -179,6 +243,8 @@ def test_copy_attributes_from associated_user: build_user, is_online: true, shopify_session_id: "123", + refresh_token: "to-refresh-token", + refresh_token_expires: Time.now - 7200, ) session_from = ShopifyAPI::Auth::Session.new( @@ -192,6 +258,8 @@ def test_copy_attributes_from associated_user: build_user, is_online: true, shopify_session_id: "456", + refresh_token: "from-refresh-token", + refresh_token_expires: Time.now + 7200, ) assert_equal(session_to, session_to.copy_attributes_from(session_from)) @@ -204,6 +272,211 @@ def test_copy_attributes_from assert_equal(session_from.expires, session_to.expires) assert_equal(session_from.associated_user, session_to.associated_user) assert_equal(session_from.shopify_session_id, session_to.shopify_session_id) + assert_equal(session_from.refresh_token, session_to.refresh_token) + assert_equal(session_from.refresh_token_expires, session_to.refresh_token_expires) + end + + def test_equality_with_all_fields_matching + id = "session-id" + shop = "test-shop" + state = "test-state" + scope = "read_products,write_products" + associated_user_scope = "read_products" + expires = Time.now + 3600 + associated_user = build_user + shopify_session_id = "shopify-session-123" + refresh_token = "refresh-token-abc" + refresh_token_expires = Time.now + 7200 + + session1 = ShopifyAPI::Auth::Session.new( + id:, + shop:, + state:, + scope:, + associated_user_scope:, + expires:, + is_online: true, + associated_user:, + shopify_session_id:, + refresh_token:, + refresh_token_expires:, + ) + + session2 = ShopifyAPI::Auth::Session.new( + id:, + shop:, + state:, + scope:, + associated_user_scope:, + expires:, + is_online: true, + associated_user:, + shopify_session_id:, + refresh_token:, + refresh_token_expires:, + ) + + assert_equal(session1, session2) + end + + def test_inequality_with_different_id + shop = "test-shop" + + session1 = ShopifyAPI::Auth::Session.new(id: "id-1", shop:) + session2 = ShopifyAPI::Auth::Session.new(id: "id-2", shop:) + + refute_equal(session1, session2) + end + + def test_inequality_with_different_shop + id = "session-id" + + session1 = ShopifyAPI::Auth::Session.new(id: id, shop: "shop-1") + session2 = ShopifyAPI::Auth::Session.new(id: id, shop: "shop-2") + + refute_equal(session1, session2) + end + + def test_inequality_with_different_state + id = "session-id" + shop = "test-shop" + + session1 = ShopifyAPI::Auth::Session.new(id: id, shop: shop, state: "state-1") + session2 = ShopifyAPI::Auth::Session.new(id: id, shop: shop, state: "state-2") + + refute_equal(session1, session2) + end + + def test_inequality_with_different_scope + id = "session-id" + shop = "test-shop" + + session1 = ShopifyAPI::Auth::Session.new(id: id, shop: shop, scope: "read_products") + session2 = ShopifyAPI::Auth::Session.new(id: id, shop: shop, scope: "write_products") + + refute_equal(session1, session2) + end + + def test_inequality_with_different_associated_user_scope + id = "session-id" + shop = "test-shop" + + session1 = ShopifyAPI::Auth::Session.new( + id: id, + shop: shop, + associated_user_scope: "read_products", + ) + session2 = ShopifyAPI::Auth::Session.new( + id: id, + shop: shop, + associated_user_scope: "write_products", + ) + + refute_equal(session1, session2) + end + + def test_inequality_with_different_expires + id = "session-id" + shop = "test-shop" + + session1 = ShopifyAPI::Auth::Session.new(id: id, shop: shop, expires: Time.now + 3600) + session2 = ShopifyAPI::Auth::Session.new(id: id, shop: shop, expires: Time.now + 7200) + + refute_equal(session1, session2) + end + + def test_inequality_with_different_is_online + id = "session-id" + shop = "test-shop" + + session1 = ShopifyAPI::Auth::Session.new(id: id, shop: shop, is_online: true) + session2 = ShopifyAPI::Auth::Session.new(id: id, shop: shop, is_online: false) + + refute_equal(session1, session2) + end + + def test_inequality_with_different_associated_user + id = "session-id" + shop = "test-shop" + user1 = ShopifyAPI::Auth::AssociatedUser.new( + id: 1, + first_name: "first", + last_name: "last", + email: "test@example.com", + email_verified: true, + account_owner: true, + locale: "en", + collaborator: false, + ) + user2 = ShopifyAPI::Auth::AssociatedUser.new( + id: 2, + first_name: "other", + last_name: "user", + email: "other@example.com", + email_verified: true, + account_owner: false, + locale: "en", + collaborator: true, + ) + + session1 = ShopifyAPI::Auth::Session.new(id: id, shop: shop, associated_user: user1) + session2 = ShopifyAPI::Auth::Session.new(id: id, shop: shop, associated_user: user2) + + refute_equal(session1, session2) + end + + def test_inequality_with_different_shopify_session_id + id = "session-id" + shop = "test-shop" + + session1 = ShopifyAPI::Auth::Session.new( + id: id, + shop: shop, + shopify_session_id: "shopify-session-1", + ) + session2 = ShopifyAPI::Auth::Session.new( + id: id, + shop: shop, + shopify_session_id: "shopify-session-2", + ) + + refute_equal(session1, session2) + end + + def test_inequality_with_different_refresh_token + id = "session-id" + shop = "test-shop" + + session1 = ShopifyAPI::Auth::Session.new( + id: id, + shop: shop, + refresh_token: "refresh-token-1", + ) + session2 = ShopifyAPI::Auth::Session.new( + id: id, + shop: shop, + refresh_token: "refresh-token-2", + ) + + refute_equal(session1, session2) + end + + def test_inequality_with_different_refresh_token_expires + id = "session-id" + shop = "test-shop" + + session1 = ShopifyAPI::Auth::Session.new( + id: id, + shop: shop, + refresh_token_expires: Time.now + 3600, + ) + session2 = ShopifyAPI::Auth::Session.new( + id: id, + shop: shop, + refresh_token_expires: Time.now + 7200, + ) + + refute_equal(session1, session2) end def teardown diff --git a/test/auth/token_exchange_test.rb b/test/auth/token_exchange_test.rb index 3fa643326..c3989bf8b 100644 --- a/test/auth/token_exchange_test.rb +++ b/test/auth/token_exchange_test.rb @@ -23,7 +23,7 @@ def setup sid: "abc123", } @session_token = JWT.encode(@jwt_payload, ShopifyAPI::Context.api_secret_key, "HS256") - @token_exchange_request = { + base_offline_token_exchange_request = { client_id: ShopifyAPI::Context.api_key, client_secret: ShopifyAPI::Context.api_secret_key, grant_type: "urn:ietf:params:oauth:grant-type:token-exchange", @@ -31,11 +31,26 @@ def setup subject_token: @session_token, requested_token_type: "urn:shopify:params:oauth:token-type:offline-access-token", } + @non_expiring_offline_token_exchange_request = base_offline_token_exchange_request.merge({ expiring: 0 }) + @expiring_offline_token_exchange_request = base_offline_token_exchange_request.merge({ expiring: 1 }) + + @online_token_exchange_request = base_offline_token_exchange_request.merge( + { requested_token_type: "urn:shopify:params:oauth:token-type:online-access-token" }, + ) + @offline_token_response = { access_token: SecureRandom.alphanumeric(10), scope: "scope1,scope2", session: SecureRandom.alphanumeric(10), } + @expiring_offline_token_response = @offline_token_response.merge( + { + expires_in: 2000, + refresh_token: SecureRandom.alphanumeric(10), + refresh_token_expires_in: 4000, + }, + ) + @online_token_response = { access_token: SecureRandom.alphanumeric(10), scope: "scope1,scope2", @@ -117,7 +132,7 @@ def test_exchange_token_invalid_session_token def test_exchange_token_rejected_session_token modify_context(is_embedded: true) stub_request(:post, "https://#{@shop}/admin/oauth/access_token") - .with(body: @token_exchange_request) + .with(body: @non_expiring_offline_token_exchange_request) .to_return( status: 400, body: { error: "invalid_subject_token" }.to_json, @@ -134,9 +149,9 @@ def test_exchange_token_rejected_session_token end def test_exchange_token_offline_token - modify_context(is_embedded: true) + modify_context(is_embedded: true, expiring_offline_access_tokens: false) stub_request(:post, "https://#{@shop}/admin/oauth/access_token") - .with(body: @token_exchange_request) + .with(body: @non_expiring_offline_token_exchange_request) .to_return(body: @offline_token_response.to_json, headers: { content_type: "application/json" }) expected_session = ShopifyAPI::Auth::Session.new( id: "offline_#{@shop}", @@ -146,6 +161,8 @@ def test_exchange_token_offline_token is_online: false, expires: nil, shopify_session_id: @offline_token_response[:session], + refresh_token: nil, + refresh_token_expires: nil, ) session = ShopifyAPI::Auth::TokenExchange.exchange_token( @@ -157,12 +174,38 @@ def test_exchange_token_offline_token assert_equal(expected_session, session) end + def test_exchange_token_expiring_offline_token + modify_context(is_embedded: true, expiring_offline_access_tokens: true) + stub_request(:post, "https://#{@shop}/admin/oauth/access_token") + .with(body: @expiring_offline_token_exchange_request) + .to_return(body: @expiring_offline_token_response.to_json, headers: { content_type: "application/json" }) + expected_session = ShopifyAPI::Auth::Session.new( + id: "offline_#{@shop}", + shop: @shop, + access_token: @expiring_offline_token_response[:access_token], + scope: @expiring_offline_token_response[:scope], + is_online: false, + expires: @stubbed_time_now + @expiring_offline_token_response[:expires_in].to_i, + shopify_session_id: @expiring_offline_token_response[:session], + refresh_token: @expiring_offline_token_response[:refresh_token], + refresh_token_expires: @stubbed_time_now + @expiring_offline_token_response[:refresh_token_expires_in].to_i, + ) + + session = Time.stub(:now, @stubbed_time_now) do + ShopifyAPI::Auth::TokenExchange.exchange_token( + shop: @shop, + session_token: @session_token, + requested_token_type: ShopifyAPI::Auth::TokenExchange::RequestedTokenType::OFFLINE_ACCESS_TOKEN, + ) + end + + assert_equal(expected_session, session) + end + def test_exchange_token_online_token modify_context(is_embedded: true) stub_request(:post, "https://#{@shop}/admin/oauth/access_token") - .with(body: @token_exchange_request.dup.tap do |h| - h[:requested_token_type] = "urn:shopify:params:oauth:token-type:online-access-token" - end) + .with(body: @online_token_exchange_request) .to_return(body: @online_token_response.to_json, headers: { content_type: "application/json" }) expected_session = ShopifyAPI::Auth::Session.new( id: "#{@shop}_#{@online_token_response[:associated_user][:id]}", @@ -185,6 +228,89 @@ def test_exchange_token_online_token assert_equal(expected_session, session) end + + def test_migrate_to_expiring_token_context_not_setup + modify_context(api_key: "", api_secret_key: "", host: "") + + assert_raises(ShopifyAPI::Errors::ContextNotSetupError) do + ShopifyAPI::Auth::TokenExchange.migrate_to_expiring_token( + shop: @shop, + non_expiring_offline_token: "old-offline-token-123", + ) + end + end + + def test_migrate_to_expiring_token_success + non_expiring_token = "old-offline-token-123" + migration_request = { + client_id: ShopifyAPI::Context.api_key, + client_secret: ShopifyAPI::Context.api_secret_key, + grant_type: "urn:ietf:params:oauth:grant-type:token-exchange", + subject_token: non_expiring_token, + subject_token_type: "urn:shopify:params:oauth:token-type:offline-access-token", + requested_token_type: "urn:shopify:params:oauth:token-type:offline-access-token", + expiring: "1", + } + + stub_request(:post, "https://#{@shop}/admin/oauth/access_token") + .with( + body: migration_request, + headers: { "Content-Type" => "application/json" }, + ) + .to_return(body: @expiring_offline_token_response.to_json, headers: { content_type: "application/json" }) + + expected_session = ShopifyAPI::Auth::Session.new( + id: "offline_#{@shop}", + shop: @shop, + access_token: @expiring_offline_token_response[:access_token], + scope: @expiring_offline_token_response[:scope], + is_online: false, + expires: @stubbed_time_now + @expiring_offline_token_response[:expires_in].to_i, + shopify_session_id: @expiring_offline_token_response[:session], + refresh_token: @expiring_offline_token_response[:refresh_token], + refresh_token_expires: @stubbed_time_now + @expiring_offline_token_response[:refresh_token_expires_in].to_i, + ) + + session = Time.stub(:now, @stubbed_time_now) do + ShopifyAPI::Auth::TokenExchange.migrate_to_expiring_token( + shop: @shop, + non_expiring_offline_token: non_expiring_token, + ) + end + + assert_equal(expected_session, session) + end + + def test_migrate_to_expiring_token_http_error + non_expiring_token = "old-offline-token-123" + migration_request = { + client_id: ShopifyAPI::Context.api_key, + client_secret: ShopifyAPI::Context.api_secret_key, + grant_type: "urn:ietf:params:oauth:grant-type:token-exchange", + subject_token: non_expiring_token, + subject_token_type: "urn:shopify:params:oauth:token-type:offline-access-token", + requested_token_type: "urn:shopify:params:oauth:token-type:offline-access-token", + expiring: "1", + } + + stub_request(:post, "https://#{@shop}/admin/oauth/access_token") + .with( + body: migration_request, + headers: { "Content-Type" => "application/json" }, + ) + .to_return( + status: 400, + body: { error: "invalid_subject_token" }.to_json, + headers: { content_type: "application/json" }, + ) + + assert_raises(ShopifyAPI::Errors::HttpResponseError) do + ShopifyAPI::Auth::TokenExchange.migrate_to_expiring_token( + shop: @shop, + non_expiring_offline_token: non_expiring_token, + ) + end + end end end end diff --git a/test/clients/http_client_test.rb b/test/clients/http_client_test.rb index 6f3ab625f..3f2282d2b 100644 --- a/test/clients/http_client_test.rb +++ b/test/clients/http_client_test.rb @@ -175,6 +175,27 @@ def test_error_message_structure assert(parsed_error["error_reference"]) end + def test_error_message_with_oauth_error_and_description + error_response_body = { + "error": "invalid_subject_token", + "error_description": "The subject token is invalid or has expired", + }.to_json + response_headers = { + "x-request-id": 9012, + } + stub_request(@request.http_method, "https://#{@shop}#{@base_path}/#{@request.path}") + .with(body: @request.body.to_json, query: @request.query, headers: @expected_headers) + .to_return(body: error_response_body, status: 400, headers: response_headers) + + response = assert_raises(ShopifyAPI::Errors::HttpResponseError) do + @client.request(@request) + end + parsed_error = JSON.parse(response.message) + assert_equal("invalid_subject_token", parsed_error["error"]) + assert_equal("The subject token is invalid or has expired", parsed_error["error_description"]) + assert(parsed_error["error_reference"]) + end + def test_non_retriable_error_code stub_request(@request.http_method, "https://#{@shop}#{@base_path}/#{@request.path}") .with(body: @request.body.to_json, query: @request.query, headers: @expected_headers) diff --git a/test/context_test.rb b/test/context_test.rb index a508a56c6..569916b41 100644 --- a/test/context_test.rb +++ b/test/context_test.rb @@ -201,6 +201,29 @@ def test_rest_disabled_can_be_set_in_setup assert(ShopifyAPI::Context.rest_disabled) end + def test_expiring_offline_access_tokens_defaults_to_false + ShopifyAPI::Context.setup( + api_key: "test-key", + api_secret_key: "test-secret-key", + api_version: "2023-01", + is_private: true, + is_embedded: false, + ) + refute(ShopifyAPI::Context.expiring_offline_access_tokens) + end + + def test_expiring_offline_access_tokens_can_be_configured + ShopifyAPI::Context.setup( + api_key: "test-key", + api_secret_key: "test-secret-key", + api_version: "2023-01", + is_private: true, + is_embedded: false, + expiring_offline_access_tokens: true, + ) + assert(ShopifyAPI::Context.expiring_offline_access_tokens) + end + def teardown ShopifyAPI::Context.deactivate_session end diff --git a/test/test_helper.rb b/test/test_helper.rb index 69409f58d..b5a856f0a 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -53,6 +53,7 @@ def setup old_api_secret_key: T.nilable(String), response_as_struct: T.nilable(T::Boolean), api_host: T.nilable(String), + expiring_offline_access_tokens: T.nilable(T::Boolean), ).void end def modify_context( @@ -68,7 +69,8 @@ def modify_context( user_agent_prefix: nil, old_api_secret_key: nil, response_as_struct: nil, - api_host: nil + api_host: nil, + expiring_offline_access_tokens: nil ) ShopifyAPI::Context.setup( api_key: api_key ? api_key : ShopifyAPI::Context.api_key, @@ -85,6 +87,12 @@ def modify_context( log_level: :off, response_as_struct: response_as_struct || ShopifyAPI::Context.response_as_struct, api_host: api_host || ShopifyAPI::Context.api_host, + expiring_offline_access_tokens: + if !expiring_offline_access_tokens.nil? + expiring_offline_access_tokens + else + ShopifyAPI::Context.expiring_offline_access_tokens + end, ) end end