Skip to content

Commit

Permalink
Introduce the token exchange API for fetching access tokens
Browse files Browse the repository at this point in the history
  • Loading branch information
gbzodek committed Jan 2, 2024
1 parent 342547f commit ae60871
Show file tree
Hide file tree
Showing 3 changed files with 298 additions and 1 deletion.
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
Note: For changes to the API, see https://shopify.dev/changelog?filter=api

## Unreleased
[#1210](https://github.com/Shopify/shopify-api-ruby/pull/1246) Add context option `response_as_struct` to allow GraphQL API responses to be accessed via dot notation.
- [#1210](https://github.com/Shopify/shopify-api-ruby/pull/1246) Add context option `response_as_struct` to allow GraphQL API responses to be accessed via dot notation.
- [#1254](https://github.com/Shopify/shopify-api-ruby/pull/1254) Introduce token exchange API for fetching access tokens. This feature is currently unstable and cannot be used yet.

## 13.3.1

Expand Down
110 changes: 110 additions & 0 deletions lib/shopify_api/auth/token_exchange.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# typed: strict
# frozen_string_literal: true

module ShopifyAPI
module Auth
module TokenExchange
extend T::Sig

TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"
ID_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id_token"

class RequestedTokenType < T::Enum
enums do
ONLINE_ACCESS_TOKEN = new("urn:shopify:params:oauth:token-type:online-access-token")
OFFLINE_ACCESS_TOKEN = new("urn:shopify:params:oauth:token-type:offline-access-token")
end
end

class << self
extend T::Sig

sig do
params(
shop: String,
session_token: String,
requested_token_type: RequestedTokenType,
).returns(ShopifyAPI::Auth::Session)
end
def exchange_token(shop:, session_token:, requested_token_type:)
unless ShopifyAPI::Context.setup?
raise ShopifyAPI::Errors::ContextNotSetupError,
"ShopifyAPI::Context not setup, please call ShopifyAPI::Context.setup"
end
raise ShopifyAPI::Errors::UnsupportedOauthError,
"Cannot perform OAuth Token Exchange for private apps." if ShopifyAPI::Context.private?
raise ShopifyAPI::Errors::UnsupportedOauthError,
"Cannot perform OAuth Token Exchange for non embedded apps." unless ShopifyAPI::Context.embedded?

# Validate the session token content
ShopifyAPI::Auth::JwtPayload.new(session_token)

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_type: ID_TOKEN_TYPE,
requested_token_type: requested_token_type.serialize,
}

client = Clients::HttpClient.new(session: shop_session, base_path: "/admin/oauth")
response = begin
client.request(
Clients::HttpRequest.new(
http_method: :post,
path: "access_token",
body: body,
body_type: "application/json",
),
)
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"
end

raise error
end

session_params = T.cast(response.body, T::Hash[String, T.untyped]).to_h
session = create_new_session(session_params, shop)

session
end

private

sig { params(session_params: T::Hash[String, T.untyped], shop: String).returns(Session) }
def create_new_session(session_params, shop)
session_params = session_params.to_h { |k, v| [k.to_sym, v] }

scope = session_params[:scope]

is_online = !session_params[:associated_user].nil?

if is_online
associated_user = AssociatedUser.new(session_params[:associated_user].to_h { |k, v| [k.to_sym, v] })
expires = Time.now + session_params[:expires_in].to_i
associated_user_scope = session_params[:associated_user_scope]
id = "#{shop}_#{associated_user.id}"
else
id = "offline_#{shop}"
end

Session.new(
id: id,
shop: shop,
access_token: session_params[:access_token],
scope: scope,
is_online: is_online,
associated_user_scope: associated_user_scope,
associated_user: associated_user,
expires: expires,
shopify_session_id: session_params[:session],
)
end
end
end
end
end
186 changes: 186 additions & 0 deletions test/auth/token_exchange_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
# typed: false
# frozen_string_literal: true

require_relative "../test_helper"

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

@stubbed_time_now = Time.now
@shop = "test-shop.myshopify.com"
@jwt_payload = {
iss: "https://#{@shop}/admin",
dest: "https://#{@shop}",
aud: ShopifyAPI::Context.api_key,
sub: "1",
exp: (Time.now + 10).to_i,
nbf: 1234,
iat: 1234,
jti: "4321",
sid: "abc123",
}
@session_token = JWT.encode(@jwt_payload, ShopifyAPI::Context.api_secret_key, "HS256")
@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: @session_token,
requested_token_type: "urn:shopify:params:oauth:token-type:offline-access-token",
}
@offline_token_response = {
access_token: SecureRandom.alphanumeric(10),
scope: "scope1,scope2",
}
@online_token_response = {
access_token: SecureRandom.alphanumeric(10),
scope: "scope1,scope2",
expires_in: 1000,
associated_user_scope: "scope1",
associated_user: {
id: 902541635,
first_name: "first",
last_name: "last",
email: "firstlast@example.com",
email_verified: true,
account_owner: true,
locale: "en",
collaborator: false,
},
}

@expected_associated_user = ShopifyAPI::Auth::AssociatedUser.new(
id: @online_token_response[:associated_user][:id],
first_name: @online_token_response[:associated_user][:first_name],
last_name: @online_token_response[:associated_user][:last_name],
email: @online_token_response[:associated_user][:email],
email_verified: @online_token_response[:associated_user][:email_verified],
account_owner: @online_token_response[:associated_user][:account_owner],
locale: @online_token_response[:associated_user][:locale],
collaborator: @online_token_response[:associated_user][:collaborator],
)
end

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

assert_raises(ShopifyAPI::Errors::ContextNotSetupError) do
ShopifyAPI::Auth::TokenExchange.exchange_token(
shop: @shop,
session_token: @session_token,
requested_token_type: ShopifyAPI::Auth::TokenExchange::RequestedTokenType::OFFLINE_ACCESS_TOKEN,
)
end
end

def test_exchange_token_private_app
modify_context(is_private: true)

assert_raises(ShopifyAPI::Errors::UnsupportedOauthError) do
ShopifyAPI::Auth::TokenExchange.exchange_token(
shop: @shop,
session_token: @session_token,
requested_token_type: ShopifyAPI::Auth::TokenExchange::RequestedTokenType::OFFLINE_ACCESS_TOKEN,
)
end
end

def test_exchange_token_not_embedded_app
modify_context(is_embedded: false)

assert_raises(ShopifyAPI::Errors::UnsupportedOauthError) do
ShopifyAPI::Auth::TokenExchange.exchange_token(
shop: @shop,
session_token: @session_token,
requested_token_type: ShopifyAPI::Auth::TokenExchange::RequestedTokenType::OFFLINE_ACCESS_TOKEN,
)
end
end

def test_exchange_token_invalid_session_token
modify_context(is_embedded: true)

assert_raises(ShopifyAPI::Errors::InvalidJwtTokenError) do
ShopifyAPI::Auth::TokenExchange.exchange_token(
shop: @shop,
session_token: "invalid",
requested_token_type: ShopifyAPI::Auth::TokenExchange::RequestedTokenType::OFFLINE_ACCESS_TOKEN,
)
end
end

def test_exchange_token_rejected_session_token
modify_context(is_embedded: true)
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::TokenExchange.exchange_token(
shop: @shop,
session_token: @session_token,
requested_token_type: ShopifyAPI::Auth::TokenExchange::RequestedTokenType::OFFLINE_ACCESS_TOKEN,
)
end
end

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

session = ShopifyAPI::Auth::TokenExchange.exchange_token(
shop: @shop,
session_token: @session_token,
requested_token_type: ShopifyAPI::Auth::TokenExchange::RequestedTokenType::OFFLINE_ACCESS_TOKEN,
)

assert_equal(expected_session, session)
end

def test_exchange_token_online_token
modify_context(is_embedded: true)
stub_request(:post, "https://#{@shop}/admin/oauth/access_token")
.with(body: @token_exchange_request.dup.tap do |h|
h[:requested_token_type] = "urn:shopify:params:oauth:token-type:online-access-token"
end)
.to_return(body: @online_token_response.to_json, headers: { content_type: "application/json" })
expected_session = ShopifyAPI::Auth::Session.new(
id: "#{@shop}_#{@online_token_response[:associated_user][:id]}",
shop: @shop,
access_token: @online_token_response[:access_token],
scope: @online_token_response[:scope],
associated_user_scope: @online_token_response[:associated_user_scope],
expires: @stubbed_time_now + @online_token_response[:expires_in].to_i,
associated_user: @expected_associated_user,
)

session = Time.stub(:now, @stubbed_time_now) do
ShopifyAPI::Auth::TokenExchange.exchange_token(
shop: @shop,
session_token: @session_token,
requested_token_type: ShopifyAPI::Auth::TokenExchange::RequestedTokenType::ONLINE_ACCESS_TOKEN,
)
end

assert_equal(expected_session, session)
end
end
end
end

0 comments on commit ae60871

Please sign in to comment.