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
  • Loading branch information
gilv93 committed Sep 18, 2024
1 parent 538a80a commit 7477e12
Show file tree
Hide file tree
Showing 12 changed files with 181 additions and 23 deletions.
7 changes: 6 additions & 1 deletion lib/recurly.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,12 @@ const DEFAULTS = {
},
report: false,
risk: {
threeDSecure: { preflightDeviceDataCollector: true }
threeDSecure: {
preflightDeviceDataCollector: true,
proactive: {
enabled: false,
}
}
},
api: DEFAULT_API_URL,
fields: {
Expand Down
1 change: 1 addition & 0 deletions lib/recurly/alternative-payment-methods/gateways/adyen.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isBindingElement } from 'typescript';

Check notice

Code scanning / CodeQL

Unused variable, import, function or class Note

Unused import isBindingElement.
import Base from './base';

class AdyenGateway extends Base {
Expand Down
14 changes: 11 additions & 3 deletions lib/recurly/risk/risk.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,19 @@ 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 }) {
function resolveRoute (recurly) {
let route = '/risk/preflights'
if (recurly.config.risk.threeDSecure.proactive.enabled) {
route += `?proactive=true&gatewayCode=${recurly.config.risk.threeDSecure.proactive.gateway_code}`
}
return route
}

return recurly.request.get({ route: resolveRoute(recurly) })
.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') {
Expand Down
35 changes: 30 additions & 5 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,40 @@ 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 { proactive } = recurly.config.risk.threeDSecure;

if(!proactive.enabled) {
return Promise.resolve();
}

const { PREFLIGHT_TIMEOUT } = BraintreeStrategy;

Check notice

Code scanning / CodeQL

Unused variable, import, function or class Note

Unused variable PREFLIGHT_TIMEOUT.
const data = {
gatewayType: BraintreeStrategy.strategyName,
gatewayCode: proactive.gateway_code,
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 }) => ({
payment_method_nonce: paymentMethodNonce,
client_token: clientToken,
bin,
proactive: true
}));
}


constructor (...args) {
super(...args);

Expand All @@ -23,15 +50,15 @@ export default class BraintreeStrategy extends ThreeDSecureStrategy {
}

get braintreeClientToken () {
return this.actionToken.three_d_secure.params.client_token;
return this.actionToken.three_d_secure.params.client_token
}

get nonce () {
return this.actionToken.three_d_secure.params.payment_method_nonce;
}

get amount () {
return this.actionToken.transaction.amount;
return this.actionToken.transaction?.amount || this.actionToken.three_d_secure.amount;
}

get billingInfo () {
Expand All @@ -54,9 +81,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
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
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 @@ -94,21 +94,21 @@ 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 { gateway_code } = result.params;
const strategy = ThreeDSecure.getStrategyForGatewayType(type);
return strategy.preflight({ recurly, number, month, year, ...result.params })
return strategy.preflight({ recurly, number, month, year, cvv, ...result.params })
.then(results => {
return finishedPreflights.concat([{ processor: type, gateway_code, results }]);
});
});
}, Promise.resolve([]));
}

constructor ({ risk, actionTokenId, challengeWindowSize }) {
constructor ({ risk, actionTokenId, challengeWindowSize, proactiveTokenId }) {
const existingConcern = risk.concerns.find((concern) => concern instanceof ThreeDSecure);
if (existingConcern) {
throw errors('3ds-multiple-instances', { name: 'ThreeDSecure', expect: 'to be the only concern' });
Expand All @@ -117,25 +117,28 @@ export class ThreeDSecure extends RiskConcern {
super({ risk });

this.actionTokenId = actionTokenId;
this.proactiveTokenId = proactiveTokenId;

this.validateChallengeWindowSize(challengeWindowSize);
this.challengeWindowSize = challengeWindowSize || this.constructor.CHALLENGE_WINDOW_SIZE_DEFAULT;

if (!actionTokenId) {
if (!actionTokenId && !proactiveTokenId) {
throw errors('invalid-option', { name: 'actionTokenId', expect: 'a three_d_secure_action_token_id' });
}

this.recurly.request.get({ route: `/tokens/${actionTokenId}` })
this.recurly.request.get({ route: `/tokens/${this.resolveToken()}` })
.catch(err => this.error(err))
.then(token => {
assertIsActionToken(token);
this.strategy = this.getStrategyForActionToken(token);
this.strategy.on('done', (...args) => this.onStrategyDone(...args));
if (this.resolveToken() == this.actionTokenId) {
this.resolveActionToken(token);
} else {
this.resolveProactiveToken(token);
}
this.markReady();
})
.catch(err => this.error(err));

this.report('create', { actionTokenId });
this.report('create', { actionTokenId, proactiveTokenId });
this.whenReady(() => this.report('ready', { strategy: this.strategy.strategyName }));
}

Expand Down Expand Up @@ -169,6 +172,11 @@ export class ThreeDSecure extends RiskConcern {
return new strategy({ threeDSecure: this, actionToken });
}

getStrategyForProactiveToken(token) {
const strategy = ThreeDSecure.getStrategyForGatewayType(token.three_d_secure.gateway.type);
return new strategy({ threeDSecure: this, proactiveToken: token });
}

/**
* Creates a ThreeDSecureActionResultToken from action results
*
Expand All @@ -181,6 +189,7 @@ export class ThreeDSecure extends RiskConcern {
const data = {
type: 'three_d_secure_action_result',
three_d_secure_action_token_id: this.actionTokenId,
proactive_three_d_secure_token_id: this.proactiveTokenId,
results
};
debug('submitting results for tokenization', data);
Expand Down Expand Up @@ -216,6 +225,21 @@ export class ThreeDSecure extends RiskConcern {
throw new Error(`Invalid challengeWindowSize. Expected any of ${validWindowSizes}, got ${challengeWindowSize}`);
}
}

resolveToken () {
return this.actionTokenId || this.proactiveTokenId;
}

resolveActionToken(token) {
assertIsActionToken(token);
this.strategy = this.getStrategyForActionToken(token);
this.strategy.on('done', (...args) => this.onStrategyDone(...args));
}

resolveProactiveToken(token) {
this.strategy = this.getStrategyForProactiveToken(token);
this.strategy.on('done', (...args) => this.onStrategyDone(...args));
}
}

function assertIsActionToken (token) {
Expand Down
23 changes: 20 additions & 3 deletions lib/recurly/token.js
Original file line number Diff line number Diff line change
Expand Up @@ -172,9 +172,11 @@ 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(results => {
enrichInputs(this, inputs, results)
})
.then(() => this.request.post({ route: '/token', data: inputs, done: complete }))
.done();
}
Expand All @@ -186,4 +188,19 @@ function token (customerData, bus, done) {
}
done(null, res);
}

function enrichInputs(recurly, inputs, results) {
if (results.length === 0) return;

inputs.risk = []

results.forEach(result => {
if (result.processor === 'braintree_blue') {
inputs.proactive = recurly.config.risk.threeDSecure.proactive;
inputs.proactive.params = result;
} else {
inputs.risk.push(result);
}
})
}
}
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
13 changes: 13 additions & 0 deletions test/unit/risk.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,19 @@ 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';
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 = [
Expand Down
26 changes: 26 additions & 0 deletions test/unit/risk/three-d-secure.test.js
Original file line number Diff line number Diff line change
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
3 changes: 3 additions & 0 deletions types/lib/configure.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ export type RecurlyOptions = {
risk?: {
threeDSecure?: {
preflightDeviceDataCollector?: boolean;
proactive?: {
enabled: true;
}
}
};

Expand Down

0 comments on commit 7477e12

Please sign in to comment.