From cb2cdfb9a5e522310f2d4621e4ce63b92d38df1b Mon Sep 17 00:00:00 2001 From: gil Date: Tue, 27 Aug 2024 08:24:35 -0700 Subject: [PATCH] Add Proactive 3DS flow as alternative to legacy 3DS flow Simplifies proactive 3-D Secure APIs --- lib/recurly.js | 8 +++- lib/recurly/risk/risk.js | 28 +++++++++----- .../risk/three-d-secure/strategy/braintree.js | 38 +++++++++++++++++-- .../three-d-secure/strategy/cybersource.js | 2 +- .../risk/three-d-secure/strategy/strategy.js | 4 +- .../risk/three-d-secure/strategy/worldpay.js | 2 +- .../risk/three-d-secure/three-d-secure.js | 36 +++++++++++++----- lib/recurly/token.js | 9 +++-- .../fixtures/tokens/proactive-token-test.json | 13 +++++++ test/unit/recurly.test.js | 23 +++++++++++ test/unit/risk.test.js | 12 +++--- test/unit/risk/three-d-secure.test.js | 8 ++-- .../strategy/cybersource.test.js | 2 +- .../three-d-secure/strategy/worldpay.test.js | 2 +- types/lib/configure.d.ts | 4 ++ 15 files changed, 149 insertions(+), 42 deletions(-) create mode 100644 packages/public-api-fixture-server/fixtures/tokens/proactive-token-test.json diff --git a/lib/recurly.js b/lib/recurly.js index 42424cc64..19181a9c0 100644 --- a/lib/recurly.js +++ b/lib/recurly.js @@ -70,7 +70,13 @@ const DEFAULTS = { }, report: false, risk: { - threeDSecure: { preflightDeviceDataCollector: true } + threeDSecure: { + preflightDeviceDataCollector: true, + proactive: { + enabled: false, + gatewayCode: '' + } + } }, api: DEFAULT_API_URL, fields: { diff --git a/lib/recurly/risk/risk.js b/lib/recurly/risk/risk.js index 54078c2b8..91c744e21 100644 --- a/lib/recurly/risk/risk.js +++ b/lib/recurly/risk/risk.js @@ -55,18 +55,28 @@ export class Risk { * @param {String} options.bin credit card BIN * @return {Promise} */ - static preflight ({ recurly, number, month, year }) { - return recurly.request.get({ route: '/risk/preflights' }) + static preflight ({ recurly, number, month, year, cvv }) { + const data = {}; + + if (recurly.config.risk.threeDSecure.proactive.enabled) { + data.proactive = true; + data.gateway_code = recurly.config.risk.threeDSecure.proactive.gatewayCode; + } + + return recurly.request.get({ route: '/risk/preflights', data }) .then(({ preflights }) => { debug('received preflight instructions', preflights); - return ThreeDSecure.preflight({ recurly, number, month, year, preflights }); + return ThreeDSecure.preflight({ recurly, number, month, year, cvv, preflights }); }) - .then(results => results.filter(maybeErr => { - if (maybeErr.code === 'risk-preflight-timeout') { - debug('timeout encountered', maybeErr); - return false; - } - return true; + .then(({ tokenType, risk }) => ({ + risk: risk.filter(maybeErr => { + if (maybeErr.code === 'risk-preflight-timeout') { + debug('timeout encountered', maybeErr); + return false; + } + return true; + }), + tokenType })); } diff --git a/lib/recurly/risk/three-d-secure/strategy/braintree.js b/lib/recurly/risk/three-d-secure/strategy/braintree.js index d7327de11..782963fd7 100644 --- a/lib/recurly/risk/three-d-secure/strategy/braintree.js +++ b/lib/recurly/risk/three-d-secure/strategy/braintree.js @@ -4,13 +4,45 @@ import ThreeDSecureStrategy from './strategy'; const debug = require('debug')('recurly:risk:three-d-secure:braintree'); export default class BraintreeStrategy extends ThreeDSecureStrategy { - static strategyName = 'braintree_blue'; loadBraintreeLibraries () { return BraintreeLoader.loadModules('threeDSecure'); } + static preflight ({ recurly, number, month, year, cvv }) { + const { enabled, gatewayCode, amount } = recurly.config.risk.threeDSecure.proactive; + + debug('performing preflight for', { gatewayCode }); + + if (!enabled) { + return Promise.resolve(); + } + + const data = { + gateway_type: BraintreeStrategy.strategyName, + gateway_code: gatewayCode, + number, + month, + year, + cvv + }; + + // we don't really need to do anything once we get a response except + // resolve with relevant data instead of session_id + return recurly.request.post({ route: '/risk/authentications', data }) + .then(({ paymentMethodNonce, clientToken, bin }) => ({ + results: { + payment_method_nonce: paymentMethodNonce, + client_token: clientToken, + bin, + amount: amount + }, + tokenType: 'three_d_secure_proactive_action' + })); + } + + constructor (...args) { super(...args); @@ -31,7 +63,7 @@ export default class BraintreeStrategy extends ThreeDSecureStrategy { } get amount () { - return this.actionToken.transaction.amount; + return this.actionToken.transaction?.amount || this.actionToken.three_d_secure.amount; } get billingInfo () { @@ -54,9 +86,7 @@ export default class BraintreeStrategy extends ThreeDSecureStrategy { this.whenReady(() => { debug('Attempting to load braintree'); - const { braintree, braintreeClientToken, amount, nonce, bin, billingInfo } = this; - const verifyCardOptions = { amount: amount, nonce: nonce, diff --git a/lib/recurly/risk/three-d-secure/strategy/cybersource.js b/lib/recurly/risk/three-d-secure/strategy/cybersource.js index 3c22f1633..7c313a20a 100644 --- a/lib/recurly/risk/three-d-secure/strategy/cybersource.js +++ b/lib/recurly/risk/three-d-secure/strategy/cybersource.js @@ -44,7 +44,7 @@ export default class CybersourceStrategy extends ThreeDSecureStrategy { const body = JSON.parse(data); if (body.MessageType === 'profile.completed') { debug('received device data session id', body); - resolve({ session_id: body.SessionId }); + resolve({ results: { session_id: body.SessionId } }); frame.destroy(); recurly.bus.off('raw-message', listener); } diff --git a/lib/recurly/risk/three-d-secure/strategy/strategy.js b/lib/recurly/risk/three-d-secure/strategy/strategy.js index 587fb8491..ae95eab2e 100644 --- a/lib/recurly/risk/three-d-secure/strategy/strategy.js +++ b/lib/recurly/risk/three-d-secure/strategy/strategy.js @@ -7,10 +7,10 @@ export default class ThreeDSecureStrategy extends ReadinessEmitter { static preflight () {} static PREFLIGHT_TIMEOUT = 30000; - constructor ({ threeDSecure, actionToken }) { + constructor ({ threeDSecure, actionToken, proactiveToken }) { super(); this.threeDSecure = threeDSecure; - this.actionToken = actionToken; + this.actionToken = actionToken || proactiveToken; } get strategyName () { diff --git a/lib/recurly/risk/three-d-secure/strategy/worldpay.js b/lib/recurly/risk/three-d-secure/strategy/worldpay.js index 38f958860..3d16e1ea3 100644 --- a/lib/recurly/risk/three-d-secure/strategy/worldpay.js +++ b/lib/recurly/risk/three-d-secure/strategy/worldpay.js @@ -44,7 +44,7 @@ export default class WorldpayStrategy extends ThreeDSecureStrategy { const body = JSON.parse(data); if (body.MessageType === 'profile.completed') { debug('received device data session id', body); - resolve({ session_id: body.SessionId }); + resolve({ results: { session_id: body.SessionId } }); recurly.bus.off('raw-message', listener); frame.destroy(); } diff --git a/lib/recurly/risk/three-d-secure/three-d-secure.js b/lib/recurly/risk/three-d-secure/three-d-secure.js index efc79f2f8..75fb21875 100644 --- a/lib/recurly/risk/three-d-secure/three-d-secure.js +++ b/lib/recurly/risk/three-d-secure/three-d-secure.js @@ -71,6 +71,11 @@ export class ThreeDSecure extends RiskConcern { '05': { height: '100%', width: '100%' } } + static VALID_ACTION_TOKEN_TYPES = [ + 'three_d_secure_action', + 'three_d_secure_proactive_action' + ]; + /** * Returns a strateggy for a given gateway type * @@ -94,18 +99,26 @@ export class ThreeDSecure extends RiskConcern { * @param {Preflights} options.preflights * @return {Promise} */ - static preflight ({ recurly, number, month, year, preflights }) { + static preflight ({ recurly, number, month, year, cvv, preflights }) { return preflights.reduce((preflight, result) => { return preflight.then((finishedPreflights) => { - const { type } = result.gateway; + const { type: gatewayType } = result.gateway; const { gateway_code } = result.params; - const strategy = ThreeDSecure.getStrategyForGatewayType(type); - return strategy.preflight({ recurly, number, month, year, ...result.params }) - .then(results => { - return finishedPreflights.concat([{ processor: type, gateway_code, results }]); + const strategy = ThreeDSecure.getStrategyForGatewayType(gatewayType); + return strategy.preflight({ recurly, number, month, year, cvv, ...result.params }) + .then(({ results, tokenType }) => { + // return finishedPreflights.concat([{ processor: type, gateway_code, results}]); + return { + tokenType: finishedPreflights.tokenType || tokenType, + risk: finishedPreflights.risk.concat({ + processor: gatewayType, + gateway_code, + results + }) + }; }); }); - }, Promise.resolve([])); + }, Promise.resolve({ risk: [] })); } constructor ({ risk, actionTokenId, challengeWindowSize }) { @@ -183,6 +196,7 @@ export class ThreeDSecure extends RiskConcern { three_d_secure_action_token_id: this.actionTokenId, results }; + debug('submitting results for tokenization', data); return this.recurly.request.post({ route: '/tokens', data }); } @@ -219,6 +233,10 @@ export class ThreeDSecure extends RiskConcern { } function assertIsActionToken (token) { - if (token && token.type === 'three_d_secure_action') return; - throw errors('invalid-option', { name: 'actionTokenId', expect: 'a three_d_secure_action_token_id' }); + if (ThreeDSecure.VALID_ACTION_TOKEN_TYPES.includes(token?.type)) return; + + throw errors('invalid-option', { + name: 'actionTokenId', + expect: `a token of type: ${ThreeDSecure.VALID_ACTION_TOKEN_TYPES.join(',')}` + }); } diff --git a/lib/recurly/token.js b/lib/recurly/token.js index 3db4bf6b4..6357c60b8 100644 --- a/lib/recurly/token.js +++ b/lib/recurly/token.js @@ -172,9 +172,12 @@ function token (customerData, bus, done) { })); } - const { number, month, year } = inputs; - Risk.preflight({ recurly: this, number, month, year }) - .then(results => inputs.risk = results) + const { number, month, year, cvv } = inputs; + Risk.preflight({ recurly: this, number, month, year, cvv }) + .then(({ risk, tokenType }) => { + inputs.risk = risk; + if (tokenType) inputs.type = tokenType; + }) .then(() => this.request.post({ route: '/token', data: inputs, done: complete })) .done(); } diff --git a/packages/public-api-fixture-server/fixtures/tokens/proactive-token-test.json b/packages/public-api-fixture-server/fixtures/tokens/proactive-token-test.json new file mode 100644 index 000000000..98d1209d7 --- /dev/null +++ b/packages/public-api-fixture-server/fixtures/tokens/proactive-token-test.json @@ -0,0 +1,13 @@ +{ + "type": "three_d_secure_proactive_action", + "id": "proactive-token-test", + "gateway": { + "code": "1234567890", + "type": "test" + }, + "three_d_secure": { + "params": { + "challengeType": "challenge" + } + } +} diff --git a/test/unit/recurly.test.js b/test/unit/recurly.test.js index ee7292fa2..27504e18a 100644 --- a/test/unit/recurly.test.js +++ b/test/unit/recurly.test.js @@ -155,6 +155,29 @@ describe('Recurly', function () { }); }); }); + + describe('when proactive3ds', function () { + describe('is set to true', function() { + it('returns true', function () { + const recurly = initRecurly({ + risk: { + threeDSecure: { + proactive: { + enabled: true + } + } + } + }); + assert.strictEqual(recurly.config.risk.threeDSecure.proactive.enabled, true); + }); + }); + describe('is not set', function() { + it('returns false', function () { + const recurly = initRecurly({}); + assert.strictEqual(recurly.config.risk.threeDSecure.proactive.enabled, false); + }); + }) + }); }); describe('destroy', function () { diff --git a/test/unit/risk.test.js b/test/unit/risk.test.js index 674bbcf00..f5059daaa 100644 --- a/test/unit/risk.test.js +++ b/test/unit/risk.test.js @@ -69,7 +69,7 @@ describe('Risk', function () { const { sandbox, recurly } = this; this.bin = '411111'; this.recurly = initRecurly({ publicKey: 'test-preflight-key' }); - this.stubPreflightResults = [{ arbitrary: 'preflight-results' }]; + this.stubPreflightResults = { risk: [{ arbitrary: 'results' }], tokenType: undefined }; sandbox.stub(ThreeDSecure, 'preflight').usingPromise(Promise).resolves(this.stubPreflightResults); }); @@ -87,12 +87,12 @@ describe('Risk', function () { describe('when some results are timeouts', function () { beforeEach(function () { - this.stubPreflightResults = [ + this.stubPreflightResults = { risk: [ { arbitrary: 'preflight-results' }, errors('risk-preflight-timeout', { processor: 'test' }), { arbitrary: 'preflight-results-2' }, errors('risk-preflight-timeout', { processor: 'test-2' }) - ]; + ], tokenType: undefined}; ThreeDSecure.preflight.usingPromise(Promise).resolves(this.stubPreflightResults); }); @@ -100,9 +100,9 @@ describe('Risk', function () { const { recurly, bin, stubPreflightResults } = this; Risk.preflight({ recurly, bin }) .done(results => { - assert.strictEqual(results.length, 2); - assert.deepStrictEqual(results[0], stubPreflightResults[0]); - assert.deepStrictEqual(results[1], stubPreflightResults[2]); + assert.strictEqual(results.risk.length, 2); + assert.deepStrictEqual(results.risk[0], stubPreflightResults.risk[0]); + assert.deepStrictEqual(results.risk[1], stubPreflightResults.risk[2]); done(); }); }); diff --git a/test/unit/risk/three-d-secure.test.js b/test/unit/risk/three-d-secure.test.js index d453d1755..090b2b512 100644 --- a/test/unit/risk/three-d-secure.test.js +++ b/test/unit/risk/three-d-secure.test.js @@ -95,7 +95,7 @@ describe('ThreeDSecure', function () { } ]; sandbox.stub(ThreeDSecure, 'getStrategyForGatewayType').callsFake(() => ({ - preflight: sandbox.stub().usingPromise(Promise).resolves({ arbitrary: 'test-results' }) + preflight: sandbox.stub().usingPromise(Promise).resolves({ results: { arbitrary: 'test-results' } }) })); }); @@ -108,9 +108,9 @@ describe('ThreeDSecure', function () { it('resolves with preflight results from strategies', function (done) { const { recurly, bin, preflights } = this; const returnValue = ThreeDSecure.preflight({ recurly, bin, preflights }) - .done(response => { - const [{ processor, results }] = response; - assert.strictEqual(Array.isArray(response), true); + .done(({ risk }) => { + const [{ processor, results }] = risk; + assert.strictEqual(Array.isArray(risk), true); assert.strictEqual(processor, 'test-gateway-type'); assert.deepStrictEqual(results, { arbitrary: 'test-results' }); done(); diff --git a/test/unit/risk/three-d-secure/strategy/cybersource.test.js b/test/unit/risk/three-d-secure/strategy/cybersource.test.js index 9021e15a1..58d02cab6 100644 --- a/test/unit/risk/three-d-secure/strategy/cybersource.test.js +++ b/test/unit/risk/three-d-secure/strategy/cybersource.test.js @@ -87,7 +87,7 @@ describe('CybersourceStrategy', function () { const { recurly, Strategy, sessionId, number, month, year, gateway_code, jwt, poll } = this; Strategy.preflight({ recurly, number, month, year, gateway_code }).then(preflightResponse => { - assert.strictEqual(preflightResponse.session_id, sessionId); + assert.strictEqual(preflightResponse.results.session_id, sessionId); clearInterval(poll); done(); diff --git a/test/unit/risk/three-d-secure/strategy/worldpay.test.js b/test/unit/risk/three-d-secure/strategy/worldpay.test.js index f360dec83..3f659da2f 100644 --- a/test/unit/risk/three-d-secure/strategy/worldpay.test.js +++ b/test/unit/risk/three-d-secure/strategy/worldpay.test.js @@ -81,7 +81,7 @@ describe('WorldpayStrategy', function () { const { recurly, Strategy, number, jwt, deviceDataCollectionUrl, sessionId, simulatePreflightResponse } = this; Strategy.preflight({ recurly, number, jwt, deviceDataCollectionUrl }).then(preflightResponse => { - assert.strictEqual(preflightResponse.session_id, sessionId); + assert.strictEqual(preflightResponse.results.session_id, sessionId); done(); }); diff --git a/types/lib/configure.d.ts b/types/lib/configure.d.ts index af58ad7e2..70d000e4f 100644 --- a/types/lib/configure.d.ts +++ b/types/lib/configure.d.ts @@ -24,6 +24,10 @@ export type RecurlyOptions = { risk?: { threeDSecure?: { preflightDeviceDataCollector?: boolean; + proactive?: { + enabled: true; + gatewayCode: string; + } } };