Skip to content

Commit

Permalink
Perform token exchange with Google ID token
Browse files Browse the repository at this point in the history
  • Loading branch information
ragalie committed Dec 12, 2024
1 parent 27500c5 commit 2b58d41
Show file tree
Hide file tree
Showing 3 changed files with 130 additions and 2 deletions.
31 changes: 31 additions & 0 deletions lib/shopify_api/auth/always_on_token.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# typed: strict
# frozen_string_literal: true

module ShopifyAPI
module Auth
module AlwaysOnToken
class << self
extend T::Sig

sig { params(shop: String).returns(ShopifyAPI::Auth::Session) }
def request(shop:)
unless ShopifyAPI::Context.setup?
raise ShopifyAPI::Errors::ContextNotSetupError,
"ShopifyAPI::Context not setup, please call ShopifyAPI::Context.setup"
end

id_token = ShopifyAPI::Auth::IdToken::GoogleIdToken.request(shop: shop)
unless id_token
raise ShopifyAPI::Errors::InvalidJwtTokenError, "Failed to get Google ID token"
end

ShopifyAPI::Auth::TokenExchange.call_token_exchange_endpoint(
shop: shop,
id_token: id_token,
requested_token_type: ShopifyAPI::Auth::TokenExchange::RequestedTokenType::OFFLINE_ACCESS_TOKEN,
)
end
end
end
end
end
15 changes: 13 additions & 2 deletions lib/shopify_api/auth/token_exchange.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,23 @@ def exchange_token(shop:, session_token:, requested_token_type:)
# Validate the session token content
ShopifyAPI::Auth::JwtPayload.new(session_token)

call_token_exchange_endpoint(shop: shop, id_token: session_token, requested_token_type: requested_token_type)
end

sig do
params(
shop: String,
id_token: String,
requested_token_type: RequestedTokenType,
).returns(ShopifyAPI::Auth::Session)
end
def call_token_exchange_endpoint(shop:, id_token:, requested_token_type:)
shop_session = ShopifyAPI::Auth::Session.new(shop: shop)
body = {
client_id: ShopifyAPI::Context.api_key,
client_secret: ShopifyAPI::Context.api_secret_key,
grant_type: TOKEN_EXCHANGE_GRANT_TYPE,
subject_token: session_token,
subject_token: id_token,
subject_token_type: ID_TOKEN_TYPE,
requested_token_type: requested_token_type.serialize,
}
Expand All @@ -61,7 +72,7 @@ def exchange_token(shop:, session_token:, requested_token_type:)
)
rescue ShopifyAPI::Errors::HttpResponseError => error
if error.code == 400 && error.response.body["error"] == "invalid_subject_token"
raise ShopifyAPI::Errors::InvalidJwtTokenError, "Session token was rejected by token exchange"
raise ShopifyAPI::Errors::InvalidJwtTokenError, "ID token was rejected by token exchange"
end

raise error
Expand Down
86 changes: 86 additions & 0 deletions test/auth/always_on_token_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# typed: false
# frozen_string_literal: true

require_relative "../test_helper"

module ShopifyAPITest
module Auth
class AlwaysOnTokenTest < Test::Unit::TestCase
def setup
super()

@shop = "test-shop.myshopify.com"
@jwt_payload = {
iss: "https://accounts.google.com",
aud: @shop,
}
@id_token = JWT.encode(@jwt_payload, nil, "none")

ShopifyAPI::Auth::IdToken::GoogleIdToken.stubs(:request).returns(@id_token)

@token_exchange_request = {
client_id: ShopifyAPI::Context.api_key,
client_secret: ShopifyAPI::Context.api_secret_key,
grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
subject_token_type: "urn:ietf:params:oauth:token-type:id_token",
subject_token: @id_token,
requested_token_type: "urn:shopify:params:oauth:token-type:offline-access-token",
}
@token_response = {
access_token: SecureRandom.alphanumeric(10),
scope: "scope1,scope2",
session: SecureRandom.alphanumeric(10),
}
end

def test_exchange_token_context_not_setup
modify_context(api_key: "", api_secret_key: "", host: "")

assert_raises(ShopifyAPI::Errors::ContextNotSetupError) do
ShopifyAPI::Auth::AlwaysOnToken.request(shop: @shop)
end
end

def test_exchange_token_unable_to_get_id_token
ShopifyAPI::Auth::IdToken::GoogleIdToken.stubs(:request).returns(nil)

assert_raises(ShopifyAPI::Errors::InvalidJwtTokenError) do
ShopifyAPI::Auth::AlwaysOnToken.request(shop: @shop)
end
end

def test_exchange_token_rejected_id_token
stub_request(:post, "https://#{@shop}/admin/oauth/access_token")
.with(body: @token_exchange_request)
.to_return(
status: 400,
body: { error: "invalid_subject_token" }.to_json,
headers: { content_type: "application/json" },
)

assert_raises(ShopifyAPI::Errors::InvalidJwtTokenError) do
ShopifyAPI::Auth::AlwaysOnToken.request(shop: @shop)
end
end

def test_request_token_succeeds
stub_request(:post, "https://#{@shop}/admin/oauth/access_token")
.with(body: @token_exchange_request)
.to_return(body: @token_response.to_json, headers: { content_type: "application/json" })
expected_session = ShopifyAPI::Auth::Session.new(
id: "offline_#{@shop}",
shop: @shop,
access_token: @token_response[:access_token],
scope: @token_response[:scope],
is_online: false,
expires: nil,
shopify_session_id: @token_response[:session],
)

session = ShopifyAPI::Auth::AlwaysOnToken.request(shop: @shop)

assert_equal(expected_session, session)
end
end
end
end

0 comments on commit 2b58d41

Please sign in to comment.