Skip to content

Commit 00a5f66

Browse files
author
Ben Creech
committed
Add support for off_session 3D Secure
This adds a simulation of a canonical Stripe test card that supports 3D Secure in off_session mode, if the configured properly with a setup_intent beforehand. This card stops requesting 3DS authentication at payment time if it was set up for off-session payments. This commit includes addition of a test-only _authenticate endpoint for setup_intents similar to the one that already exists for payment_intents. (Tests can POST to the _authenticate endpoint to simulate asynchronous interaction by the cardholder with 3D Secure challenges.)
1 parent f6b1803 commit 00a5f66

7 files changed

Lines changed: 228 additions & 23 deletions

File tree

localstripe/localstripe-v3.js

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -295,18 +295,14 @@ Stripe = (apiKey) => {
295295
} else if (body.status === 'succeeded') {
296296
return {error: null, setupIntent: body};
297297
} else if (body.status === 'requires_action') {
298-
const url =
299-
(await openModal('3D Secure\nDo you want to confirm or cancel?',
300-
'Complete authentication', 'Fail authentication'))
301-
? `${LOCALSTRIPE_BASE_API}/v1/setup_intents/${seti}/confirm`
302-
: `${LOCALSTRIPE_BASE_API}/v1/setup_intents/${seti}/cancel`;
298+
const success = await openModal(
299+
'3D Secure\nDo you want to confirm or cancel?',
300+
'Complete authentication', 'Fail authentication');
301+
let url = `${LOCALSTRIPE_BASE_API}/v1/setup_intents/${seti}` +
302+
`/_authenticate?success=${success}`;
303303
response = await fetch(url, {
304304
method: 'POST',
305-
body: JSON.stringify({
306-
key: apiKey,
307-
use_stripe_sdk: true,
308-
client_secret: clientSecret,
309-
}),
305+
body: JSON.stringify({key: apiKey}),
310306
});
311307
body = await response.json().catch(() => ({}));
312308
if (response.status !== 200 || body.error) {

localstripe/resources.py

Lines changed: 93 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -434,13 +434,18 @@ def __init__(self, source=None, **kwargs):
434434
self.tokenization_method = None
435435

436436
self.customer = None
437+
self._authenticated = False
437438

438439
@property
439440
def last4(self):
440441
return self._card_number[-4:]
441442

442-
def _requires_authentication(self):
443-
return PaymentMethod._requires_authentication(self)
443+
def _setup_requires_authentication(self, usage=None):
444+
return PaymentMethod._setup_requires_authentication(self, usage)
445+
446+
def _payment_requires_authentication(self, off_session):
447+
return PaymentMethod._payment_requires_authentication(
448+
self, off_session)
444449

445450
def _attaching_is_declined(self):
446451
return PaymentMethod._attaching_is_declined(self)
@@ -1830,7 +1835,8 @@ class PaymentIntent(StripeObject):
18301835

18311836
def __init__(self, amount=None, currency=None, customer=None,
18321837
payment_method=None, metadata=None, payment_method_types=None,
1833-
capture_method=None, payment_method_options=None, **kwargs):
1838+
capture_method=None, payment_method_options=None,
1839+
off_session=None, **kwargs):
18341840
if kwargs:
18351841
raise UserError(400, 'Unexpected ' + ', '.join(kwargs.keys()))
18361842

@@ -1850,6 +1856,8 @@ def __init__(self, amount=None, currency=None, customer=None,
18501856
assert capture_method in ('automatic',
18511857
'automatic_async',
18521858
'manual')
1859+
if off_session is not None:
1860+
assert type(off_session) is bool
18531861
except AssertionError:
18541862
raise UserError(400, 'Bad request')
18551863

@@ -1872,6 +1880,7 @@ def __init__(self, amount=None, currency=None, customer=None,
18721880
self.invoice = None
18731881
self.next_action = None
18741882
self.capture_method = capture_method or 'automatic_async'
1883+
self.off_session = off_session or False
18751884

18761885
self._canceled = False
18771886
self._authentication_failed = False
@@ -1963,7 +1972,7 @@ def _api_create(cls, confirm=None, off_session=None, **data):
19631972
except AssertionError:
19641973
raise UserError(400, 'Bad request')
19651974

1966-
obj = super()._api_create(**data)
1975+
obj = super()._api_create(off_session=off_session, **data)
19671976

19681977
if confirm:
19691978
obj._confirm(on_failure_now=obj._report_failure)
@@ -2001,7 +2010,7 @@ def _api_confirm(cls, id, payment_method=None, client_secret=None,
20012010
def _confirm(self, on_failure_now):
20022011
self._authentication_failed = False
20032012
payment_method = PaymentMethod._api_retrieve(self.payment_method)
2004-
if payment_method._requires_authentication():
2013+
if payment_method._payment_requires_authentication(self.off_session):
20052014
self.next_action = {
20062015
'type': 'use_stripe_sdk',
20072016
'use_stripe_sdk': {'type': 'three_d_secure_redirect',
@@ -2157,11 +2166,30 @@ def __init__(self, type=None, billing_details=None, card=None,
21572166

21582167
self.customer = None
21592168
self.metadata = metadata or {}
2169+
self._authenticated = False
2170+
2171+
def _setup_requires_authentication(self, usage=None):
2172+
if self.type == 'card':
2173+
if self._card_number == '4000002500003155':
2174+
# For this card, if we're setting up a payment method for
2175+
# off_session future payments, Stripe proactively forces
2176+
# 3DS authentication at setup time:
2177+
return usage == 'off_session'
2178+
2179+
return self._card_number in ('4000002760003184',
2180+
'4000008260003178',
2181+
'4000000000003220',
2182+
'4000000000003063',
2183+
'4000008400001629')
2184+
return False
21602185

2161-
def _requires_authentication(self):
2186+
def _payment_requires_authentication(self, off_session):
21622187
if self.type == 'card':
2163-
return self._card_number in ('4000002500003155',
2164-
'4000002760003184',
2188+
if self._card_number == '4000002500003155':
2189+
# See https://docs.stripe.com/testing#authentication-and-setup
2190+
return not (off_session and self._authenticated)
2191+
2192+
return self._card_number in ('4000002760003184',
21652193
'4000008260003178',
21662194
'4000000000003220',
21672195
'4000000000003063',
@@ -2274,6 +2302,14 @@ def _try_get_canonical_test_article(cls, id):
22742302
exp_month='12',
22752303
exp_year='2030',
22762304
cvc='123'))
2305+
if id == 'pm_card_authenticationRequiredOnSetup':
2306+
return PaymentMethod(
2307+
type='card',
2308+
card=dict(
2309+
number='4000002500003155',
2310+
exp_month='12',
2311+
exp_year='2030',
2312+
cvc='123'))
22772313

22782314
@classmethod
22792315
def _api_list_all(cls, url, customer=None, type=None, limit=None,
@@ -2715,9 +2751,15 @@ def __init__(self, type=None, currency=None, owner=None, metadata=None,
27152751
'mandate_url': 'https://fake/NXDSYREGC9PSMKWY',
27162752
}
27172753

2718-
def _requires_authentication(self):
2754+
def _setup_requires_authentication(self, usage=None):
2755+
if self.type == 'sepa_debit':
2756+
return PaymentMethod._setup_requires_authentication(self, usage)
2757+
return False
2758+
2759+
def _payment_requires_authentication(self, off_session):
27192760
if self.type == 'sepa_debit':
2720-
return PaymentMethod._requires_authentication(self)
2761+
return PaymentMethod._payment_requires_authentication(
2762+
self, off_session)
27212763
return False
27222764

27232765
def _attaching_is_declined(self):
@@ -2812,7 +2854,7 @@ def _attach_pm(self, pm):
28122854
self.next_action = None
28132855
raise UserError(402, 'Your card was declined.',
28142856
{'code': 'card_declined'})
2815-
elif pm._requires_authentication():
2857+
elif pm._setup_requires_authentication(self.usage):
28162858
self.status = 'requires_action'
28172859
self.next_action = {'type': 'use_stripe_sdk',
28182860
'use_stripe_sdk': {
@@ -2844,10 +2886,49 @@ def _api_cancel(cls, id, use_stripe_sdk=None, client_secret=None,
28442886
obj.next_action = None
28452887
return obj
28462888

2889+
@classmethod
2890+
def _api_authenticate(cls, id, success=False, **kwargs):
2891+
"""This is a test-only endpoint to help test payment methods which
2892+
require authentication during setup.
2893+
2894+
E.g., for credit cards which are subject to the 3D Secure protocol,
2895+
when confirmed, SetupIntent may transition to the 'requires_action'
2896+
status, with a 'next_action' indicating some flow that usually
2897+
involves human interaction from the cardholder. This endpoint bypasses
2898+
that required action for test purposes.
2899+
"""
2900+
2901+
if kwargs:
2902+
raise UserError(400, 'Unexpected ' + ', '.join(kwargs.keys()))
2903+
2904+
success = try_convert_to_bool(success)
2905+
try:
2906+
assert type(id) is str and id.startswith('seti_')
2907+
assert type(success) is bool
2908+
except AssertionError:
2909+
raise UserError(400, 'Bad request')
2910+
2911+
obj = cls._api_retrieve(id)
2912+
2913+
if obj.status != 'requires_action':
2914+
raise UserError(400, 'Bad request')
2915+
2916+
pm = PaymentMethod._api_retrieve(obj.payment_method)
2917+
2918+
if success:
2919+
pm._authenticated = True
2920+
2921+
obj.status = 'succeeded'
2922+
obj.next_action = None
2923+
2924+
return obj
2925+
28472926

28482927
extra_apis.extend((
28492928
('POST', '/v1/setup_intents/{id}/confirm', SetupIntent._api_confirm),
2850-
('POST', '/v1/setup_intents/{id}/cancel', SetupIntent._api_cancel)))
2929+
('POST', '/v1/setup_intents/{id}/cancel', SetupIntent._api_cancel),
2930+
('POST', '/v1/setup_intents/{id}/_authenticate',
2931+
SetupIntent._api_authenticate)))
28512932

28522933

28532934
class Subscription(StripeObject):

localstripe/server.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ async def auth_middleware(request, handler):
169169
r'^/v1/sources$',
170170
r'^/v1/payment_intents/\w+/_authenticate\b',
171171
r'^/v1/payment_intents/\w+/confirm$',
172+
r'^/v1/setup_intents/\w+/_authenticate\b',
172173
r'^/v1/setup_intents/\w+/confirm$',
173174
r'^/v1/setup_intents/\w+/cancel$',
174175
)))

samples/pm_setup/index.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ <h1>
2626
<form id="payment-form">
2727
<label for="payment-amount">Make a payment in cents:</label>
2828
<input id="payment-amount" name="amount" type="number"/>
29+
<label for="payment-off-session">Make payment off-session:</label>
30+
<input id="payment-off-session" name="payment-off-session" type="checkbox"/>
2931
<input type="submit" name="payment-submit" value="Submit"/>
3032
<div id="payment-result-message"></div>
3133
</form>

samples/pm_setup/pm_setup.js

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,23 @@ let handlePaymentMethodSubmit = async (event) => {
8484
let handlePaymentSubmit = async (event) => {
8585
event.preventDefault();
8686

87+
const container = document.getElementById('payment-result-message');
88+
89+
if (document.getElementById('payment-off-session').checked) {
90+
const response = await fetch('/pay_off_session', {
91+
method: "POST",
92+
body: JSON.stringify({
93+
amount: document.getElementById('payment-amount').value,
94+
})
95+
});
96+
if (!response.ok) {
97+
container.textContent = "Error making payment: " + await response.text();
98+
} else {
99+
container.textContent = "Successfully paid!";
100+
}
101+
return;
102+
}
103+
87104
const response = await fetch('/payment_intent', {
88105
method: "POST",
89106
body: JSON.stringify({
@@ -94,7 +111,6 @@ let handlePaymentSubmit = async (event) => {
94111

95112
const {error} = await stripe.confirmCardPayment(clientSecret, {});
96113

97-
const container = document.getElementById('payment-result-message');
98114
if (error) {
99115
container.textContent = error.message;
100116
} else {

samples/pm_setup/server.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,27 @@ async def payment_intent(request):
120120
))
121121

122122

123+
@routes.post('/pay_off_session')
124+
async def pay_off_session(request):
125+
global customer_state
126+
127+
body = await request.json()
128+
129+
pi = stripe.PaymentIntent.create(
130+
customer=customer_state.cus,
131+
payment_method=customer_state.pm,
132+
amount=body['amount'],
133+
currency='usd',
134+
off_session=True,
135+
confirm=True,
136+
)
137+
138+
if pi.status == 'succeeded':
139+
return web.Response()
140+
141+
return web.Response(status=400, text=f'Payment failed; status={pi.status}')
142+
143+
123144
app.add_routes(routes)
124145

125146

test.sh

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1220,3 +1220,91 @@ status=$(
12201220
-d items[0][plan]=basique-annuel \
12211221
| grep -oE '"status": "incomplete"')
12221222
[ -n "$status" ]
1223+
1224+
### test 3D Secure with both on-session and off-session payments
1225+
1226+
# Set up for on-session payments. Doesn't require authentication at setup time,
1227+
# but does require authentication when we make a payment_intent:
1228+
cus=$(curl -sSfg -u $SK: $HOST/v1/customers \
1229+
-d email=on_session@example.com \
1230+
| grep -oE 'cus_\w+' | head -n 1)
1231+
res=$(curl -sSfg -u $SK: -X POST $HOST/v1/setup_intents -d usage=on_session)
1232+
seti=$(echo "$res" | grep '"id"' | grep -oE 'seti_\w+' | head -n 1)
1233+
seti_secret=$(echo $res | grep -oE 'seti_\w+_secret_\w+' | head -n 1)
1234+
res=$(curl -sSfg $HOST/v1/setup_intents/$seti/confirm \
1235+
-d key=pk_test_sldkjflaksdfj \
1236+
-d client_secret=$seti_secret \
1237+
-d payment_method_data[type]=card \
1238+
-d payment_method_data[card][number]=4000002500003155 \
1239+
-d payment_method_data[card][cvc]=242 \
1240+
-d payment_method_data[card][exp_month]=4 \
1241+
-d payment_method_data[card][exp_year]=2030 \
1242+
-d payment_method_data[billing_details][address][postal_code]=42424)
1243+
succeeded=$(echo "$res" | grep -oE '"status": "succeeded"' | head -n 1)
1244+
[ -n "$succeeded" ]
1245+
pm=$(echo "$res" | grep '"payment_method"' | grep -oE 'pm_\w+' | head -n 1)
1246+
curl -u $SK: $HOST/v1/payment_methods/$pm/attach -d customer=$cus
1247+
# requires authentication for on-session payments:
1248+
res=$(curl -sSfg -u $SK: $HOST/v1/payment_intents \
1249+
-d customer=$cus \
1250+
-d payment_method=$pm \
1251+
-d amount=1000 \
1252+
-d confirm=true \
1253+
-d currency=usd)
1254+
requires_action=$(echo "$res" | grep -oE '"status": "requires_action"' | head -n 1)
1255+
[ -n "$requires_action" ]
1256+
# requires authentication for off-session payments too:
1257+
res=$(curl -sSfg -u $SK: $HOST/v1/payment_intents \
1258+
-d customer=$cus \
1259+
-d payment_method=$pm \
1260+
-d amount=1000 \
1261+
-d confirm=true \
1262+
-d off_session=true \
1263+
-d currency=usd)
1264+
requires_action=$(echo "$res" | grep -oE '"status": "requires_action"' | head -n 1)
1265+
[ -n "$requires_action" ]
1266+
1267+
# Set up for off-session payments. Does require authentication at setup time,
1268+
# but doesn't require authentication when we make an offline payment_intent:
1269+
cus=$(curl -sSfg -u $SK: $HOST/v1/customers \
1270+
-d email=off_session@example.com \
1271+
| grep -oE 'cus_\w+' | head -n 1)
1272+
res=$(curl -sSfg -u $SK: -X POST $HOST/v1/setup_intents -d usage=off_session)
1273+
seti=$(echo "$res" | grep '"id"' | grep -oE 'seti_\w+' | head -n 1)
1274+
seti_secret=$(echo $res | grep -oE 'seti_\w+_secret_\w+' | head -n 1)
1275+
res=$(curl -sSfg $HOST/v1/setup_intents/$seti/confirm \
1276+
-d key=pk_test_sldkjflaksdfj \
1277+
-d client_secret=$seti_secret \
1278+
-d payment_method_data[type]=card \
1279+
-d payment_method_data[card][number]=4000002500003155 \
1280+
-d payment_method_data[card][cvc]=242 \
1281+
-d payment_method_data[card][exp_month]=4 \
1282+
-d payment_method_data[card][exp_year]=2030 \
1283+
-d payment_method_data[billing_details][address][postal_code]=42424)
1284+
requires_action=$(echo "$res" | grep -oE '"status": "requires_action"' | head -n 1)
1285+
[ -n "$requires_action" ]
1286+
# Do a backdoor authentication using this test-only authenticate endpoint:
1287+
res=$(curl -f -u $SK: -X POST $HOST/v1/setup_intents/$seti/_authenticate)
1288+
succeeded=$(echo "$res" | grep -oE '"status": "succeeded"' | head -n 1)
1289+
[ -n "$succeeded" ]
1290+
pm=$(echo "$res" | grep '"payment_method"' | grep -oE 'pm_\w+' | head -n 1)
1291+
curl -u $SK: $HOST/v1/payment_methods/$pm/attach -d customer=$cus
1292+
# still requires authentication for on-session payments:
1293+
res=$(curl -sSfg -u $SK: $HOST/v1/payment_intents \
1294+
-d customer=$cus \
1295+
-d payment_method=$pm \
1296+
-d amount=1000 \
1297+
-d confirm=true \
1298+
-d currency=usd)
1299+
requires_action=$(echo "$res" | grep -oE '"status": "requires_action"' | head -n 1)
1300+
[ -n "$requires_action" ]
1301+
# but doesn't require authentication for off-session payments:
1302+
res=$(curl -sSfg -u $SK: $HOST/v1/payment_intents \
1303+
-d customer=$cus \
1304+
-d payment_method=$pm \
1305+
-d amount=1000 \
1306+
-d confirm=true \
1307+
-d off_session=true \
1308+
-d currency=usd)
1309+
succeeded=$(echo "$res" | grep -oE '"status": "succeeded"' | head -n 1)
1310+
[ -n "$succeeded" ]

0 commit comments

Comments
 (0)