-
Notifications
You must be signed in to change notification settings - Fork 683
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #1822 from Shopify/handle-invalid-access-token
Handle invalid access tokens in token exchange
- Loading branch information
Showing
9 changed files
with
451 additions
and
164 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
# frozen_string_literal: true | ||
|
||
module ShopifyApp | ||
module AdminAPI | ||
module WithTokenRefetch | ||
def with_token_refetch(session, shopify_id_token) | ||
retrying = false if retrying.nil? | ||
yield | ||
rescue ShopifyAPI::Errors::HttpResponseError => error | ||
if error.code != 401 | ||
ShopifyApp::Logger.debug("Encountered error: #{error.code} - #{error.response.inspect}, re-raising") | ||
elsif retrying | ||
ShopifyApp::Logger.debug("Shopify API returned a 401 Unauthorized error that was not corrected " \ | ||
"with token exchange, deleting current session and re-raising") | ||
ShopifyApp::SessionRepository.delete_session(session.id) | ||
else | ||
retrying = true | ||
ShopifyApp::Logger.debug("Shopify API returned a 401 Unauthorized error, exchanging token and " \ | ||
"retrying with new session") | ||
new_session = ShopifyApp::Auth::TokenExchange.perform(shopify_id_token) | ||
session.copy_attributes_from(new_session) | ||
retry | ||
end | ||
raise | ||
end | ||
end | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,73 @@ | ||
# frozen_string_literal: true | ||
|
||
module ShopifyApp | ||
module Auth | ||
class TokenExchange | ||
attr_reader :id_token | ||
|
||
def self.perform(id_token) | ||
new(id_token).perform | ||
end | ||
|
||
def initialize(id_token) | ||
@id_token = id_token | ||
end | ||
|
||
def perform | ||
domain = ShopifyApp::JWT.new(id_token).shopify_domain | ||
|
||
Logger.info("Performing Token Exchange for [#{domain}] - (Offline)") | ||
session = exchange_token( | ||
shop: domain, | ||
id_token: id_token, | ||
requested_token_type: ShopifyAPI::Auth::TokenExchange::RequestedTokenType::OFFLINE_ACCESS_TOKEN, | ||
) | ||
|
||
if online_token_configured? | ||
Logger.info("Performing Token Exchange for [#{domain}] - (Online)") | ||
session = exchange_token( | ||
shop: domain, | ||
id_token: id_token, | ||
requested_token_type: ShopifyAPI::Auth::TokenExchange::RequestedTokenType::ONLINE_ACCESS_TOKEN, | ||
) | ||
end | ||
|
||
ShopifyApp.configuration.post_authenticate_tasks.perform(session) | ||
|
||
session | ||
end | ||
|
||
private | ||
|
||
def exchange_token(shop:, id_token:, requested_token_type:) | ||
session = ShopifyAPI::Auth::TokenExchange.exchange_token( | ||
shop: shop, | ||
session_token: id_token, | ||
requested_token_type: requested_token_type, | ||
) | ||
|
||
SessionRepository.store_session(session) | ||
|
||
session | ||
rescue ShopifyAPI::Errors::InvalidJwtTokenError | ||
Logger.error("Invalid id token '#{id_token}' during token exchange") | ||
raise | ||
rescue ShopifyAPI::Errors::HttpResponseError => error | ||
Logger.error( | ||
"A #{error.code} error (#{error.class}) occurred during the token exchange. Response: #{error.response.body}", | ||
) | ||
raise | ||
rescue ActiveRecord::RecordNotUnique | ||
Logger.debug("Session not stored due to concurrent token exchange calls") | ||
session | ||
rescue => error | ||
Logger.error("An error occurred during the token exchange: [#{error.class}] #{error.message}") | ||
raise | ||
end | ||
|
||
def online_token_configured? | ||
ShopifyApp.configuration.online_token_configured? | ||
end | ||
end | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,98 @@ | ||
# frozen_string_literal: true | ||
|
||
require "test_helper" | ||
|
||
class ShopifyApp::AdminAPI::WithTokenRefetchTest < ActiveSupport::TestCase | ||
include ShopifyApp::AdminAPI::WithTokenRefetch | ||
|
||
def setup | ||
@session = ShopifyAPI::Auth::Session.new(id: "session-id", shop: "shop", access_token: "old", expires: 1.hour.ago) | ||
@id_token = "an-id-token" | ||
|
||
tomorrow = 1.day.from_now | ||
@new_session = ShopifyAPI::Auth::Session.new(id: "session-id", shop: "shop", access_token: "new", expires: tomorrow) | ||
|
||
@fake_admin_api = stub(:admin_api) | ||
end | ||
|
||
test "#with_token_refetch takes a block and returns its value" do | ||
result = with_token_refetch(@session, @id_token) do | ||
"returned by block" | ||
end | ||
|
||
assert_equal "returned by block", result | ||
end | ||
|
||
test "#with_token_refetch rescues Admin API HttpResponseError 401, performs token exchange and retries block" do | ||
response = ShopifyAPI::Clients::HttpResponse.new(code: 401, body: { error: "oops" }.to_json, headers: {}) | ||
error = ShopifyAPI::Errors::HttpResponseError.new(response: response) | ||
@fake_admin_api.stubs(:query).raises(error).then.returns("oh now we're good") | ||
|
||
ShopifyApp::Logger.expects(:debug).with("Shopify API returned a 401 Unauthorized error, exchanging token " \ | ||
"and retrying with new session") | ||
|
||
ShopifyApp::Auth::TokenExchange.expects(:perform).with(@id_token).returns(@new_session) | ||
|
||
result = with_token_refetch(@session, @id_token) do | ||
@fake_admin_api.query | ||
end | ||
|
||
assert_equal "oh now we're good", result | ||
end | ||
|
||
test "#with_token_refetch updates original session's attributes when token exchange is performed" do | ||
response = ShopifyAPI::Clients::HttpResponse.new(code: 401, body: "", headers: {}) | ||
error = ShopifyAPI::Errors::HttpResponseError.new(response: response) | ||
@fake_admin_api.stubs(:query).raises(error).then.returns("oh now we're good") | ||
|
||
ShopifyApp::Auth::TokenExchange.stubs(:perform).with(@id_token).returns(@new_session) | ||
|
||
with_token_refetch(@session, @id_token) do | ||
@fake_admin_api.query | ||
end | ||
|
||
assert_equal @new_session.access_token, @session.access_token | ||
assert_equal @new_session.expires, @session.expires | ||
end | ||
|
||
test "#with_token_refetch deletes existing token and re-raises when 401 persists" do | ||
response = ShopifyAPI::Clients::HttpResponse.new(code: 401, body: "401 message", headers: {}) | ||
api_error = ShopifyAPI::Errors::HttpResponseError.new(response: response) | ||
|
||
ShopifyApp::Auth::TokenExchange.stubs(:perform).with(@id_token).returns(@new_session) | ||
|
||
@fake_admin_api.expects(:query).twice.raises(api_error) | ||
|
||
ShopifyApp::Logger.expects(:debug).with("Shopify API returned a 401 Unauthorized error, exchanging token " \ | ||
"and retrying with new session") | ||
|
||
ShopifyApp::Logger.expects(:debug).with("Shopify API returned a 401 Unauthorized error that was not corrected " \ | ||
"with token exchange, deleting current session and re-raising") | ||
ShopifyApp::SessionRepository.expects(:delete_session).with("session-id") | ||
|
||
reraised_error = assert_raises ShopifyAPI::Errors::HttpResponseError do | ||
with_token_refetch(@session, @id_token) do | ||
@fake_admin_api.query | ||
end | ||
end | ||
|
||
assert_equal reraised_error, api_error | ||
end | ||
|
||
test "#with_token_refetch re-raises without deleting session when error is not a 401" do | ||
response = ShopifyAPI::Clients::HttpResponse.new(code: 500, body: { error: "ooops" }.to_json, headers: {}) | ||
api_error = ShopifyAPI::Errors::HttpResponseError.new(response: response) | ||
|
||
@fake_admin_api.expects(:query).raises(api_error) | ||
ShopifyApp::SessionRepository.expects(:delete_session).never | ||
ShopifyApp::Logger.expects(:debug).with(regexp_matches(/Encountered error: 500 \- .*ooops.*, re-raising/)) | ||
|
||
reraised_error = assert_raises ShopifyAPI::Errors::HttpResponseError do | ||
with_token_refetch(@session, @id_token) do | ||
@fake_admin_api.query | ||
end | ||
end | ||
|
||
assert_equal reraised_error, api_error | ||
end | ||
end |
Oops, something went wrong.