From 1b440b9191e66727c0222095e1ead65c62ec96f5 Mon Sep 17 00:00:00 2001 From: Zoey Lan Date: Wed, 17 Apr 2024 13:59:04 -0600 Subject: [PATCH 1/5] Add new SessionUtil method to retrieve session id from shopify ID token --- lib/shopify_api/utils/session_utils.rb | 17 +++++++++ test/utils/session_utils_test.rb | 52 ++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 test/utils/session_utils_test.rb diff --git a/lib/shopify_api/utils/session_utils.rb b/lib/shopify_api/utils/session_utils.rb index 05fda8a19..3fa4d5108 100644 --- a/lib/shopify_api/utils/session_utils.rb +++ b/lib/shopify_api/utils/session_utils.rb @@ -48,6 +48,23 @@ def current_session_id(auth_header, cookies, online) end end + sig do + params( + id_token: String, + online: T::Boolean, + ).returns(String) + end + def session_id_from_shopify_id_token(id_token:, online:) + payload = Auth::JwtPayload.new(id_token) + shop = payload.shop + + if online + jwt_session_id(shop, payload.sub) + else + offline_session_id(shop) + end + end + sig { params(shop: String, user_id: String).returns(String) } def jwt_session_id(shop, user_id) "#{shop}_#{user_id}" diff --git a/test/utils/session_utils_test.rb b/test/utils/session_utils_test.rb new file mode 100644 index 000000000..fb232d2ba --- /dev/null +++ b/test/utils/session_utils_test.rb @@ -0,0 +1,52 @@ +# typed: false +# frozen_string_literal: true + +require_relative "../test_helper" + +module ShopifyAPITest + module Utils + class SessionUtils < Test::Unit::TestCase + def setup + super + @user_id = "my_user_id" + @shop = "test-shop.myshopify.io" + + @jwt_payload = { + iss: "https://#{@shop}/admin", + dest: "https://#{@shop}", + aud: ShopifyAPI::Context.api_key, + sub: @user_id, + exp: (Time.now + 10).to_i, + nbf: 1234, + iat: 1234, + jti: "4321", + sid: "abc123", + } + + @jwt_token = JWT.encode(@jwt_payload, ShopifyAPI::Context.api_secret_key, "HS256") + end + + def test_gets_online_session_id_from_shopify_id_token + expected_session_id = "#{@shop}_#{@user_id}" + assert_equal( + expected_session_id, + ShopifyAPI::Utils::SessionUtils.session_id_from_shopify_id_token(id_token: @jwt_token, online: true), + ) + end + + def test_gets_offline_session_id_from_shopify_id_token + expected_session_id = "offline_#{@shop}" + assert_equal( + expected_session_id, + ShopifyAPI::Utils::SessionUtils.session_id_from_shopify_id_token(id_token: @jwt_token, online: false), + ) + end + + def test_session_id_from_shopify_id_token_raises_invalid_jwt_errors + assert_raises(ShopifyAPI::Errors::InvalidJwtTokenError) do + ShopifyAPI::Utils::SessionUtils.session_id_from_shopify_id_token(id_token: "invalid_token", online: true) + end + end + end + end +end From e2d3ac860f1c5cd290d30c3796b10dc1f81770f2 Mon Sep 17 00:00:00 2001 From: Zoey Lan Date: Wed, 17 Apr 2024 14:35:22 -0600 Subject: [PATCH 2/5] Refactor SessionUtil to use new method and add test coverage --- lib/shopify_api/utils/session_utils.rb | 9 +-- test/utils/session_utils_test.rb | 83 ++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 8 deletions(-) diff --git a/lib/shopify_api/utils/session_utils.rb b/lib/shopify_api/utils/session_utils.rb index 3fa4d5108..ee59c3c46 100644 --- a/lib/shopify_api/utils/session_utils.rb +++ b/lib/shopify_api/utils/session_utils.rb @@ -25,14 +25,7 @@ def current_session_id(auth_header, cookies, online) raise Errors::MissingJwtTokenError, "Missing Bearer token in authorization header" end - jwt_payload = Auth::JwtPayload.new(T.must(matches[1])) - shop = jwt_payload.shop - - if online - jwt_session_id(shop, jwt_payload.sub) - else - offline_session_id(shop) - end + session_id_from_shopify_id_token(id_token: T.must(matches[1]), online: online) else # falling back to session cookie raise Errors::CookieNotFoundError, "JWT token or Session cookie not found for app" unless diff --git a/test/utils/session_utils_test.rb b/test/utils/session_utils_test.rb index fb232d2ba..3005f4d7d 100644 --- a/test/utils/session_utils_test.rb +++ b/test/utils/session_utils_test.rb @@ -24,6 +24,7 @@ def setup } @jwt_token = JWT.encode(@jwt_payload, ShopifyAPI::Context.api_secret_key, "HS256") + @auth_header = "Bearer #{@jwt_token}" end def test_gets_online_session_id_from_shopify_id_token @@ -47,6 +48,88 @@ def test_session_id_from_shopify_id_token_raises_invalid_jwt_errors ShopifyAPI::Utils::SessionUtils.session_id_from_shopify_id_token(id_token: "invalid_token", online: true) end end + + def test_non_embedded_app_current_session_id_raises_cookie_not_found_error + ShopifyAPI::Context.stubs(:embedded?).returns(false) + + [ + nil, + {}, + { "not-session-cookie-name": "not-this-cookie" }, + ].each do |cookies| + error = assert_raises(ShopifyAPI::Errors::CookieNotFoundError) do + ShopifyAPI::Utils::SessionUtils.current_session_id(nil, cookies, true) + end + assert_equal("Session cookie not found for app", error.message) + end + end + + def test_non_embedded_app_current_session_id_returns_id_from_cookie + ShopifyAPI::Context.stubs(:embedded?).returns(false) + expected_session_id = "cookie_value" + cookies = { ShopifyAPI::Auth::Oauth::SessionCookie::SESSION_COOKIE_NAME => expected_session_id } + + assert_equal( + expected_session_id, + ShopifyAPI::Utils::SessionUtils.current_session_id(nil, cookies, true), + ) + end + + def test_embedded_app_current_session_id_raises_cookie_not_found_error + ShopifyAPI::Context.stubs(:embedded?).returns(true) + + [ + nil, + {}, + { "not-session-cookie-name": "not-this-cookie" }, + ].each do |cookies| + error = assert_raises(ShopifyAPI::Errors::CookieNotFoundError) do + ShopifyAPI::Utils::SessionUtils.current_session_id(nil, cookies, true) + end + assert_equal("JWT token or Session cookie not found for app", error.message) + end + end + + def test_embedded_app_current_session_id_raises_missing_jwt_token_error + ShopifyAPI::Context.stubs(:embedded?).returns(true) + + error = assert_raises(ShopifyAPI::Errors::MissingJwtTokenError) do + ShopifyAPI::Utils::SessionUtils.current_session_id("", nil, true) + end + + assert_equal("Missing Bearer token in authorization header", error.message) + end + + def test_embedded_app_current_session_id_returns_online_id_from_auth_header + ShopifyAPI::Context.stubs(:embedded?).returns(true) + expected_session_id = "#{@shop}_#{@user_id}" + + assert_equal( + expected_session_id, + ShopifyAPI::Utils::SessionUtils.current_session_id(@auth_header, nil, true), + ) + end + + def test_embedded_app_current_session_id_returns_offline_id_from_auth_header + ShopifyAPI::Context.stubs(:embedded?).returns(true) + expected_session_id = "offline_#{@shop}" + + assert_equal( + expected_session_id, + ShopifyAPI::Utils::SessionUtils.current_session_id(@auth_header, nil, false), + ) + end + + def test_embedded_app_current_session_id_returns_id_from_auth_header_even_with_cookies + ShopifyAPI::Context.stubs(:embedded?).returns(true) + cookies = { ShopifyAPI::Auth::Oauth::SessionCookie::SESSION_COOKIE_NAME => "cookie_value" } + expected_session_id = "#{@shop}_#{@user_id}" + + assert_equal( + expected_session_id, + ShopifyAPI::Utils::SessionUtils.current_session_id(@auth_header, cookies, true), + ) + end end end end From 3ed00c49807e5b0e55d144b7e255f21a4b12ae80 Mon Sep 17 00:00:00 2001 From: Zoey Lan Date: Wed, 17 Apr 2024 14:54:24 -0600 Subject: [PATCH 3/5] Update changelog and docs --- CHANGELOG.md | 1 + docs/getting_started.md | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e4e13b4d..8fb8cf4fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Note: For changes to the API, see https://shopify.dev/changelog?filter=api ## Unreleased - [#1312](https://github.com/Shopify/shopify-api-ruby/pull/1312) Use same leeway for `exp` and `nbf` when parsing JWT +- [#1314](https://github.com/Shopify/shopify-api-ruby/pull/1314) Add new session util method `SessionUtils::session_id_from_shopify_id_token` ## 14.2.0 - [#1309](https://github.com/Shopify/shopify-api-ruby/pull/1309) Add `Session#copy_attributes_from` method diff --git a/docs/getting_started.md b/docs/getting_started.md index bf4883ebe..5bdafc470 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -46,13 +46,25 @@ Session persistence is handled by the [ShopifyApp](https://github.com/Shopify/sh #### Cookie Cookie based authentication is not supported for embedded apps due to browsers dropping support for third party cookies due to security concerns. Non-embedded apps are able to use cookies for session storage/retrieval. -For *non-embedded* apps, you can pass the cookies into `ShopifyAPI::Utils::SessionUtils.current_session_id(nil, cookies, true)` for online (user) sessions or `ShopifyAPI::Utils::SessionUtils.current_session_id(nil, cookies, false)` for offline (store) sessions. +For *non-embedded* apps, you can pass the cookies into: + - `ShopifyAPI::Utils::SessionUtils.current_session_id(nil, cookies, true)` for online (user) sessions or + - `ShopifyAPI::Utils::SessionUtils.current_session_id(nil, cookies, false)` for offline (store) sessions. #### Getting Session ID From Embedded Requests -For *embedded* apps, you can pass the auth header into `ShopifyAPI::Utils::SessionUtils.current_session_id(auth_header, nil, true)` for online (user) sessions or `ShopifyAPI::Utils::SessionUtils.current_session_id(auth_header, nil, false)` for offline (store) sessions. This function needs an `auth_header` which is the `HTTP_AUTHORIZATION` header. If your app uses client side rendering instead of server side rendering, you will need to use App Bridge's [authenticatedFetch](https://shopify.dev/docs/apps/auth/oauth/session-tokens/getting-started) to make authenticated API requests from the client. +For *embedded* apps: + +If you have an `HTTP_AUTHORIZATION` header, you can pass the auth header into: +- `ShopifyAPI::Utils::SessionUtils.current_session_id(auth_header, nil, true)` for online (user) sessions or +- `ShopifyAPI::Utils::SessionUtils.current_session_id(auth_header, nil, false)` for offline (store) sessions. + +You can also use `id_token` from the request URL params to get the session ID: +- `ShopifyAPI::Utils::SessionUtils::session_id_from_shopify_id_token(id_token: id_token_from_param, online: true)` for online (user) sessions or +- `ShopifyAPI::Utils::SessionUtils::session_id_from_shopify_id_token(id_token: id_token_from_param, online: false)` for offline (store) sessions. + + #### Start Making Authenticated Shopify Requests You can now start making authenticated Shopify API calls using the Admin [REST](usage/rest.md) or [GraphQL](usage/graphql.md) Clients or the [Storefront GraphQL Client](usage/graphql_storefront.md). From b7c98817f7e14f605fe13d57f86f5f0cce5afcf4 Mon Sep 17 00:00:00 2001 From: Zoey Lan Date: Wed, 17 Apr 2024 15:31:00 -0600 Subject: [PATCH 4/5] Raise missing jwt token error in session_id_from_shopify_id_token --- lib/shopify_api/utils/session_utils.rb | 4 +++- test/utils/session_utils_test.rb | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/shopify_api/utils/session_utils.rb b/lib/shopify_api/utils/session_utils.rb index ee59c3c46..271719f16 100644 --- a/lib/shopify_api/utils/session_utils.rb +++ b/lib/shopify_api/utils/session_utils.rb @@ -43,11 +43,13 @@ def current_session_id(auth_header, cookies, online) sig do params( - id_token: String, + id_token: T.nilable(String), online: T::Boolean, ).returns(String) end def session_id_from_shopify_id_token(id_token:, online:) + raise Errors::MissingJwtTokenError, "Missing Shopify ID Token" unless id_token + payload = Auth::JwtPayload.new(id_token) shop = payload.shop diff --git a/test/utils/session_utils_test.rb b/test/utils/session_utils_test.rb index 3005f4d7d..0a3fd3fa9 100644 --- a/test/utils/session_utils_test.rb +++ b/test/utils/session_utils_test.rb @@ -49,6 +49,14 @@ def test_session_id_from_shopify_id_token_raises_invalid_jwt_errors end end + def test_session_id_from_shopify_id_token_raises_missing_jwt_token_error + error = assert_raises(ShopifyAPI::Errors::MissingJwtTokenError) do + ShopifyAPI::Utils::SessionUtils.session_id_from_shopify_id_token(id_token: nil, online: true) + end + + assert_equal("Missing Shopify ID Token", error.message) + end + def test_non_embedded_app_current_session_id_raises_cookie_not_found_error ShopifyAPI::Context.stubs(:embedded?).returns(false) From 11cbcae1c814adfb76dd4d05415b83d2a5f2a81c Mon Sep 17 00:00:00 2001 From: Zoey Lan Date: Thu, 18 Apr 2024 11:54:26 -0600 Subject: [PATCH 5/5] Accept 'id_token' or 'Bearer id_token' format in SessionUtils::current_session_id --- CHANGELOG.md | 4 +- docs/getting_started.md | 15 +++--- lib/shopify_api/utils/session_utils.rb | 17 +++---- test/utils/session_utils_test.rb | 63 ++++++++++++++++++++------ 4 files changed, 66 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fb8cf4fe..327abeba7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,9 @@ Note: For changes to the API, see https://shopify.dev/changelog?filter=api ## Unreleased - [#1312](https://github.com/Shopify/shopify-api-ruby/pull/1312) Use same leeway for `exp` and `nbf` when parsing JWT -- [#1314](https://github.com/Shopify/shopify-api-ruby/pull/1314) Add new session util method `SessionUtils::session_id_from_shopify_id_token` +- [#1314](https://github.com/Shopify/shopify-api-ruby/pull/1314) + - Add new session util method `SessionUtils::session_id_from_shopify_id_token` + - `SessionUtils::current_session_id` now accepts shopify Id token in the format of `Bearer this_token` or just `this_token` ## 14.2.0 - [#1309](https://github.com/Shopify/shopify-api-ruby/pull/1309) Add `Session#copy_attributes_from` method diff --git a/docs/getting_started.md b/docs/getting_started.md index 5bdafc470..58fc4833f 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -56,14 +56,17 @@ If your app uses client side rendering instead of server side rendering, you wil For *embedded* apps: -If you have an `HTTP_AUTHORIZATION` header, you can pass the auth header into: -- `ShopifyAPI::Utils::SessionUtils.current_session_id(auth_header, nil, true)` for online (user) sessions or -- `ShopifyAPI::Utils::SessionUtils.current_session_id(auth_header, nil, false)` for offline (store) sessions. +If you have an `HTTP_AUTHORIZATION` header or `id_token` from the request URL params , you can pass that as `shopify_id_token` into: +- `ShopifyAPI::Utils::SessionUtils.current_session_id(shopify_id_token, nil, true)` for online (user) sessions or +- `ShopifyAPI::Utils::SessionUtils.current_session_id(shopify_id_token, nil, false)` for offline (store) sessions. -You can also use `id_token` from the request URL params to get the session ID: -- `ShopifyAPI::Utils::SessionUtils::session_id_from_shopify_id_token(id_token: id_token_from_param, online: true)` for online (user) sessions or -- `ShopifyAPI::Utils::SessionUtils::session_id_from_shopify_id_token(id_token: id_token_from_param, online: false)` for offline (store) sessions. +`current_session_id` accepts shopify_id_token in the format of `Bearer this_token` or just `this_token`. +You can also use this method to get session ID: +- `ShopifyAPI::Utils::SessionUtils::session_id_from_shopify_id_token(id_token: id_token, online: true)` for online (user) sessions or +- `ShopifyAPI::Utils::SessionUtils::session_id_from_shopify_id_token(id_token: id_token, online: false)` for offline (store) sessions. + +`session_id_from_shopify_id_token` does **NOT** accept shopify_id_token in the format of `Bearer this_token`, you must pass in `this_token`. #### Start Making Authenticated Shopify Requests diff --git a/lib/shopify_api/utils/session_utils.rb b/lib/shopify_api/utils/session_utils.rb index 271719f16..21eef510f 100644 --- a/lib/shopify_api/utils/session_utils.rb +++ b/lib/shopify_api/utils/session_utils.rb @@ -11,21 +11,16 @@ class << self sig do params( - auth_header: T.nilable(String), + shopify_id_token: T.nilable(String), cookies: T.nilable(T::Hash[String, String]), online: T::Boolean, ).returns(T.nilable(String)) end - def current_session_id(auth_header, cookies, online) + def current_session_id(shopify_id_token, cookies, online) if Context.embedded? - if auth_header - matches = auth_header.match(/^Bearer (.+)$/) - unless matches - ShopifyAPI::Logger.warn("Missing Bearer token in authorization header") - raise Errors::MissingJwtTokenError, "Missing Bearer token in authorization header" - end - - session_id_from_shopify_id_token(id_token: T.must(matches[1]), online: online) + if shopify_id_token + id_token = shopify_id_token.gsub("Bearer ", "") + session_id_from_shopify_id_token(id_token: id_token, online: online) else # falling back to session cookie raise Errors::CookieNotFoundError, "JWT token or Session cookie not found for app" unless @@ -48,7 +43,7 @@ def current_session_id(auth_header, cookies, online) ).returns(String) end def session_id_from_shopify_id_token(id_token:, online:) - raise Errors::MissingJwtTokenError, "Missing Shopify ID Token" unless id_token + raise Errors::MissingJwtTokenError, "Missing Shopify ID Token" if id_token.nil? || id_token.empty? payload = Auth::JwtPayload.new(id_token) shop = payload.shop diff --git a/test/utils/session_utils_test.rb b/test/utils/session_utils_test.rb index 0a3fd3fa9..265687a33 100644 --- a/test/utils/session_utils_test.rb +++ b/test/utils/session_utils_test.rb @@ -25,20 +25,20 @@ def setup @jwt_token = JWT.encode(@jwt_payload, ShopifyAPI::Context.api_secret_key, "HS256") @auth_header = "Bearer #{@jwt_token}" + @expected_online_session_id = "#{@shop}_#{@user_id}" + @expected_offline_session_id = "offline_#{@shop}" end def test_gets_online_session_id_from_shopify_id_token - expected_session_id = "#{@shop}_#{@user_id}" assert_equal( - expected_session_id, + @expected_online_session_id, ShopifyAPI::Utils::SessionUtils.session_id_from_shopify_id_token(id_token: @jwt_token, online: true), ) end def test_gets_offline_session_id_from_shopify_id_token - expected_session_id = "offline_#{@shop}" assert_equal( - expected_session_id, + @expected_offline_session_id, ShopifyAPI::Utils::SessionUtils.session_id_from_shopify_id_token(id_token: @jwt_token, online: false), ) end @@ -50,11 +50,16 @@ def test_session_id_from_shopify_id_token_raises_invalid_jwt_errors end def test_session_id_from_shopify_id_token_raises_missing_jwt_token_error - error = assert_raises(ShopifyAPI::Errors::MissingJwtTokenError) do - ShopifyAPI::Utils::SessionUtils.session_id_from_shopify_id_token(id_token: nil, online: true) - end + [ + nil, + "", + ].each do |missing_jwt| + error = assert_raises(ShopifyAPI::Errors::MissingJwtTokenError) do + ShopifyAPI::Utils::SessionUtils.session_id_from_shopify_id_token(id_token: missing_jwt, online: true) + end - assert_equal("Missing Shopify ID Token", error.message) + assert_equal("Missing Shopify ID Token", error.message) + end end def test_non_embedded_app_current_session_id_raises_cookie_not_found_error @@ -98,6 +103,19 @@ def test_embedded_app_current_session_id_raises_cookie_not_found_error end end + def test_embedded_app_current_session_id_raises_invalid_jwt_token_error + ShopifyAPI::Context.stubs(:embedded?).returns(true) + [ + "Bearer invalid_token", + "Bearer", + "invalid_token", + ].each do |invalid_token| + assert_raises(ShopifyAPI::Errors::InvalidJwtTokenError, " - #{invalid_token}") do + ShopifyAPI::Utils::SessionUtils.current_session_id(invalid_token, nil, true) + end + end + end + def test_embedded_app_current_session_id_raises_missing_jwt_token_error ShopifyAPI::Context.stubs(:embedded?).returns(true) @@ -105,36 +123,51 @@ def test_embedded_app_current_session_id_raises_missing_jwt_token_error ShopifyAPI::Utils::SessionUtils.current_session_id("", nil, true) end - assert_equal("Missing Bearer token in authorization header", error.message) + assert_equal("Missing Shopify ID Token", error.message) end def test_embedded_app_current_session_id_returns_online_id_from_auth_header ShopifyAPI::Context.stubs(:embedded?).returns(true) - expected_session_id = "#{@shop}_#{@user_id}" assert_equal( - expected_session_id, + @expected_online_session_id, ShopifyAPI::Utils::SessionUtils.current_session_id(@auth_header, nil, true), ) end def test_embedded_app_current_session_id_returns_offline_id_from_auth_header ShopifyAPI::Context.stubs(:embedded?).returns(true) - expected_session_id = "offline_#{@shop}" assert_equal( - expected_session_id, + @expected_offline_session_id, ShopifyAPI::Utils::SessionUtils.current_session_id(@auth_header, nil, false), ) end + def test_embedded_app_current_session_id_returns_online_id_from_shopify_id_token + ShopifyAPI::Context.stubs(:embedded?).returns(true) + + assert_equal( + @expected_online_session_id, + ShopifyAPI::Utils::SessionUtils.current_session_id(@jwt_token, nil, true), + ) + end + + def test_embedded_app_current_session_id_returns_offline_id_from_shopify_id_token + ShopifyAPI::Context.stubs(:embedded?).returns(true) + + assert_equal( + @expected_offline_session_id, + ShopifyAPI::Utils::SessionUtils.current_session_id(@jwt_token, nil, false), + ) + end + def test_embedded_app_current_session_id_returns_id_from_auth_header_even_with_cookies ShopifyAPI::Context.stubs(:embedded?).returns(true) cookies = { ShopifyAPI::Auth::Oauth::SessionCookie::SESSION_COOKIE_NAME => "cookie_value" } - expected_session_id = "#{@shop}_#{@user_id}" assert_equal( - expected_session_id, + @expected_online_session_id, ShopifyAPI::Utils::SessionUtils.current_session_id(@auth_header, cookies, true), ) end