diff --git a/CHANGELOG.md b/CHANGELOG.md index 869c91cbb..e99c7cea8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Note: For changes to the API, see https://shopify.dev/changelog?filter=api ## Unreleased +- [#979](https://github.com/Shopify/shopify_api/pull/979) Update `ShopifyAPI::Context.setup` to take `old_api_secret_key` to support API credentials rotation + ## Version 10.1.0 - [#933](https://github.com/Shopify/shopify_api/pull/933) Fix syntax of GraphQL query in `Webhooks.get_webhook_id` method by removing extra curly brace diff --git a/lib/shopify_api/auth/jwt_payload.rb b/lib/shopify_api/auth/jwt_payload.rb index 867baa690..643357c7f 100644 --- a/lib/shopify_api/auth/jwt_payload.rb +++ b/lib/shopify_api/auth/jwt_payload.rb @@ -16,11 +16,12 @@ class JwtPayload sig { params(token: String).void } def initialize(token) - begin - payload_hash = JWT.decode(token, Context.api_secret_key, true, - { exp_leeway: JWT_EXPIRATION_LEEWAY, algorithm: "HS256" })[0] - rescue - raise ShopifyAPI::Errors::InvalidJwtTokenError, "Failed to parse session token '#{token}'" + payload_hash = begin + decode_token(token, Context.api_secret_key) + rescue ShopifyAPI::Errors::InvalidJwtTokenError + raise unless Context.old_api_secret_key + + decode_token(token, T.must(Context.old_api_secret_key)) end @iss = T.let(payload_hash["iss"], String) @@ -67,6 +68,16 @@ def ==(other) jti == other.jti && sid == other.sid end + + private + + sig { params(token: String, api_secret_key: String).returns(T::Hash[String, T.untyped]) } + def decode_token(token, api_secret_key) + JWT.decode(token, api_secret_key, true, + { exp_leeway: JWT_EXPIRATION_LEEWAY, algorithm: "HS256" })[0] + rescue + raise ShopifyAPI::Errors::InvalidJwtTokenError, "Failed to parse session token '#{token}'" + end end end end diff --git a/lib/shopify_api/context.rb b/lib/shopify_api/context.rb index 6c95d63c5..b3282b507 100644 --- a/lib/shopify_api/context.rb +++ b/lib/shopify_api/context.rb @@ -18,6 +18,7 @@ class Context @notified_missing_resources_folder = T.let({}, T::Hash[String, T::Boolean]) @active_session = T.let(Concurrent::ThreadLocalVar.new { nil }, Concurrent::ThreadLocalVar) @user_agent_prefix = T.let(nil, T.nilable(String)) + @old_api_secret_key = T.let(nil, T.nilable(String)) @rest_resource_loader = T.let(nil, T.nilable(Zeitwerk::Loader)) @@ -37,6 +38,7 @@ class << self logger: Logger, private_shop: T.nilable(String), user_agent_prefix: T.nilable(String), + old_api_secret_key: T.nilable(String), ).void end def setup( @@ -50,7 +52,8 @@ def setup( session_storage:, logger: Logger.new($stdout), private_shop: nil, - user_agent_prefix: nil + user_agent_prefix: nil, + old_api_secret_key: nil ) unless ShopifyAPI::AdminVersions::SUPPORTED_ADMIN_VERSIONS.include?(api_version) raise Errors::UnsupportedVersionError, @@ -68,6 +71,7 @@ def setup( @logger = logger @private_shop = private_shop @user_agent_prefix = user_agent_prefix + @old_api_secret_key = old_api_secret_key load_rest_resources(api_version: api_version) end @@ -118,7 +122,7 @@ def private? end sig { returns(T.nilable(String)) } - attr_reader :private_shop, :user_agent_prefix + attr_reader :private_shop, :user_agent_prefix, :old_api_secret_key sig { returns(T::Boolean) } def embedded? diff --git a/test/context_test.rb b/test/context_test.rb index a7f311090..f0626486a 100644 --- a/test/context_test.rb +++ b/test/context_test.rb @@ -18,7 +18,8 @@ def setup session_storage: ShopifyAPI::Auth::FileSessionStorage.new, logger: Logger.new(writer), private_shop: "privateshop.myshopify.com", - user_agent_prefix: "user_agent_prefix1" + user_agent_prefix: "user_agent_prefix1", + old_api_secret_key: "old_secret" ) end @@ -40,6 +41,7 @@ def test_setup assert_match(/test log/, @reader.gets) assert_equal("privateshop.myshopify.com", ShopifyAPI::Context.private_shop) assert_equal("user_agent_prefix1", ShopifyAPI::Context.user_agent_prefix) + assert_equal("old_secret", ShopifyAPI::Context.old_api_secret_key) end def test_active_session_is_thread_safe @@ -125,7 +127,8 @@ def clear_context is_private: false, is_embedded: true, session_storage: ShopifyAPI::Auth::FileSessionStorage.new, - user_agent_prefix: nil + user_agent_prefix: nil, + old_api_secret_key: nil ) end end diff --git a/test/test_helper.rb b/test/test_helper.rb index daa24fcc7..a56140e37 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -31,7 +31,8 @@ def setup is_private: false, is_embedded: false, session_storage: TestHelpers::FakeSessionStorage.new, - user_agent_prefix: nil + user_agent_prefix: nil, + old_api_secret_key: nil ) end @@ -47,7 +48,8 @@ def setup session_storage: T.nilable(ShopifyAPI::Auth::SessionStorage), logger: T.nilable(Logger), private_shop: T.nilable(String), - user_agent_prefix: T.nilable(String) + user_agent_prefix: T.nilable(String), + old_api_secret_key: T.nilable(String) ).void end def modify_context( @@ -61,7 +63,8 @@ def modify_context( session_storage: nil, logger: nil, private_shop: "do-not-set", - user_agent_prefix: nil + user_agent_prefix: nil, + old_api_secret_key: nil ) ShopifyAPI::Context.setup( api_key: api_key ? api_key : ShopifyAPI::Context.api_key, @@ -74,7 +77,8 @@ def modify_context( session_storage: session_storage ? session_storage : ShopifyAPI::Context.session_storage, logger: logger ? logger : ShopifyAPI::Context.logger, private_shop: private_shop != "do-not-set" ? private_shop : ShopifyAPI::Context.private_shop, - user_agent_prefix: user_agent_prefix ? user_agent_prefix : ShopifyAPI::Context.user_agent_prefix + user_agent_prefix: user_agent_prefix ? user_agent_prefix : ShopifyAPI::Context.user_agent_prefix, + old_api_secret_key: old_api_secret_key ? old_api_secret_key : ShopifyAPI::Context.old_api_secret_key ) end end diff --git a/test/utils/session_utils_test.rb b/test/utils/session_utils_test.rb index d80165e98..f5622ccf6 100644 --- a/test/utils/session_utils_test.rb +++ b/test/utils/session_utils_test.rb @@ -84,6 +84,31 @@ def test_fails_if_authorization_header_bearer_token_is_invalid end end + def test_fails_if_authorization_header_be + modify_context(is_embedded: true) + jwt_header = create_jwt_header("UNKNOWN_API_SECRET_KEY") + assert_raises(ShopifyAPI::Errors::InvalidJwtTokenError) do + ShopifyAPI::Utils::SessionUtils.load_current_session(auth_header: jwt_header, is_online: true) + end + end + + def test_decodes_jwt_token_signed_with_old_secret + modify_context(is_embedded: true) + modify_context(old_api_secret_key: "OLD_API_SECRET_KEY") + jwt_header = create_jwt_header(ShopifyAPI::Context.old_api_secret_key) + loaded_session = ShopifyAPI::Utils::SessionUtils.load_current_session(auth_header: jwt_header, is_online: true) + assert_equal(@online_embedded_session, loaded_session) + end + + def test_fails_if_old_api_secret_key_is_invalid + modify_context(is_embedded: true) + modify_context(old_api_secret_key: "OLD_API_SECRET_KEY") + jwt_header = create_jwt_header("UNKNOWN_OLD_API_SECRET_KEY") + assert_raises(ShopifyAPI::Errors::InvalidJwtTokenError) do + ShopifyAPI::Utils::SessionUtils.load_current_session(auth_header: jwt_header, is_online: true) + end + end + def test_fails_if_authorization_header_is_not_a_bearer_token modify_context(is_embedded: true) assert_raises(ShopifyAPI::Errors::MissingJwtTokenError) do @@ -212,6 +237,11 @@ def add_session(is_online:) another_session = ShopifyAPI::Auth::Session.new(shop: @shop, is_online: is_online) ShopifyAPI::Context.session_storage.store_session(another_session) end + + def create_jwt_header(api_secret_key) + jwt_token = JWT.encode(@jwt_payload, api_secret_key, "HS256") + "Bearer #{jwt_token}" + end end end end