Skip to content

Commit b3caa97

Browse files
committed
Add Proactive 3DS flow as alternative to legacy 3DS flow
Simplifies proactive 3-D Secure APIs
1 parent 168095b commit b3caa97

File tree

15 files changed

+195
-42
lines changed

15 files changed

+195
-42
lines changed

lib/recurly.js

+7-1
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,13 @@ const DEFAULTS = {
7070
},
7171
report: false,
7272
risk: {
73-
threeDSecure: { preflightDeviceDataCollector: true }
73+
threeDSecure: {
74+
preflightDeviceDataCollector: true,
75+
proactive: {
76+
enabled: false,
77+
gatewayCode: ''
78+
}
79+
}
7480
},
7581
api: DEFAULT_API_URL,
7682
fields: {

lib/recurly/risk/risk.js

+19-9
Original file line numberDiff line numberDiff line change
@@ -55,18 +55,28 @@ export class Risk {
5555
* @param {String} options.bin credit card BIN
5656
* @return {Promise}
5757
*/
58-
static preflight ({ recurly, number, month, year }) {
59-
return recurly.request.get({ route: '/risk/preflights' })
58+
static preflight ({ recurly, number, month, year, cvv }) {
59+
const data = {};
60+
61+
if (recurly.config.risk.threeDSecure.proactive.enabled) {
62+
data.proactive = true;
63+
data.gateway_code = recurly.config.risk.threeDSecure.proactive.gatewayCode;
64+
}
65+
66+
return recurly.request.get({ route: '/risk/preflights', data })
6067
.then(({ preflights }) => {
6168
debug('received preflight instructions', preflights);
62-
return ThreeDSecure.preflight({ recurly, number, month, year, preflights });
69+
return ThreeDSecure.preflight({ recurly, number, month, year, cvv, preflights });
6370
})
64-
.then(results => results.filter(maybeErr => {
65-
if (maybeErr.code === 'risk-preflight-timeout') {
66-
debug('timeout encountered', maybeErr);
67-
return false;
68-
}
69-
return true;
71+
.then(({ tokenType, risk }) => ({
72+
risk: risk.filter(maybeErr => {
73+
if (maybeErr.code === 'risk-preflight-timeout') {
74+
debug('timeout encountered', maybeErr);
75+
return false;
76+
}
77+
return true;
78+
}),
79+
tokenType
7080
}));
7181
}
7282

lib/recurly/risk/three-d-secure/strategy/braintree.js

+34-4
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,45 @@ import ThreeDSecureStrategy from './strategy';
44
const debug = require('debug')('recurly:risk:three-d-secure:braintree');
55

66
export default class BraintreeStrategy extends ThreeDSecureStrategy {
7-
87
static strategyName = 'braintree_blue';
98

109
loadBraintreeLibraries () {
1110
return BraintreeLoader.loadModules('threeDSecure');
1211
}
1312

13+
static preflight ({ recurly, number, month, year, cvv }) {
14+
const { enabled, gatewayCode, amount } = recurly.config.risk.threeDSecure.proactive;
15+
16+
debug('performing preflight for', { gatewayCode });
17+
18+
if (!enabled) {
19+
return Promise.resolve();
20+
}
21+
22+
const data = {
23+
gateway_type: BraintreeStrategy.strategyName,
24+
gateway_code: gatewayCode,
25+
number,
26+
month,
27+
year,
28+
cvv
29+
};
30+
31+
// we don't really need to do anything once we get a response except
32+
// resolve with relevant data instead of session_id
33+
return recurly.request.post({ route: '/risk/authentications', data })
34+
.then(({ paymentMethodNonce, clientToken, bin }) => ({
35+
results: {
36+
payment_method_nonce: paymentMethodNonce,
37+
client_token: clientToken,
38+
bin,
39+
amount: amount
40+
},
41+
tokenType: 'three_d_secure_proactive_action'
42+
}));
43+
}
44+
45+
1446
constructor (...args) {
1547
super(...args);
1648

@@ -31,7 +63,7 @@ export default class BraintreeStrategy extends ThreeDSecureStrategy {
3163
}
3264

3365
get amount () {
34-
return this.actionToken.transaction.amount;
66+
return this.actionToken.transaction?.amount || this.actionToken.three_d_secure.amount;
3567
}
3668

3769
get billingInfo () {
@@ -54,9 +86,7 @@ export default class BraintreeStrategy extends ThreeDSecureStrategy {
5486

5587
this.whenReady(() => {
5688
debug('Attempting to load braintree');
57-
5889
const { braintree, braintreeClientToken, amount, nonce, bin, billingInfo } = this;
59-
6090
const verifyCardOptions = {
6191
amount: amount,
6292
nonce: nonce,

lib/recurly/risk/three-d-secure/strategy/cybersource.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export default class CybersourceStrategy extends ThreeDSecureStrategy {
4444
const body = JSON.parse(data);
4545
if (body.MessageType === 'profile.completed') {
4646
debug('received device data session id', body);
47-
resolve({ session_id: body.SessionId });
47+
resolve({ results: { session_id: body.SessionId } });
4848
frame.destroy();
4949
recurly.bus.off('raw-message', listener);
5050
}

lib/recurly/risk/three-d-secure/strategy/strategy.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@ export default class ThreeDSecureStrategy extends ReadinessEmitter {
77
static preflight () {}
88
static PREFLIGHT_TIMEOUT = 30000;
99

10-
constructor ({ threeDSecure, actionToken }) {
10+
constructor ({ threeDSecure, actionToken, proactiveToken }) {
1111
super();
1212
this.threeDSecure = threeDSecure;
13-
this.actionToken = actionToken;
13+
this.actionToken = actionToken || proactiveToken;
1414
}
1515

1616
get strategyName () {

lib/recurly/risk/three-d-secure/strategy/worldpay.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export default class WorldpayStrategy extends ThreeDSecureStrategy {
4444
const body = JSON.parse(data);
4545
if (body.MessageType === 'profile.completed') {
4646
debug('received device data session id', body);
47-
resolve({ session_id: body.SessionId });
47+
resolve({ results: { session_id: body.SessionId } });
4848
recurly.bus.off('raw-message', listener);
4949
frame.destroy();
5050
}

lib/recurly/risk/three-d-secure/three-d-secure.js

+33-9
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@ export class ThreeDSecure extends RiskConcern {
7171
'05': { height: '100%', width: '100%' }
7272
}
7373

74+
static VALID_ACTION_TOKEN_TYPES = [
75+
'three_d_secure_action',
76+
'three_d_secure_proactive_action'
77+
];
78+
7479
/**
7580
* Returns a strateggy for a given gateway type
7681
*
@@ -94,18 +99,32 @@ export class ThreeDSecure extends RiskConcern {
9499
* @param {Preflights} options.preflights
95100
* @return {Promise}
96101
*/
97-
static preflight ({ recurly, number, month, year, preflights }) {
102+
static preflight ({ recurly, number, month, year, cvv, preflights }) {
98103
return preflights.reduce((preflight, result) => {
99104
return preflight.then((finishedPreflights) => {
100-
const { type } = result.gateway;
105+
const { type: gatewayType } = result.gateway;
101106
const { gateway_code } = result.params;
102-
const strategy = ThreeDSecure.getStrategyForGatewayType(type);
103-
return strategy.preflight({ recurly, number, month, year, ...result.params })
104-
.then(results => {
105-
return finishedPreflights.concat([{ processor: type, gateway_code, results }]);
107+
const strategy = ThreeDSecure.getStrategyForGatewayType(gatewayType);
108+
return strategy.preflight({ recurly, number, month, year, cvv, ...result.params })
109+
.then(({ results, tokenType }) => {
110+
// return finishedPreflights.concat([{ processor: type, gateway_code, results}]);
111+
return {
112+
tokenType: finishedPreflights.tokenType || tokenType,
113+
// risk: {
114+
// processor: gatewayType,
115+
// gateway_code,
116+
// risk
117+
// // finishedPreflights.risk.concat(risk)
118+
// }
119+
risk: finishedPreflights.risk.concat({
120+
processor: gatewayType,
121+
gateway_code,
122+
results
123+
})
124+
};
106125
});
107126
});
108-
}, Promise.resolve([]));
127+
}, Promise.resolve({ risk: [] }));
109128
}
110129

111130
constructor ({ risk, actionTokenId, challengeWindowSize }) {
@@ -183,6 +202,7 @@ export class ThreeDSecure extends RiskConcern {
183202
three_d_secure_action_token_id: this.actionTokenId,
184203
results
185204
};
205+
186206
debug('submitting results for tokenization', data);
187207
return this.recurly.request.post({ route: '/tokens', data });
188208
}
@@ -219,6 +239,10 @@ export class ThreeDSecure extends RiskConcern {
219239
}
220240

221241
function assertIsActionToken (token) {
222-
if (token && token.type === 'three_d_secure_action') return;
223-
throw errors('invalid-option', { name: 'actionTokenId', expect: 'a three_d_secure_action_token_id' });
242+
if (ThreeDSecure.VALID_ACTION_TOKEN_TYPES.includes(token?.type)) return;
243+
244+
throw errors('invalid-option', {
245+
name: 'actionTokenId',
246+
expect: `a token of type: ${ThreeDSecure.VALID_ACTION_TOKEN_TYPES.join(',')}`
247+
});
224248
}

lib/recurly/token.js

+6-3
Original file line numberDiff line numberDiff line change
@@ -172,9 +172,12 @@ function token (customerData, bus, done) {
172172
}));
173173
}
174174

175-
const { number, month, year } = inputs;
176-
Risk.preflight({ recurly: this, number, month, year })
177-
.then(results => inputs.risk = results)
175+
const { number, month, year, cvv } = inputs;
176+
Risk.preflight({ recurly: this, number, month, year, cvv })
177+
.then(({ risk, tokenType }) => {
178+
inputs.risk = risk;
179+
if (tokenType) inputs.type = tokenType;
180+
})
178181
.then(() => this.request.post({ route: '/token', data: inputs, done: complete }))
179182
.done();
180183
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"type": "three_d_secure_proactive_action",
3+
"id": "proactive-token-test",
4+
"gateway": {
5+
"code": "1234567890",
6+
"type": "test"
7+
},
8+
"three_d_secure": {
9+
"params": {
10+
"challengeType": "challenge"
11+
}
12+
}
13+
}

test/unit/recurly.test.js

+23
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,29 @@ describe('Recurly', function () {
155155
});
156156
});
157157
});
158+
159+
describe('when proactive3ds', function () {
160+
describe('is set to true', function() {
161+
it('returns true', function () {
162+
const recurly = initRecurly({
163+
risk: {
164+
threeDSecure: {
165+
proactive: {
166+
enabled: true
167+
}
168+
}
169+
}
170+
});
171+
assert.strictEqual(recurly.config.risk.threeDSecure.proactive.enabled, true);
172+
});
173+
});
174+
describe('is not set', function() {
175+
it('returns false', function () {
176+
const recurly = initRecurly({});
177+
assert.strictEqual(recurly.config.risk.threeDSecure.proactive.enabled, false);
178+
});
179+
})
180+
});
158181
});
159182

160183
describe('destroy', function () {

test/unit/risk.test.js

+20-6
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ describe('Risk', function () {
6969
const { sandbox, recurly } = this;
7070
this.bin = '411111';
7171
this.recurly = initRecurly({ publicKey: 'test-preflight-key' });
72-
this.stubPreflightResults = [{ arbitrary: 'preflight-results' }];
72+
this.stubPreflightResults = { risk: [{ arbitrary: 'results' }], tokenType: undefined };
7373
sandbox.stub(ThreeDSecure, 'preflight').usingPromise(Promise).resolves(this.stubPreflightResults);
7474
});
7575

@@ -85,24 +85,38 @@ describe('Risk', function () {
8585
});
8686
});
8787

88+
// it('appends proactive data to the preflight request when enabled', function (done) {
89+
// const { recurly, sandbox, bin } = this;
90+
// recurly.config.risk.threeDSecure.proactive.enabled = true;
91+
// recurly.config.risk.threeDSecure.proactive.gateway_code = 'test-gateway-code';
92+
// recurly.config.risk.threeDSecure.proactive.amount = 0.00
93+
// sandbox.spy(recurly.request, 'get');
94+
// Risk.preflight({ recurly, bin })
95+
// .done(results => {
96+
// assert(recurly.request.get.calledOnce);
97+
// assert(recurly.request.get.calledWithMatch({ route: '/risk/preflights?proactive=true&gatewayCode=test-gateway-code' }));
98+
// done();
99+
// });
100+
// });
101+
88102
describe('when some results are timeouts', function () {
89103
beforeEach(function () {
90-
this.stubPreflightResults = [
104+
this.stubPreflightResults = { risk: [
91105
{ arbitrary: 'preflight-results' },
92106
errors('risk-preflight-timeout', { processor: 'test' }),
93107
{ arbitrary: 'preflight-results-2' },
94108
errors('risk-preflight-timeout', { processor: 'test-2' })
95-
];
109+
], tokenType: undefined};
96110
ThreeDSecure.preflight.usingPromise(Promise).resolves(this.stubPreflightResults);
97111
});
98112

99113
it('filters out those timeout results', function (done) {
100114
const { recurly, bin, stubPreflightResults } = this;
101115
Risk.preflight({ recurly, bin })
102116
.done(results => {
103-
assert.strictEqual(results.length, 2);
104-
assert.deepStrictEqual(results[0], stubPreflightResults[0]);
105-
assert.deepStrictEqual(results[1], stubPreflightResults[2]);
117+
assert.strictEqual(results.risk.length, 2);
118+
assert.deepStrictEqual(results.risk[0], stubPreflightResults.risk[0]);
119+
assert.deepStrictEqual(results.risk[1], stubPreflightResults.risk[2]);
106120
done();
107121
});
108122
});

test/unit/risk/three-d-secure.test.js

+30-4
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ describe('ThreeDSecure', function () {
9595
}
9696
];
9797
sandbox.stub(ThreeDSecure, 'getStrategyForGatewayType').callsFake(() => ({
98-
preflight: sandbox.stub().usingPromise(Promise).resolves({ arbitrary: 'test-results' })
98+
preflight: sandbox.stub().usingPromise(Promise).resolves({ results: { arbitrary: 'test-results' } })
9999
}));
100100
});
101101

@@ -108,9 +108,9 @@ describe('ThreeDSecure', function () {
108108
it('resolves with preflight results from strategies', function (done) {
109109
const { recurly, bin, preflights } = this;
110110
const returnValue = ThreeDSecure.preflight({ recurly, bin, preflights })
111-
.done(response => {
112-
const [{ processor, results }] = response;
113-
assert.strictEqual(Array.isArray(response), true);
111+
.done(({ risk }) => {
112+
const [{ processor, results }] = risk;
113+
assert.strictEqual(Array.isArray(risk), true);
114114
assert.strictEqual(processor, 'test-gateway-type');
115115
assert.deepStrictEqual(results, { arbitrary: 'test-results' });
116116
done();
@@ -242,6 +242,32 @@ describe('ThreeDSecure', function () {
242242
});
243243
});
244244

245+
// describe('when a proactiveTokenId is provided', function () {
246+
// it('throws an error if it is not valid', function (done) {
247+
// const { risk } = this;
248+
// const threeDSecure = new ThreeDSecure({ risk, proactiveTokenId: 'invalid-token-id' });
249+
250+
// threeDSecure.on('error', err => {
251+
// assert.strictEqual(err.code, 'not-found');
252+
// assert.strictEqual(err.message, 'Token not found');
253+
// done();
254+
// });
255+
// });
256+
257+
// it('calls onStrategyDone when a strategy completes', function (done) {
258+
// const { sandbox, threeDSecure } = this;
259+
// const example = { arbitrary: 'test-payload' };
260+
// sandbox.spy(threeDSecure, 'onStrategyDone');
261+
262+
// threeDSecure.whenReady(() => {
263+
// threeDSecure.strategy.emit('done', example);
264+
// assert(threeDSecure.onStrategyDone.calledOnce);
265+
// assert(threeDSecure.onStrategyDone.calledWithMatch(example));
266+
// done();
267+
// });
268+
// });
269+
// });
270+
245271
describe('challengeWindowSize', function() {
246272
it('validates', function () {
247273
const challengeWindowSize = 'xx';

test/unit/risk/three-d-secure/strategy/cybersource.test.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ describe('CybersourceStrategy', function () {
8787
const { recurly, Strategy, sessionId, number, month, year, gateway_code, jwt, poll } = this;
8888

8989
Strategy.preflight({ recurly, number, month, year, gateway_code }).then(preflightResponse => {
90-
assert.strictEqual(preflightResponse.session_id, sessionId);
90+
assert.strictEqual(preflightResponse.results.session_id, sessionId);
9191

9292
clearInterval(poll);
9393
done();

0 commit comments

Comments
 (0)