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 Dec 7, 2023
1 parent 342547f commit 5b08767
Show file tree
Hide file tree
Showing 3 changed files with 214 additions and 12 deletions.
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
84 changes: 74 additions & 10 deletions lib/shopify_api/auth/oauth.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ module Oauth

NONCE_LENGTH = 15

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

Expand Down Expand Up @@ -70,19 +80,10 @@ def validate_auth_callback(cookies:, auth_query:)
raise Errors::InvalidOauthError,
"Invalid state in OAuth callback." unless state == auth_query.state

null_session = Auth::Session.new(shop: auth_query.shop)
body = { client_id: Context.api_key, client_secret: Context.api_secret_key, code: auth_query.code }

client = Clients::HttpClient.new(session: null_session, base_path: "/admin/oauth")
response = begin
client.request(
Clients::HttpRequest.new(
http_method: :post,
path: "access_token",
body: body,
body_type: "application/json",
),
)
access_token_request(auth_query.shop, body)
rescue ShopifyAPI::Errors::HttpResponseError => e
raise Errors::RequestAccessTokenError,
"Cannot complete OAuth process. Received a #{e.code} error while requesting access token."
Expand All @@ -106,8 +107,71 @@ def validate_auth_callback(cookies:, auth_query:)
{ session: session, cookie: cookie }
end

sig do
params(
shop: String,
session_token: String,
requested_token_type: RequestedTokenType,
).returns(ShopifyAPI::Auth::Session)
end
def token_exchange(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)

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,
}

response = begin
access_token_request(shop, body)
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 do
params(shop: String,
body: T.nilable(T.any(T::Hash[T.any(Symbol, String), T.untyped],
String))).returns(ShopifyAPI::Clients::HttpResponse)
end
def access_token_request(shop, body)
null_session = ShopifyAPI::Auth::Session.new(shop: shop)
client = ShopifyAPI::Clients::HttpClient.new(session: null_session, base_path: "/admin/oauth")
client.request(
ShopifyAPI::Clients::HttpRequest.new(
http_method: :post,
path: "access_token",
body: body,
body_type: "application/json",
),
)
end

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] }
Expand Down
139 changes: 138 additions & 1 deletion test/auth/oauth_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,26 @@ def setup
client_secret: ShopifyAPI::Context.api_secret_key,
code: @callback_code,
}

@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",
Expand Down Expand Up @@ -263,6 +282,124 @@ def test_validate_auth_callback_bad_http_response
end
end

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

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

def test_token_exchange_private_app
modify_context(is_private: true)

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

def test_token_exchange_not_embedded_app
modify_context(is_embedded: false)

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

def test_token_exchange_invalid_session_token
modify_context(is_embedded: true)

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

def test_token_exchange_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::Oauth.token_exchange(
shop: @shop,
session_token: @session_token,
requested_token_type: ShopifyAPI::Auth::Oauth::RequestedTokenType::OFFLINE_ACCESS_TOKEN,
)
end
end

def test_token_exchange_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::Oauth.token_exchange(
shop: @shop,
session_token: @session_token,
requested_token_type: ShopifyAPI::Auth::Oauth::RequestedTokenType::OFFLINE_ACCESS_TOKEN,
)

assert_equal(expected_session, session)
end

def test_token_exchange_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::Oauth.token_exchange(
shop: @shop,
session_token: @session_token,
requested_token_type: ShopifyAPI::Auth::Oauth::RequestedTokenType::ONLINE_ACCESS_TOKEN,
)
end

assert_equal(expected_session, session)
end

private

def verify_oauth_begin(auth_route:, cookie:, is_online:, scope: ShopifyAPI::Context.scope)
Expand Down

0 comments on commit 5b08767

Please sign in to comment.