Skip to content

Commit

Permalink
Merge pull request #1254 from Shopify/token-exchange
Browse files Browse the repository at this point in the history
Introduce the token exchange API for fetching access tokens
  • Loading branch information
gbzodek authored Jan 17, 2024
2 parents dc9f17f + b5dcf68 commit 79ca4e2
Show file tree
Hide file tree
Showing 8 changed files with 443 additions and 33 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
Note: For changes to the API, see https://shopify.dev/changelog?filter=api

## Unreleased
- [#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.4.0
- [#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.
Expand Down
35 changes: 2 additions & 33 deletions lib/shopify_api/auth/oauth.rb
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@ def validate_auth_callback(cookies:, auth_query:)
end

session_params = T.cast(response.body, T::Hash[String, T.untyped]).to_h
session = create_new_session(session_params, auth_query.shop)
session = Session.from(shop: auth_query.shop,
access_token_response: Oauth::AccessTokenResponse.from_hash(session_params))

cookie = if Context.embedded?
SessionCookie.new(
Expand All @@ -105,38 +106,6 @@ def validate_auth_callback(cookies:, auth_query:)

{ session: session, cookie: cookie }
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
Expand Down
37 changes: 37 additions & 0 deletions lib/shopify_api/auth/oauth/access_token_response.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# typed: strict
# frozen_string_literal: true

module ShopifyAPI
module Auth
module Oauth
class AccessTokenResponse < T::Struct
extend T::Sig

const :access_token, String
const :scope, String
const :session, T.nilable(String)
const :expires_in, T.nilable(Integer)
const :associated_user, T.nilable(AssociatedUser)
const :associated_user_scope, T.nilable(String)

sig { returns(T::Boolean) }
def online_token?
!associated_user.nil?
end

alias_method :eql?, :==
sig { params(other: T.nilable(AccessTokenResponse)).returns(T::Boolean) }
def ==(other)
return false unless other

access_token == other.access_token &&
scope == other.scope &&
session == other.session &&
expires_in == other.expires_in &&
associated_user == other.associated_user &&
associated_user_scope == other.associated_user_scope
end
end
end
end
end
26 changes: 26 additions & 0 deletions lib/shopify_api/auth/session.rb
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,32 @@ def temp(shop:, access_token:, &blk)
end
end

sig { params(shop: String, access_token_response: Oauth::AccessTokenResponse).returns(Session) }
def from(shop:, access_token_response:)
is_online = access_token_response.online_token?

if is_online
associated_user = T.must(access_token_response.associated_user)
expires = Time.now + access_token_response.expires_in.to_i
associated_user_scope = access_token_response.associated_user_scope
id = "#{shop}_#{associated_user.id}"
else
id = "offline_#{shop}"
end

new(
id: id,
shop: shop,
access_token: access_token_response.access_token,
scope: access_token_response.scope,
is_online: is_online,
associated_user_scope: associated_user_scope,
associated_user: associated_user,
expires: expires,
shopify_session_id: access_token_response.session,
)
end

sig { params(str: String).returns(Session) }
def deserialize(str)
Oj.load(str)
Expand Down
80 changes: 80 additions & 0 deletions lib/shopify_api/auth/token_exchange.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# 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.from(
shop: shop,
access_token_response: Oauth::AccessTokenResponse.from_hash(session_params),
)
end
end
end
end
end
43 changes: 43 additions & 0 deletions test/auth/oauth/access_token_response_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# typed: true
# frozen_string_literal: true

require_relative "../../test_helper"

module ShopifyAPITest
module Auth
module Oauth
class AccessTokenResponseTest < Test::Unit::TestCase
def test_online_token_is_false_when_no_associated_user
token_response = ShopifyAPI::Auth::Oauth::AccessTokenResponse.new(
access_token: "token",
scope: "scope1, scope2",
)

assert(!token_response.online_token?)
end

def test_online_token_is_true_when_associated_user_is_present
associated_user = ShopifyAPI::Auth::AssociatedUser.new(
id: 902541635,
first_name: "first",
last_name: "last",
email: "[email protected]",
email_verified: true,
account_owner: true,
locale: "en",
collaborator: false,
)
token_response = ShopifyAPI::Auth::Oauth::AccessTokenResponse.new(
access_token: "token",
scope: "scope1, scope2",
expires_in: 1000,
associated_user_scope: "scope1",
associated_user: associated_user,
)

assert(token_response.online_token?)
end
end
end
end
end
64 changes: 64 additions & 0 deletions test/auth/session_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,70 @@ def test_temp_with_block_var
assert_equal(session, ShopifyAPI::Context.active_session)
end

def test_from_with_offline_access_token_response
shop = "test-shop"
response = ShopifyAPI::Auth::Oauth::AccessTokenResponse.new(
access_token: "token",
scope: "scope1, scope2",
session: "session",
)
expected_session = ShopifyAPI::Auth::Session.new(
id: "offline_#{shop}",
shop: shop,
access_token: response.access_token,
scope: response.scope,
is_online: false,
associated_user_scope: nil,
associated_user: nil,
expires: nil,
shopify_session_id: response.session,
)

session = ShopifyAPI::Auth::Session.from(shop: shop, access_token_response: response)

assert_equal(expected_session, session)
end

def test_from_with_online_access_token_response
shop = "test-shop"
associated_user = ShopifyAPI::Auth::AssociatedUser.new(
id: 902541635,
first_name: "first",
last_name: "last",
email: "[email protected]",
email_verified: true,
account_owner: true,
locale: "en",
collaborator: false,
)
response = ShopifyAPI::Auth::Oauth::AccessTokenResponse.new(
access_token: "token",
scope: "scope1, scope2",
session: "session",
expires_in: 1000,
associated_user: associated_user,
associated_user_scope: "scope1",
)
time_now = Time.now
expected_session = ShopifyAPI::Auth::Session.new(
id: "#{shop}_#{associated_user.id}",
shop: shop,
access_token: response.access_token,
scope: response.scope,
is_online: false,
associated_user_scope: response.associated_user_scope,
associated_user: associated_user,
expires: time_now + response.expires_in,
shopify_session_id: response.session,
)

session = Time.stub(:now, time_now) do
ShopifyAPI::Auth::Session.from(shop: shop, access_token_response: response)
end

assert_equal(expected_session, session)
end

def teardown
ShopifyAPI::Context.deactivate_session
end
Expand Down
Loading

0 comments on commit 79ca4e2

Please sign in to comment.