Skip to content

Commit 2b58d41

Browse files
committed
Perform token exchange with Google ID token
1 parent 27500c5 commit 2b58d41

File tree

3 files changed

+130
-2
lines changed

3 files changed

+130
-2
lines changed
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
module ShopifyAPI
5+
module Auth
6+
module AlwaysOnToken
7+
class << self
8+
extend T::Sig
9+
10+
sig { params(shop: String).returns(ShopifyAPI::Auth::Session) }
11+
def request(shop:)
12+
unless ShopifyAPI::Context.setup?
13+
raise ShopifyAPI::Errors::ContextNotSetupError,
14+
"ShopifyAPI::Context not setup, please call ShopifyAPI::Context.setup"
15+
end
16+
17+
id_token = ShopifyAPI::Auth::IdToken::GoogleIdToken.request(shop: shop)
18+
unless id_token
19+
raise ShopifyAPI::Errors::InvalidJwtTokenError, "Failed to get Google ID token"
20+
end
21+
22+
ShopifyAPI::Auth::TokenExchange.call_token_exchange_endpoint(
23+
shop: shop,
24+
id_token: id_token,
25+
requested_token_type: ShopifyAPI::Auth::TokenExchange::RequestedTokenType::OFFLINE_ACCESS_TOKEN,
26+
)
27+
end
28+
end
29+
end
30+
end
31+
end

lib/shopify_api/auth/token_exchange.rb

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,23 @@ def exchange_token(shop:, session_token:, requested_token_type:)
3939
# Validate the session token content
4040
ShopifyAPI::Auth::JwtPayload.new(session_token)
4141

42+
call_token_exchange_endpoint(shop: shop, id_token: session_token, requested_token_type: requested_token_type)
43+
end
44+
45+
sig do
46+
params(
47+
shop: String,
48+
id_token: String,
49+
requested_token_type: RequestedTokenType,
50+
).returns(ShopifyAPI::Auth::Session)
51+
end
52+
def call_token_exchange_endpoint(shop:, id_token:, requested_token_type:)
4253
shop_session = ShopifyAPI::Auth::Session.new(shop: shop)
4354
body = {
4455
client_id: ShopifyAPI::Context.api_key,
4556
client_secret: ShopifyAPI::Context.api_secret_key,
4657
grant_type: TOKEN_EXCHANGE_GRANT_TYPE,
47-
subject_token: session_token,
58+
subject_token: id_token,
4859
subject_token_type: ID_TOKEN_TYPE,
4960
requested_token_type: requested_token_type.serialize,
5061
}
@@ -61,7 +72,7 @@ def exchange_token(shop:, session_token:, requested_token_type:)
6172
)
6273
rescue ShopifyAPI::Errors::HttpResponseError => error
6374
if error.code == 400 && error.response.body["error"] == "invalid_subject_token"
64-
raise ShopifyAPI::Errors::InvalidJwtTokenError, "Session token was rejected by token exchange"
75+
raise ShopifyAPI::Errors::InvalidJwtTokenError, "ID token was rejected by token exchange"
6576
end
6677

6778
raise error

test/auth/always_on_token_test.rb

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# typed: false
2+
# frozen_string_literal: true
3+
4+
require_relative "../test_helper"
5+
6+
module ShopifyAPITest
7+
module Auth
8+
class AlwaysOnTokenTest < Test::Unit::TestCase
9+
def setup
10+
super()
11+
12+
@shop = "test-shop.myshopify.com"
13+
@jwt_payload = {
14+
iss: "https://accounts.google.com",
15+
aud: @shop,
16+
}
17+
@id_token = JWT.encode(@jwt_payload, nil, "none")
18+
19+
ShopifyAPI::Auth::IdToken::GoogleIdToken.stubs(:request).returns(@id_token)
20+
21+
@token_exchange_request = {
22+
client_id: ShopifyAPI::Context.api_key,
23+
client_secret: ShopifyAPI::Context.api_secret_key,
24+
grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
25+
subject_token_type: "urn:ietf:params:oauth:token-type:id_token",
26+
subject_token: @id_token,
27+
requested_token_type: "urn:shopify:params:oauth:token-type:offline-access-token",
28+
}
29+
@token_response = {
30+
access_token: SecureRandom.alphanumeric(10),
31+
scope: "scope1,scope2",
32+
session: SecureRandom.alphanumeric(10),
33+
}
34+
end
35+
36+
def test_exchange_token_context_not_setup
37+
modify_context(api_key: "", api_secret_key: "", host: "")
38+
39+
assert_raises(ShopifyAPI::Errors::ContextNotSetupError) do
40+
ShopifyAPI::Auth::AlwaysOnToken.request(shop: @shop)
41+
end
42+
end
43+
44+
def test_exchange_token_unable_to_get_id_token
45+
ShopifyAPI::Auth::IdToken::GoogleIdToken.stubs(:request).returns(nil)
46+
47+
assert_raises(ShopifyAPI::Errors::InvalidJwtTokenError) do
48+
ShopifyAPI::Auth::AlwaysOnToken.request(shop: @shop)
49+
end
50+
end
51+
52+
def test_exchange_token_rejected_id_token
53+
stub_request(:post, "https://#{@shop}/admin/oauth/access_token")
54+
.with(body: @token_exchange_request)
55+
.to_return(
56+
status: 400,
57+
body: { error: "invalid_subject_token" }.to_json,
58+
headers: { content_type: "application/json" },
59+
)
60+
61+
assert_raises(ShopifyAPI::Errors::InvalidJwtTokenError) do
62+
ShopifyAPI::Auth::AlwaysOnToken.request(shop: @shop)
63+
end
64+
end
65+
66+
def test_request_token_succeeds
67+
stub_request(:post, "https://#{@shop}/admin/oauth/access_token")
68+
.with(body: @token_exchange_request)
69+
.to_return(body: @token_response.to_json, headers: { content_type: "application/json" })
70+
expected_session = ShopifyAPI::Auth::Session.new(
71+
id: "offline_#{@shop}",
72+
shop: @shop,
73+
access_token: @token_response[:access_token],
74+
scope: @token_response[:scope],
75+
is_online: false,
76+
expires: nil,
77+
shopify_session_id: @token_response[:session],
78+
)
79+
80+
session = ShopifyAPI::Auth::AlwaysOnToken.request(shop: @shop)
81+
82+
assert_equal(expected_session, session)
83+
end
84+
end
85+
end
86+
end

0 commit comments

Comments
 (0)