Skip to content

Commit

Permalink
Add Proactive 3DS flow as alternative to legacy 3DS flow
Browse files Browse the repository at this point in the history
Simplifies proactive 3-D Secure APIs
  • Loading branch information
gilv93 committed Sep 30, 2024
1 parent 168095b commit b3caa97
Show file tree
Hide file tree
Showing 15 changed files with 195 additions and 42 deletions.
8 changes: 7 additions & 1 deletion lib/recurly.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,13 @@ const DEFAULTS = {
},
report: false,
risk: {
threeDSecure: { preflightDeviceDataCollector: true }
threeDSecure: {
preflightDeviceDataCollector: true,
proactive: {
enabled: false,
gatewayCode: ''
}
}
},
api: DEFAULT_API_URL,
fields: {
Expand Down
28 changes: 19 additions & 9 deletions lib/recurly/risk/risk.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}));
}

Expand Down
38 changes: 34 additions & 4 deletions lib/recurly/risk/three-d-secure/strategy/braintree.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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 () {
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion lib/recurly/risk/three-d-secure/strategy/cybersource.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
4 changes: 2 additions & 2 deletions lib/recurly/risk/three-d-secure/strategy/strategy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down
2 changes: 1 addition & 1 deletion lib/recurly/risk/three-d-secure/strategy/worldpay.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
42 changes: 33 additions & 9 deletions lib/recurly/risk/three-d-secure/three-d-secure.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand All @@ -94,18 +99,32 @@ 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: {
// processor: gatewayType,
// gateway_code,
// risk
// // finishedPreflights.risk.concat(risk)
// }
risk: finishedPreflights.risk.concat({
processor: gatewayType,
gateway_code,
results
})
};
});
});
}, Promise.resolve([]));
}, Promise.resolve({ risk: [] }));
}

constructor ({ risk, actionTokenId, challengeWindowSize }) {
Expand Down Expand Up @@ -183,6 +202,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 });
}
Expand Down Expand Up @@ -219,6 +239,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(',')}`
});
}
9 changes: 6 additions & 3 deletions lib/recurly/token.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
23 changes: 23 additions & 0 deletions test/unit/recurly.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down
26 changes: 20 additions & 6 deletions test/unit/risk.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand All @@ -85,24 +85,38 @@ describe('Risk', function () {
});
});

// it('appends proactive data to the preflight request when enabled', function (done) {
// const { recurly, sandbox, bin } = this;
// recurly.config.risk.threeDSecure.proactive.enabled = true;
// recurly.config.risk.threeDSecure.proactive.gateway_code = 'test-gateway-code';
// recurly.config.risk.threeDSecure.proactive.amount = 0.00
// sandbox.spy(recurly.request, 'get');
// Risk.preflight({ recurly, bin })
// .done(results => {
// assert(recurly.request.get.calledOnce);
// assert(recurly.request.get.calledWithMatch({ route: '/risk/preflights?proactive=true&gatewayCode=test-gateway-code' }));
// done();
// });
// });

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);
});

it('filters out those timeout results', function (done) {
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();
});
});
Expand Down
34 changes: 30 additions & 4 deletions test/unit/risk/three-d-secure.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' } })
}));
});

Expand All @@ -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();
Expand Down Expand Up @@ -242,6 +242,32 @@ describe('ThreeDSecure', function () {
});
});

// describe('when a proactiveTokenId is provided', function () {
// it('throws an error if it is not valid', function (done) {
// const { risk } = this;
// const threeDSecure = new ThreeDSecure({ risk, proactiveTokenId: 'invalid-token-id' });

// threeDSecure.on('error', err => {
// assert.strictEqual(err.code, 'not-found');
// assert.strictEqual(err.message, 'Token not found');
// done();
// });
// });

// it('calls onStrategyDone when a strategy completes', function (done) {
// const { sandbox, threeDSecure } = this;
// const example = { arbitrary: 'test-payload' };
// sandbox.spy(threeDSecure, 'onStrategyDone');

// threeDSecure.whenReady(() => {
// threeDSecure.strategy.emit('done', example);
// assert(threeDSecure.onStrategyDone.calledOnce);
// assert(threeDSecure.onStrategyDone.calledWithMatch(example));
// done();
// });
// });
// });

describe('challengeWindowSize', function() {
it('validates', function () {
const challengeWindowSize = 'xx';
Expand Down
2 changes: 1 addition & 1 deletion test/unit/risk/three-d-secure/strategy/cybersource.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading

0 comments on commit b3caa97

Please sign in to comment.