Skip to content
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ Note: For changes to the API, see https://shopify.dev/changelog?filter=api
## Unreleased

- [#1362](https://github.com/Shopify/shopify-api-ruby/pull/1362) Add support for client credentials grant
- [#1369](https://github.com/Shopify/shopify-api-ruby/pull/1369) Make `sub` and `sid` jwt claims optional (Checkout ui extension support)

## 14.8.0

Expand Down
33 changes: 18 additions & 15 deletions lib/shopify_api/auth/jwt_payload.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,14 @@ class JwtPayload
JWT_EXPIRATION_LEEWAY = JWT_LEEWAY

sig { returns(String) }
attr_reader :iss, :dest, :aud, :sub, :jti, :sid
attr_reader :iss, :dest, :aud, :jti

sig { returns(Integer) }
attr_reader :exp, :nbf, :iat

sig { returns(T.nilable(String)) }
attr_reader :sub, :sid

alias_method :expire_at, :exp

sig { params(token: String).void }
Expand All @@ -30,12 +33,12 @@ def initialize(token)
@iss = T.let(payload_hash["iss"], String)
@dest = T.let(payload_hash["dest"], String)
@aud = T.let(payload_hash["aud"], String)
@sub = T.let(payload_hash["sub"], String)
@sub = T.let(payload_hash["sub"], T.nilable(String))
@exp = T.let(payload_hash["exp"], Integer)
@nbf = T.let(payload_hash["nbf"], Integer)
@iat = T.let(payload_hash["iat"], Integer)
@jti = T.let(payload_hash["jti"], String)
@sid = T.let(payload_hash["sid"], String)
@sid = T.let(payload_hash["sid"], T.nilable(String))

raise ShopifyAPI::Errors::InvalidJwtTokenError,
"Session token had invalid API key" unless @aud == Context.api_key
Expand All @@ -47,19 +50,9 @@ def shop
end
alias_method :shopify_domain, :shop

sig { returns(Integer) }
sig { returns(T.nilable(Integer)) }
def shopify_user_id
@sub.to_i
end

# TODO: Remove before releasing v11
sig { params(shop: String).returns(T::Boolean) }
def validate_shop(shop)
Context.logger.warn(
"Deprecation notice: ShopifyAPI::Auth::JwtPayload.validate_shop no longer checks the given shop and always " \
"returns true. It will be removed in v11.",
)
true
@sub.to_i if user_id_sub? && admin_session_token?
end
Copy link
Contributor Author

@BaggioGiacomo BaggioGiacomo Mar 28, 2025

Choose a reason for hiding this comment

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

How can this work?

If the sub is gid://shopify/Customer/123456789, shouldn't this method return 123456789?
gid://shopify/Customer/123456789.to_i returns 0

@lizkenyon I think this is the answer to your question here: https://github.com/Shopify/shopify-api-ruby/pull/1346/files#r1831667330


alias_method :eql?, :==
Expand All @@ -86,6 +79,16 @@ def decode_token(token, api_secret_key)
rescue JWT::DecodeError => err
raise ShopifyAPI::Errors::InvalidJwtTokenError, "Error decoding session token: #{err.message}"
end

sig { returns(T::Boolean) }
def admin_session_token?
@iss.end_with?("/admin")
end

sig { returns(T::Boolean) }
def user_id_sub?
@sub&.match?(/\A\d+\z/) || false
end
end
end
end
2 changes: 1 addition & 1 deletion lib/shopify_api/utils/session_utils.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def session_id_from_shopify_id_token(id_token:, online:)
shop = payload.shop

if online
jwt_session_id(shop, payload.sub)
jwt_session_id(shop, T.must(payload.sub))
else
offline_session_id(shop)
end
Expand Down
78 changes: 67 additions & 11 deletions test/auth/jwt_payload_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ module Auth
class JwtPayloadTest < Test::Unit::TestCase
def setup
super
@jwt_payload = {
@admin_jwt_payload = {
iss: "https://test-shop.myshopify.io/admin",
dest: "https://test-shop.myshopify.io",
aud: ShopifyAPI::Context.api_key,
Expand All @@ -19,12 +19,23 @@ def setup
jti: "4321",
sid: "abc123",
}

@checkout_ui_extension_jwt_payload = {
iss: "https://test-shop.myshopify.io/checkouts",
dest: "test-shop.myshopify.io",
aud: ShopifyAPI::Context.api_key,
sub: "gid://shopify/Customer/123456789",
exp: (Time.now + 10).to_i,
nbf: 1234,
iat: 1234,
jti: "4321",
}
end

def test_decode_jwt_payload_succeeds_with_valid_token
jwt_token = JWT.encode(@jwt_payload, ShopifyAPI::Context.api_secret_key, "HS256")
jwt_token = JWT.encode(@admin_jwt_payload, ShopifyAPI::Context.api_secret_key, "HS256")
decoded = ShopifyAPI::Auth::JwtPayload.new(jwt_token)
assert_equal(@jwt_payload,
assert_equal(@admin_jwt_payload,
{
iss: decoded.iss,
dest: decoded.dest,
Expand All @@ -38,14 +49,14 @@ def test_decode_jwt_payload_succeeds_with_valid_token
})

# Helper methods
assert_equal(decoded.expire_at, @jwt_payload[:exp])
assert_equal(decoded.expire_at, @admin_jwt_payload[:exp])
assert_equal("test-shop.myshopify.io", decoded.shopify_domain)
assert_equal("test-shop.myshopify.io", decoded.shop)
assert_equal(1, decoded.shopify_user_id)
end

def test_decode_jwt_payload_succeeds_with_spin_domain
payload = @jwt_payload.dup
payload = @admin_jwt_payload.dup
payload[:iss] = "https://test-shop.other.spin.dev/admin"
payload[:dest] = "https://test-shop.other.spin.dev"
jwt_token = JWT.encode(payload, ShopifyAPI::Context.api_secret_key, "HS256")
Expand All @@ -68,14 +79,14 @@ def test_decode_jwt_payload_succeeds_with_spin_domain
end

def test_decode_jwt_payload_fails_with_wrong_key
jwt_token = JWT.encode(@jwt_payload, "Wrong", "HS256")
jwt_token = JWT.encode(@admin_jwt_payload, "Wrong", "HS256")
assert_raises(ShopifyAPI::Errors::InvalidJwtTokenError) do
ShopifyAPI::Auth::JwtPayload.new(jwt_token)
end
end

def test_decode_jwt_payload_fails_with_expired_token
payload = @jwt_payload.dup
payload = @admin_jwt_payload.dup
payload[:exp] = (Time.now - 40).to_i
jwt_token = JWT.encode(payload, ShopifyAPI::Context.api_secret_key, "HS256")
assert_raises(ShopifyAPI::Errors::InvalidJwtTokenError) do
Expand All @@ -84,7 +95,7 @@ def test_decode_jwt_payload_fails_with_expired_token
end

def test_decode_jwt_payload_fails_if_not_activated_yet
payload = @jwt_payload.dup
payload = @admin_jwt_payload.dup
payload[:nbf] = (Time.now + 12).to_i
jwt_token = JWT.encode(payload, ShopifyAPI::Context.api_secret_key, "HS256")
assert_raises(ShopifyAPI::Errors::InvalidJwtTokenError) do
Expand All @@ -93,7 +104,7 @@ def test_decode_jwt_payload_fails_if_not_activated_yet
end

def test_decode_jwt_payload_fails_with_invalid_api_key
jwt_token = JWT.encode(@jwt_payload, ShopifyAPI::Context.api_secret_key, "HS256")
jwt_token = JWT.encode(@admin_jwt_payload, ShopifyAPI::Context.api_secret_key, "HS256")

modify_context(api_key: "invalid")

Expand All @@ -103,7 +114,7 @@ def test_decode_jwt_payload_fails_with_invalid_api_key
end

def test_decode_jwt_payload_succeeds_with_expiration_in_the_past_within_10s_leeway
payload = @jwt_payload.merge(exp: Time.now.to_i - 8)
payload = @admin_jwt_payload.merge(exp: Time.now.to_i - 8)
jwt_token = JWT.encode(payload, ShopifyAPI::Context.api_secret_key, "HS256")

decoded = ShopifyAPI::Auth::JwtPayload.new(jwt_token)
Expand All @@ -122,7 +133,7 @@ def test_decode_jwt_payload_succeeds_with_expiration_in_the_past_within_10s_leew
end

def test_decode_jwt_payload_succeeds_with_not_before_in_the_future_within_10s_leeway
payload = @jwt_payload.merge(nbf: Time.now.to_i + 8)
payload = @admin_jwt_payload.merge(nbf: Time.now.to_i + 8)
jwt_token = JWT.encode(payload, ShopifyAPI::Context.api_secret_key, "HS256")

decoded = ShopifyAPI::Auth::JwtPayload.new(jwt_token)
Expand All @@ -139,6 +150,51 @@ def test_decode_jwt_payload_succeeds_with_not_before_in_the_future_within_10s_le
sid: decoded.sid,
})
end

def test_decode_jwt_payload_coming_from_checkout_ui_extension
payload = @checkout_ui_extension_jwt_payload.dup
jwt_token = JWT.encode(payload, ShopifyAPI::Context.api_secret_key, "HS256")
decoded = ShopifyAPI::Auth::JwtPayload.new(jwt_token)
assert_equal(payload,
{
iss: decoded.iss,
dest: decoded.dest,
aud: decoded.aud,
sub: decoded.sub,
exp: decoded.exp,
nbf: decoded.nbf,
iat: decoded.iat,
jti: decoded.jti,
})

assert_equal(decoded.expire_at, @checkout_ui_extension_jwt_payload[:exp])
assert_equal("test-shop.myshopify.io", decoded.shopify_domain)
assert_equal("test-shop.myshopify.io", decoded.shop)
assert_nil(decoded.shopify_user_id)
end

def test_decode_jwt_payload_coming_from_checkout_ui_extension_without_user_logged_in
payload = @checkout_ui_extension_jwt_payload.dup
payload[:sub] = nil
jwt_token = JWT.encode(payload, ShopifyAPI::Context.api_secret_key, "HS256")
decoded = ShopifyAPI::Auth::JwtPayload.new(jwt_token)
assert_equal(payload,
{
iss: decoded.iss,
dest: decoded.dest,
aud: decoded.aud,
sub: decoded.sub,
exp: decoded.exp,
nbf: decoded.nbf,
iat: decoded.iat,
jti: decoded.jti,
})

assert_equal(decoded.expire_at, @checkout_ui_extension_jwt_payload[:exp])
assert_equal("test-shop.myshopify.io", decoded.shopify_domain)
assert_equal("test-shop.myshopify.io", decoded.shop)
assert_nil(decoded.shopify_user_id)
end
end
end
end