Skip to content

Commit

Permalink
Checkout V2: Retain and refresh OAuth access token
Browse files Browse the repository at this point in the history
Retain OAuth access token to be used on subsequent transactions and refreshed when it expires

Spreedly reference:
[ECS-2996](https://spreedly.atlassian.net/browse/ECS-2996)

Unit:
66 tests, 403 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
100% passed
2902.89 tests/s, 17725.19 assertions/s

Remote:
103 tests, 254 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
100% passed
0.63 tests/s, 1.56 assertions/s
  • Loading branch information
Luis Urrea committed Apr 26, 2024
1 parent 7034274 commit bd03d37
Show file tree
Hide file tree
Showing 3 changed files with 99 additions and 40 deletions.
58 changes: 31 additions & 27 deletions lib/active_merchant/billing/gateways/checkout_v2.rb
Expand Up @@ -17,15 +17,7 @@ class CheckoutV2Gateway < Gateway
TEST_ACCESS_TOKEN_URL = 'https://access.sandbox.checkout.com/connect/token'

def initialize(options = {})
@options = options
@access_token = options[:access_token] || nil

if options.has_key?(:secret_key)
requires!(options, :secret_key)
else
requires!(options, :client_id, :client_secret)
@access_token ||= setup_access_token
end
options.has_key?(:secret_key) ? requires!(options, :secret_key) : requires!(options, :client_id, :client_secret)

super
end
Expand Down Expand Up @@ -103,7 +95,8 @@ def scrub(transcript)
gsub(/("cvv\\":\\")\d+/, '\1[FILTERED]').
gsub(/("cryptogram\\":\\")\w+/, '\1[FILTERED]').
gsub(/(source\\":\{.*\\"token\\":\\")\d+/, '\1[FILTERED]').
gsub(/("token\\":\\")\w+/, '\1[FILTERED]')
gsub(/("token\\":\\")\w+/, '\1[FILTERED]').
gsub(/("access_token\\?"\s*:\s*\\?")[^"]*\w+/, '\1[FILTERED]')
end

def store(payment_method, options = {})
Expand Down Expand Up @@ -459,37 +452,48 @@ def access_token_url
end

def setup_access_token
request = 'grant_type=client_credentials'
begin
raw_response = ssl_post(access_token_url, request, access_token_header)
rescue ResponseError => e
raise OAuthResponseError.new(e)
else
response = parse(raw_response)
response = parse(ssl_post(access_token_url, 'grant_type=client_credentials', access_token_header))
@options[:access_token] = response['access_token']
@options[:expires] = response['expires_in'] && response['expires_in'] > 0 && (DateTime.now + (response['expires_in'] - 120).seconds).strftime('%Q').to_i

if (access_token = response['access_token'])
access_token
else
raise OAuthResponseError.new(response)
end
end
Response.new(
access_token_valid?,
message_from(true, response, {}),
response.merge({ expires: @options[:expires] }),
test: test?,
error_code: error_code_from(false, response, {})
)
rescue ResponseError => e
raise OAuthResponseError.new(e)
end

def commit(action, post, options, authorization = nil, method = :post)
def access_token_valid?
@options[:access_token].present? && @options[:expires].to_i > DateTime.now.strftime('%Q').to_i
end

def perform_payin(action, post, options, authorization = nil, method = :post)
begin
raw_response = ssl_request(method, url(action, authorization), post.nil? || post.empty? ? nil : post.to_json, headers(action, options))
response = parse(raw_response)
response['id'] = response['_links']['payment']['href'].split('/')[-1] if action == :capture && response.key?('_links')
source_id = authorization if action == :unstore
rescue ResponseError => e
@options[:access_token] = '' if e.response.code == '401' && !@options[:secret_key]

raise unless e.response.code.to_s =~ /4\d\d/

response = parse(e.response.body, error: e.response)
end

succeeded = success_from(action, response)

response(action, succeeded, response, options, source_id)
response(action, succeeded, response, options)
end

def commit(action, post, options, authorization = nil, method = :post)
MultiResponse.run do |r|
r.process { setup_access_token } unless @options[:secret_key] || access_token_valid?
r.process { perform_payin(action, post, options, authorization, method) }
end
end

def response(action, succeeded, response, options = {}, source_id = nil)
Expand All @@ -508,7 +512,7 @@ def response(action, succeeded, response, options = {}, source_id = nil)
end

def headers(action, options)
auth_token = @access_token ? "Bearer #{@access_token}" : @options[:secret_key]
auth_token = @options[:access_token] ? "Bearer #{@options[:access_token]}" : @options[:secret_key]
auth_token = @options[:public_key] if action == :tokens
headers = {
'Authorization' => auth_token,
Expand Down
60 changes: 52 additions & 8 deletions test/remote/gateways/remote_checkout_v2_test.rb
@@ -1,3 +1,4 @@
require 'timecop'
require 'test_helper'

class RemoteCheckoutV2Test < Test::Unit::TestCase
Expand Down Expand Up @@ -167,7 +168,7 @@ def test_failed_access_token
end
end

def test_failed_purchase_with_failed_access_token
def test_failed_purchase_with_failed_oauth_credentials
error = assert_raises(ActiveMerchant::OAuthResponseError) do
gateway = CheckoutV2Gateway.new({ client_id: 'YOUR_CLIENT_ID', client_secret: 'YOUR_CLIENT_SECRET' })
gateway.purchase(@amount, @credit_card, @options)
Expand Down Expand Up @@ -197,6 +198,7 @@ def test_transcript_scrubbing_via_oauth
assert_scrubbed(declined_card.verification_value, transcript)
assert_scrubbed(@gateway_oauth.options[:client_id], transcript)
assert_scrubbed(@gateway_oauth.options[:client_secret], transcript)
assert_scrubbed(@gateway_oauth.options[:access_token], transcript)
end

def test_network_transaction_scrubbing
Expand Down Expand Up @@ -231,6 +233,53 @@ def test_successful_purchase_via_oauth
assert_equal 'Succeeded', response.message
end

def test_successful_purchase_via_oauth_with_access_token
assert_nil @gateway_oauth.options[:access_token]
assert_nil @gateway_oauth.options[:expires]
purchase = @gateway_oauth.purchase(@amount, @credit_card, @options)
assert_success purchase
access_token = @gateway_oauth.options[:access_token]
expires = @gateway_oauth.options[:expires]
response = @gateway_oauth.purchase(@amount, @credit_card, @options)
assert_success response
assert_equal @gateway_oauth.options[:access_token], access_token
assert_equal @gateway_oauth.options[:expires], expires
end

def test_failure_purchase_via_oauth_with_invalid_access_token_without_expires
@gateway_oauth.options[:access_token] = 'ABC123'
@gateway_oauth.options[:expires] = DateTime.now.strftime('%Q').to_i + 3600.seconds

response = @gateway_oauth.purchase(@amount, @credit_card, @options)
assert_failure response
assert_equal '401: Unauthorized', response.message
assert_equal @gateway_oauth.options[:access_token], ''
end

def test_successful_purchase_via_oauth_with_invalid_access_token_with_correct_expires
@gateway_oauth.options[:access_token] = 'ABC123'
response = @gateway_oauth.purchase(@amount, @credit_card, @options)
assert_success response
assert_not_equal 'ABC123', @gateway_oauth.options[:access_token]
end

def test_successful_purchase_with_an_expired_access_token
initial_access_token = @gateway_oauth.options[:access_token] = SecureRandom.alphanumeric(10)
initial_expires = @gateway_oauth.options[:expires] = DateTime.now.strftime('%Q').to_i

Timecop.freeze(DateTime.now + 1.hour) do
purchase = @gateway_oauth.purchase(@amount, @credit_card, @options)
assert_success purchase

assert_equal 2, purchase.responses.size
assert_not_equal initial_access_token, @gateway_oauth.options[:access_token]
assert_not_equal initial_expires, @gateway.options[:expires]

assert_not_nil purchase.responses.first.params['access_token']
assert_not_nil purchase.responses.first.params['expires']
end
end

def test_successful_purchase_with_vts_network_token
response = @gateway.purchase(100, @vts_network_token, @options)
assert_success response
Expand Down Expand Up @@ -1005,21 +1054,16 @@ def test_failed_void_via_oauth

def test_successful_verify
response = @gateway.verify(@credit_card, @options)
# this should only be a Response and not a MultiResponse
# as we are passing in a 0 amount and there should be
# no void call
assert_instance_of(Response, response)
refute_instance_of(MultiResponse, response)
assert_success response
assert_match %r{Succeeded}, response.message
end

def test_successful_verify_via_oauth
response = @gateway_oauth.verify(@credit_card, @options)
assert_instance_of(Response, response)
refute_instance_of(MultiResponse, response)
assert_success response
assert_match %r{Succeeded}, response.message
assert_not_nil response.responses.first.params['access_token']
assert_not_nil response.responses.first.params['expires']
end

def test_failed_verify
Expand Down
21 changes: 16 additions & 5 deletions test/unit/gateways/checkout_v2_test.rb
Expand Up @@ -260,11 +260,16 @@ def test_successful_render_for_oauth
processing_channel_id = 'abcd123'
response = stub_comms(@gateway_oauth, :ssl_request) do
@gateway_oauth.purchase(@amount, @credit_card, { processing_channel_id: processing_channel_id })
end.check_request do |_method, _endpoint, data, headers|
request = JSON.parse(data)
assert_equal headers['Authorization'], 'Bearer 12345678'
assert_equal request['processing_channel_id'], processing_channel_id
end.respond_with(successful_purchase_response)
end.check_request do |_method, endpoint, data, headers|
if endpoint.match?(/token/)
assert_equal headers['Authorization'], 'Basic YWJjZDoxMjM0'
assert_equal data, 'grant_type=client_credentials'
else
request = JSON.parse(data)
assert_equal headers['Authorization'], 'Bearer 12345678'
assert_equal request['processing_channel_id'], processing_channel_id
end
end.respond_with(successful_access_token_response, successful_purchase_response)
assert_success response
end

Expand Down Expand Up @@ -1075,6 +1080,12 @@ def post_scrubbed
)
end

def successful_access_token_response
%(
{"access_token":"12345678","expires_in":3600,"token_type":"Bearer","scope":"disputes:accept disputes:provide-evidence disputes:view files flow:events flow:workflows fx gateway gateway:payment gateway:payment-authorizations gateway:payment-captures gateway:payment-details gateway:payment-refunds gateway:payment-voids middleware middleware:merchants-secret payouts:bank-details risk sessions:app sessions:browser vault:instruments"}
)
end

def successful_purchase_response
%(
{"id":"pay_bgv5tmah6fmuzcmcrcro6exe6m","action_id":"act_bgv5tmah6fmuzcmcrcro6exe6m","amount":200,"currency":"USD","approved":true,"status":"Authorized","auth_code":"127172","eci":"05","scheme_id":"096091887499308","response_code":"10000","response_summary":"Approved","risk":{"flagged":false},"source":{"id":"src_fzp3cwkf4ygebbmvrxdhyrwmbm","type":"card","billing_address":{"address_line1":"456 My Street","address_line2":"Apt 1","city":"Ottawa","state":"ON","zip":"K1C2N6","country":"CA"},"expiry_month":6,"expiry_year":2025,"name":"Longbob Longsen","scheme":"Visa","last4":"4242","fingerprint":"9F3BAD2E48C6C8579F2F5DC0710B7C11A8ACD5072C3363A72579A6FB227D64BE","bin":"424242","card_type":"Credit","card_category":"Consumer","issuer":"JPMORGAN CHASE BANK NA","issuer_country":"US","product_id":"A","product_type":"Visa Traditional","avs_check":"S","cvv_check":"Y","payouts":true,"fast_funds":"d"},"customer":{"id":"cus_tz76qzbwr44ezdfyzdvrvlwogy","email":"longbob.longsen@example.com","name":"Longbob Longsen"},"processed_on":"2020-09-11T13:58:32Z","reference":"1","processing":{"acquirer_transaction_id":"9819327011","retrieval_reference_number":"861613285622"},"_links":{"self":{"href":"https://api.sandbox.checkout.com/payments/pay_bgv5tmah6fmuzcmcrcro6exe6m"},"actions":{"href":"https://api.sandbox.checkout.com/payments/pay_bgv5tmah6fmuzcmcrcro6exe6m/actions"},"capture":{"href":"https://api.sandbox.checkout.com/payments/pay_bgv5tmah6fmuzcmcrcro6exe6m/captures"},"void":{"href":"https://api.sandbox.checkout.com/payments/pay_bgv5tmah6fmuzcmcrcro6exe6m/voids"}}}
Expand Down

0 comments on commit bd03d37

Please sign in to comment.