Skip to content

Commit

Permalink
Merge pull request #1314 from Shopify/support-id-token
Browse files Browse the repository at this point in the history
Add new SessionUtil method to retrieve current session Id from Shopify ID token
  • Loading branch information
zzooeeyy authored Apr 18, 2024
2 parents c1163e0 + 11cbcae commit eaaa96d
Show file tree
Hide file tree
Showing 4 changed files with 220 additions and 19 deletions.
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:)
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
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),
)
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

0 comments on commit eaaa96d

Please sign in to comment.