Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add new SessionUtil method to retrieve current session Id from Shopify ID token #1314

Merged
merged 5 commits into from
Apr 18, 2024
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 @@ -4,6 +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`
- `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
Expand Down
19 changes: 17 additions & 2 deletions docs/getting_started.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,28 @@ 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 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.

`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

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).
Expand Down
41 changes: 24 additions & 17 deletions lib/shopify_api/utils/session_utils.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,28 +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

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
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
Expand All @@ -48,6 +36,25 @@ def current_session_id(auth_header, cookies, online)
end
end

sig do
params(
id_token: T.nilable(String),
online: T::Boolean,
).returns(String)
end
def session_id_from_shopify_id_token(id_token:, online:)
paulomarg marked this conversation as resolved.
Show resolved Hide resolved
raise Errors::MissingJwtTokenError, "Missing Shopify ID Token" if id_token.nil? || id_token.empty?

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}"
Expand Down
176 changes: 176 additions & 0 deletions test/utils/session_utils_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
# 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")
@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
assert_equal(
@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
assert_equal(
@expected_offline_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

def test_session_id_from_shopify_id_token_raises_missing_jwt_token_error
[
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)
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
paulomarg marked this conversation as resolved.
Show resolved Hide resolved
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_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)

error = assert_raises(ShopifyAPI::Errors::MissingJwtTokenError) do
ShopifyAPI::Utils::SessionUtils.current_session_id("", nil, true)
end

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)

assert_equal(
@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)

assert_equal(
@expected_offline_session_id,
ShopifyAPI::Utils::SessionUtils.current_session_id(@auth_header, nil, false),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know it's not new code we added in this PR, but can we expect to raise the missing JWT error when a valid token is present in the header but doesn't follow the Bearer {token} format?

)
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" }

assert_equal(
@expected_online_session_id,
ShopifyAPI::Utils::SessionUtils.current_session_id(@auth_header, cookies, true),
)
end
end
end
end
Loading