Skip to content

Commit 51ce641

Browse files
committed
fix: align Stripe and parser conformance behavior
1 parent 24f92ec commit 51ce641

12 files changed

Lines changed: 569 additions & 69 deletions

File tree

src/main/java/com/stripe/mpp/Challenge.java

Lines changed: 119 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -86,29 +86,33 @@ public static Challenge create(
8686
public static List<Challenge> fromWwwAuthenticate(List<String> wwwAuthenticateHeaders) {
8787
List<Challenge> challenges = new ArrayList<>();
8888
for (String header : wwwAuthenticateHeaders) {
89-
// Find each "Payment " scheme in the header. Auth-params are comma-separated, so
90-
// we cannot simply split by comma; instead we locate the scheme token boundary.
91-
String authParams = extractPaymentAuthParams(header);
92-
if (authParams == null) continue;
93-
94-
Map<String, String> params = Parsing.parseAuthParams(authParams);
95-
Map<String, Object> opaque = null;
96-
String opaqueVal = params.get("opaque");
97-
if (opaqueVal != null && !opaqueVal.isEmpty()) {
98-
opaque = ChallengeId.b64urlDecodeToMap(opaqueVal);
89+
for (String authParams : extractPaymentAuthParamChunks(header)) {
90+
Map<String, String> params = Parsing.parseAuthParams(authParams);
91+
String id = Parsing.requireString(params, "id");
92+
String realm = Parsing.requireString(params, "realm");
93+
String method = Parsing.requireString(params, "method");
94+
Parsing.validatePaymentMethodId(method);
95+
String intent = Parsing.requireString(params, "intent");
96+
String requestB64 = Parsing.requireString(params, "request");
97+
Map<String, Object> request = ChallengeId.b64urlDecodeToMap(requestB64);
98+
Map<String, Object> opaque = null;
99+
String opaqueVal = params.get("opaque");
100+
if (opaqueVal != null && !opaqueVal.isEmpty()) {
101+
opaque = ChallengeId.b64urlDecodeToMap(opaqueVal);
102+
}
103+
challenges.add(new Challenge(
104+
id,
105+
method,
106+
intent,
107+
request,
108+
realm,
109+
requestB64,
110+
params.get("digest"),
111+
params.get("expires"),
112+
params.get("description"),
113+
opaque
114+
));
99115
}
100-
challenges.add(new Challenge(
101-
params.get("id"),
102-
params.get("method"),
103-
params.get("intent"),
104-
null,
105-
params.get("realm"),
106-
params.get("request"),
107-
params.get("digest"),
108-
params.get("expires"),
109-
params.get("description"),
110-
opaque
111-
));
112116
}
113117
return challenges;
114118
}
@@ -118,12 +122,99 @@ public static List<Challenge> fromWwwAuthenticate(List<String> wwwAuthenticateHe
118122
* Handles multi-scheme headers like "Bearer token, Payment id=...".
119123
*/
120124
static String extractPaymentAuthParams(String header) {
121-
String lower = header.toLowerCase();
122-
// Match at start or after a scheme boundary (", " before the scheme token)
123-
if (lower.startsWith("payment ")) return header.substring("payment ".length());
124-
int idx = lower.indexOf(", payment ");
125-
if (idx >= 0) return header.substring(idx + ", payment ".length());
126-
return null;
125+
List<String> chunks = extractPaymentAuthParamChunks(header);
126+
return chunks.isEmpty() ? null : chunks.get(0);
127+
}
128+
129+
private static List<String> extractPaymentAuthParamChunks(String header) {
130+
List<AuthScheme> schemes = authSchemes(header, 0);
131+
List<String> chunks = new ArrayList<>();
132+
for (int i = 0; i < schemes.size(); i++) {
133+
AuthScheme scheme = schemes.get(i);
134+
if (!"Payment".equalsIgnoreCase(scheme.name)) continue;
135+
136+
int end = i + 1 < schemes.size() ? schemes.get(i + 1).index : header.length();
137+
String chunk = header.substring(scheme.paramsStart, end).replaceAll(",\\s*$", "").trim();
138+
if (!chunk.isEmpty()) chunks.add(chunk);
139+
}
140+
return chunks;
141+
}
142+
143+
private static List<AuthScheme> authSchemes(String header, int offset) {
144+
List<AuthScheme> schemes = new ArrayList<>();
145+
boolean inQuote = false;
146+
boolean escaped = false;
147+
int i = offset;
148+
149+
while (i < header.length()) {
150+
char c = header.charAt(i);
151+
if (inQuote) {
152+
if (escaped) {
153+
escaped = false;
154+
} else if (c == '\\') {
155+
escaped = true;
156+
} else if (c == '"') {
157+
inQuote = false;
158+
}
159+
i++;
160+
continue;
161+
}
162+
163+
if (c == '"') {
164+
inQuote = true;
165+
i++;
166+
continue;
167+
}
168+
169+
if (schemeBoundary(header, i) && isSchemeStart(c)) {
170+
int tokenEnd = i + 1;
171+
while (tokenEnd < header.length() && isSchemeChar(header.charAt(tokenEnd))) {
172+
tokenEnd++;
173+
}
174+
int paramsStart = tokenEnd;
175+
if (paramsStart < header.length() && Character.isWhitespace(header.charAt(paramsStart))) {
176+
while (paramsStart < header.length() && Character.isWhitespace(header.charAt(paramsStart))) {
177+
paramsStart++;
178+
}
179+
if (paramsStart >= header.length() || header.charAt(paramsStart) != '=') {
180+
schemes.add(new AuthScheme(i, paramsStart, header.substring(i, tokenEnd)));
181+
i = paramsStart;
182+
continue;
183+
}
184+
}
185+
}
186+
187+
i++;
188+
}
189+
return schemes;
190+
}
191+
192+
private static boolean schemeBoundary(String header, int index) {
193+
int i = index - 1;
194+
while (i >= 0 && Character.isWhitespace(header.charAt(i))) i--;
195+
return i < 0 || header.charAt(i) == ',';
196+
}
197+
198+
private static boolean isSchemeStart(char c) {
199+
return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z');
200+
}
201+
202+
private static boolean isSchemeChar(char c) {
203+
return isSchemeStart(c)
204+
|| (c >= '0' && c <= '9')
205+
|| c == '.' || c == '_' || c == '~' || c == '+' || c == '/' || c == '-';
206+
}
207+
208+
private static final class AuthScheme {
209+
private final int index;
210+
private final int paramsStart;
211+
private final String name;
212+
213+
private AuthScheme(int index, int paramsStart, String name) {
214+
this.index = index;
215+
this.paramsStart = paramsStart;
216+
this.name = name;
217+
}
127218
}
128219

129220
public static List<Challenge> fromWwwAuthenticate(String wwwAuthenticate) {

src/main/java/com/stripe/mpp/ChallengeId.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,12 @@ public static byte[] b64urlDecode(String encoded) {
6161
/** Decode a base64url string to a JSON map. */
6262
@SuppressWarnings("unchecked")
6363
public static Map<String, Object> b64urlDecodeToMap(String encoded) {
64-
byte[] bytes = b64urlDecode(encoded);
64+
byte[] bytes;
65+
try {
66+
bytes = b64urlDecode(encoded);
67+
} catch (IllegalArgumentException e) {
68+
throw new com.stripe.mpp.error.ParseException("Invalid base64url encoding", e);
69+
}
6570
String json = new String(bytes, java.nio.charset.StandardCharsets.UTF_8);
6671
try {
6772
return Json.MAPPER.readValue(json, Map.class);

src/main/java/com/stripe/mpp/Parsing.java

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,9 @@ private Parsing() {}
1919

2020
// Matches: key="quoted value" or key=token
2121
private static final Pattern AUTH_PARAM = Pattern.compile(
22-
"(\\w+)=(?:\"([^\"\\\\]*(?:\\\\.[^\"\\\\]*)*)\"|([^\\s,]+))"
22+
"([A-Za-z_][\\w-]*)\\s*=\\s*(?:\"([^\"\\\\]*(?:\\\\.[^\"\\\\]*)*)\"|([^\\s,]+))"
2323
);
24+
private static final Pattern PAYMENT_METHOD_ID = Pattern.compile("[a-z]+");
2425

2526
// --- Encoding helpers ---
2627

@@ -31,7 +32,12 @@ static String b64Encode(Object data) {
3132

3233
/** Decode base64url to a parsed JSON object, or return the raw string if not JSON. */
3334
static Object b64Decode(String encoded) {
34-
byte[] bytes = ChallengeId.b64urlDecode(encoded);
35+
byte[] bytes;
36+
try {
37+
bytes = ChallengeId.b64urlDecode(encoded);
38+
} catch (IllegalArgumentException e) {
39+
throw new ParseException("Invalid base64url encoding", e);
40+
}
3541
String str = new String(bytes, StandardCharsets.UTF_8);
3642
try {
3743
return Json.parse(str);
@@ -54,6 +60,7 @@ static Map<String, String> parseAuthParams(String input) {
5460
Matcher m = AUTH_PARAM.matcher(input);
5561
while (m.find()) {
5662
String key = m.group(1);
63+
if (params.containsKey(key)) throw new ParseException("Duplicate parameter: " + key);
5764
String value = m.group(2) != null ? m.group(2) : m.group(3);
5865
// Unescape backslash sequences in quoted strings
5966
if (m.group(2) != null) value = value.replace("\\\"", "\"").replace("\\\\", "\\");
@@ -62,6 +69,20 @@ static Map<String, String> parseAuthParams(String input) {
6269
return params;
6370
}
6471

72+
static String requireString(Map<String, ?> map, String key) {
73+
Object value = map.get(key);
74+
if (!(value instanceof String) || ((String) value).isEmpty()) {
75+
throw new ParseException("Missing " + key);
76+
}
77+
return (String) value;
78+
}
79+
80+
static void validatePaymentMethodId(String method) {
81+
if (method == null || !PAYMENT_METHOD_ID.matcher(method).matches()) {
82+
throw new ParseException("Invalid payment method ID");
83+
}
84+
}
85+
6586
private static String quote(String value) {
6687
if (value.indexOf('\r') >= 0 || value.indexOf('\n') >= 0) {
6788
throw new IllegalArgumentException("Header values must not contain CR or LF");
@@ -98,6 +119,8 @@ static Credential parseAuthorization(String header) {
98119
Map<String, Object> challengeMap = (Map<String, Object>) challengeObj;
99120

100121
if (!challengeMap.containsKey("id")) throw new ParseException("Credential challenge missing required field: id");
122+
String method = requireString(challengeMap, "method");
123+
validatePaymentMethodId(method);
101124

102125
Map<String, Object> opaque = null;
103126
if (challengeMap.get("opaque") instanceof Map) {
@@ -107,7 +130,7 @@ static Credential parseAuthorization(String header) {
107130
ChallengeEcho echo = new ChallengeEcho(
108131
str(challengeMap, "id"),
109132
str(challengeMap, "realm"),
110-
str(challengeMap, "method"),
133+
method,
111134
str(challengeMap, "intent"),
112135
str(challengeMap, "request"),
113136
str(challengeMap, "expires"),
@@ -118,7 +141,7 @@ static Credential parseAuthorization(String header) {
118141
Object payload = envelope.get("payload");
119142
if (payload == null) throw new ParseException("Credential missing required field: payload");
120143

121-
String source = envelope.get("source") instanceof String ? (String) envelope.get("source") : header;
144+
String source = envelope.get("source") instanceof String ? (String) envelope.get("source") : null;
122145
return new Credential(echo, payload, source);
123146
}
124147

@@ -170,12 +193,14 @@ static Receipt parsePaymentReceipt(String header) {
170193
String reference = str(map, "reference");
171194
if (reference == null) throw new ParseException("Missing reference");
172195

173-
String method = str(map, "method");
174-
if (method == null) method = "";
196+
String method = requireString(map, "method");
197+
validatePaymentMethodId(method);
175198

176199
Object extra = map.get("extra");
177200

178-
return new Receipt(status, timestamp, reference, method, str(map, "external_id"), extra);
201+
String externalId = str(map, "externalId");
202+
if (externalId == null) externalId = str(map, "external_id");
203+
return new Receipt(status, timestamp, reference, method, externalId, extra);
179204
}
180205

181206
static String formatPaymentReceipt(Receipt receipt) {
@@ -186,7 +211,7 @@ static String formatPaymentReceipt(Receipt receipt) {
186211
if (receipt.method() != null && !receipt.method().isEmpty())
187212
map.put("method", receipt.method());
188213
if (receipt.externalId() != null)
189-
map.put("external_id", receipt.externalId());
214+
map.put("externalId", receipt.externalId());
190215
if (receipt.extra() != null)
191216
map.put("extra", receipt.extra());
192217
return b64Encode(map);

src/main/java/com/stripe/mpp/Receipt.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,12 @@ public Receipt(String status, Instant timestamp, String reference, String method
3434
/**
3535
* Create a success receipt with the current timestamp.
3636
*/
37+
public static Receipt success(String reference, String method, String externalId, Object extra) {
38+
return new Receipt("success", Instant.now(), reference, method, externalId, extra);
39+
}
40+
3741
public static Receipt success(String reference, String method, String externalId) {
38-
return new Receipt("success", Instant.now(), reference, method, externalId, null);
42+
return success(reference, method, externalId, null);
3943
}
4044

4145
public static Receipt success(String reference, String method) {

src/main/java/com/stripe/mpp/methods/stripe/Stripe.java

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,15 @@ private Stripe() {}
2929
* @param networkId Stripe profile/network identifier sent to the client in the challenge
3030
*/
3131
public static StripeMethod method(String secretKey, String networkId) {
32-
return method(secretKey, networkId, null, null);
32+
return method(secretKey, networkId, List.of("card"), null);
3333
}
3434

3535
/**
3636
* Returns a {@link StripeMethod} with full configuration.
3737
*
3838
* @param secretKey Stripe secret API key
3939
* @param networkId Stripe network identifier sent to the client in the challenge
40-
* @param paymentMethods allowed payment method types (e.g. {@code List.of("card", "link")}); may be null
40+
* @param paymentMethods allowed payment method types (e.g. {@code List.of("card", "link")}); defaults to card if null
4141
* @param metadata optional metadata attached to created PaymentIntents; may be null
4242
*/
4343
public static StripeMethod method(
@@ -47,7 +47,23 @@ public static StripeMethod method(
4747
Map<String, String> metadata
4848
) {
4949
return new StripeMethod(
50-
secretKey, networkId, paymentMethods, metadata,
50+
secretKey, networkId, paymentMethods, metadata, null,
51+
StripeDefaults.DEFAULT_DECIMALS
52+
);
53+
}
54+
55+
/**
56+
* Returns a {@link StripeMethod} with full configuration and a request-bound external ID.
57+
*/
58+
public static StripeMethod method(
59+
String secretKey,
60+
String networkId,
61+
List<String> paymentMethods,
62+
Map<String, String> metadata,
63+
String externalId
64+
) {
65+
return new StripeMethod(
66+
secretKey, networkId, paymentMethods, metadata, externalId,
5167
StripeDefaults.DEFAULT_DECIMALS
5268
);
5369
}

src/main/java/com/stripe/mpp/methods/stripe/StripeApi.java

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import com.stripe.param.PaymentIntentCreateParams;
77
import com.stripe.mpp.error.VerificationFailedException;
88

9+
import java.util.List;
910
import java.util.Map;
1011
import java.util.Objects;
1112

@@ -52,6 +53,7 @@ Result createAndConfirm(
5253
long amountMinorUnits,
5354
String currency,
5455
String spt,
56+
List<String> paymentMethodTypes,
5557
Map<String, String> metadata
5658
) {
5759
try {
@@ -61,13 +63,7 @@ Result createAndConfirm(
6163
.setAmount(amountMinorUnits)
6264
.setCurrency(currency)
6365
.setConfirm(true)
64-
.setAutomaticPaymentMethods(
65-
PaymentIntentCreateParams.AutomaticPaymentMethods.builder()
66-
.setEnabled(true)
67-
.setAllowRedirects(
68-
PaymentIntentCreateParams.AutomaticPaymentMethods.AllowRedirects.NEVER)
69-
.build()
70-
)
66+
.addAllPaymentMethodType(paymentMethodTypes)
7167
.putExtraParam("shared_payment_granted_token", spt);
7268

7369
if (metadata != null && !metadata.isEmpty()) {

0 commit comments

Comments
 (0)