From 533fe31d564ba9b3acb301fa4380d13bdfaf90de Mon Sep 17 00:00:00 2001 From: Emma Jamieson-Hoare Date: Wed, 17 Jun 2026 18:09:19 +0100 Subject: [PATCH] fix: align Stripe and parser conformance behavior --- src/main/java/com/stripe/mpp/Challenge.java | 147 ++++++++++++++---- src/main/java/com/stripe/mpp/ChallengeId.java | 7 +- src/main/java/com/stripe/mpp/Parsing.java | 41 ++++- src/main/java/com/stripe/mpp/Receipt.java | 6 +- .../com/stripe/mpp/methods/stripe/Stripe.java | 22 ++- .../stripe/mpp/methods/stripe/StripeApi.java | 10 +- .../methods/stripe/StripeChargeIntent.java | 55 ++++++- .../methods/stripe/StripeClientMethod.java | 9 +- .../mpp/methods/stripe/StripeMethod.java | 34 +++- src/test/java/com/stripe/mpp/ParsingTest.java | 134 +++++++++++++++- .../stripe/StripeChargeIntentTest.java | 67 +++++++- .../mpp/methods/stripe/StripeMethodTest.java | 106 +++++++++++++ 12 files changed, 569 insertions(+), 69 deletions(-) create mode 100644 src/test/java/com/stripe/mpp/methods/stripe/StripeMethodTest.java diff --git a/src/main/java/com/stripe/mpp/Challenge.java b/src/main/java/com/stripe/mpp/Challenge.java index d959a4d..781c35e 100644 --- a/src/main/java/com/stripe/mpp/Challenge.java +++ b/src/main/java/com/stripe/mpp/Challenge.java @@ -86,29 +86,33 @@ public static Challenge create( public static List fromWwwAuthenticate(List wwwAuthenticateHeaders) { List challenges = new ArrayList<>(); for (String header : wwwAuthenticateHeaders) { - // Find each "Payment " scheme in the header. Auth-params are comma-separated, so - // we cannot simply split by comma; instead we locate the scheme token boundary. - String authParams = extractPaymentAuthParams(header); - if (authParams == null) continue; - - Map params = Parsing.parseAuthParams(authParams); - Map opaque = null; - String opaqueVal = params.get("opaque"); - if (opaqueVal != null && !opaqueVal.isEmpty()) { - opaque = ChallengeId.b64urlDecodeToMap(opaqueVal); + for (String authParams : extractPaymentAuthParamChunks(header)) { + Map params = Parsing.parseAuthParams(authParams); + String id = Parsing.requireString(params, "id"); + String realm = Parsing.requireString(params, "realm"); + String method = Parsing.requireString(params, "method"); + Parsing.validatePaymentMethodId(method); + String intent = Parsing.requireString(params, "intent"); + String requestB64 = Parsing.requireString(params, "request"); + Map request = ChallengeId.b64urlDecodeToMap(requestB64); + Map opaque = null; + String opaqueVal = params.get("opaque"); + if (opaqueVal != null && !opaqueVal.isEmpty()) { + opaque = ChallengeId.b64urlDecodeToMap(opaqueVal); + } + challenges.add(new Challenge( + id, + method, + intent, + request, + realm, + requestB64, + params.get("digest"), + params.get("expires"), + params.get("description"), + opaque + )); } - challenges.add(new Challenge( - params.get("id"), - params.get("method"), - params.get("intent"), - null, - params.get("realm"), - params.get("request"), - params.get("digest"), - params.get("expires"), - params.get("description"), - opaque - )); } return challenges; } @@ -118,12 +122,99 @@ public static List fromWwwAuthenticate(List wwwAuthenticateHe * Handles multi-scheme headers like "Bearer token, Payment id=...". */ static String extractPaymentAuthParams(String header) { - String lower = header.toLowerCase(); - // Match at start or after a scheme boundary (", " before the scheme token) - if (lower.startsWith("payment ")) return header.substring("payment ".length()); - int idx = lower.indexOf(", payment "); - if (idx >= 0) return header.substring(idx + ", payment ".length()); - return null; + List chunks = extractPaymentAuthParamChunks(header); + return chunks.isEmpty() ? null : chunks.get(0); + } + + private static List extractPaymentAuthParamChunks(String header) { + List schemes = authSchemes(header, 0); + List chunks = new ArrayList<>(); + for (int i = 0; i < schemes.size(); i++) { + AuthScheme scheme = schemes.get(i); + if (!"Payment".equalsIgnoreCase(scheme.name)) continue; + + int end = i + 1 < schemes.size() ? schemes.get(i + 1).index : header.length(); + String chunk = header.substring(scheme.paramsStart, end).replaceAll(",\\s*$", "").trim(); + if (!chunk.isEmpty()) chunks.add(chunk); + } + return chunks; + } + + private static List authSchemes(String header, int offset) { + List schemes = new ArrayList<>(); + boolean inQuote = false; + boolean escaped = false; + int i = offset; + + while (i < header.length()) { + char c = header.charAt(i); + if (inQuote) { + if (escaped) { + escaped = false; + } else if (c == '\\') { + escaped = true; + } else if (c == '"') { + inQuote = false; + } + i++; + continue; + } + + if (c == '"') { + inQuote = true; + i++; + continue; + } + + if (schemeBoundary(header, i) && isSchemeStart(c)) { + int tokenEnd = i + 1; + while (tokenEnd < header.length() && isSchemeChar(header.charAt(tokenEnd))) { + tokenEnd++; + } + int paramsStart = tokenEnd; + if (paramsStart < header.length() && Character.isWhitespace(header.charAt(paramsStart))) { + while (paramsStart < header.length() && Character.isWhitespace(header.charAt(paramsStart))) { + paramsStart++; + } + if (paramsStart >= header.length() || header.charAt(paramsStart) != '=') { + schemes.add(new AuthScheme(i, paramsStart, header.substring(i, tokenEnd))); + i = paramsStart; + continue; + } + } + } + + i++; + } + return schemes; + } + + private static boolean schemeBoundary(String header, int index) { + int i = index - 1; + while (i >= 0 && Character.isWhitespace(header.charAt(i))) i--; + return i < 0 || header.charAt(i) == ','; + } + + private static boolean isSchemeStart(char c) { + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'); + } + + private static boolean isSchemeChar(char c) { + return isSchemeStart(c) + || (c >= '0' && c <= '9') + || c == '.' || c == '_' || c == '~' || c == '+' || c == '/' || c == '-'; + } + + private static final class AuthScheme { + private final int index; + private final int paramsStart; + private final String name; + + private AuthScheme(int index, int paramsStart, String name) { + this.index = index; + this.paramsStart = paramsStart; + this.name = name; + } } public static List fromWwwAuthenticate(String wwwAuthenticate) { diff --git a/src/main/java/com/stripe/mpp/ChallengeId.java b/src/main/java/com/stripe/mpp/ChallengeId.java index 1d290b1..fbec666 100644 --- a/src/main/java/com/stripe/mpp/ChallengeId.java +++ b/src/main/java/com/stripe/mpp/ChallengeId.java @@ -61,7 +61,12 @@ public static byte[] b64urlDecode(String encoded) { /** Decode a base64url string to a JSON map. */ @SuppressWarnings("unchecked") public static Map b64urlDecodeToMap(String encoded) { - byte[] bytes = b64urlDecode(encoded); + byte[] bytes; + try { + bytes = b64urlDecode(encoded); + } catch (IllegalArgumentException e) { + throw new com.stripe.mpp.error.ParseException("Invalid base64url encoding", e); + } String json = new String(bytes, java.nio.charset.StandardCharsets.UTF_8); try { return Json.MAPPER.readValue(json, Map.class); diff --git a/src/main/java/com/stripe/mpp/Parsing.java b/src/main/java/com/stripe/mpp/Parsing.java index e049411..ef0c41f 100644 --- a/src/main/java/com/stripe/mpp/Parsing.java +++ b/src/main/java/com/stripe/mpp/Parsing.java @@ -19,8 +19,9 @@ private Parsing() {} // Matches: key="quoted value" or key=token private static final Pattern AUTH_PARAM = Pattern.compile( - "(\\w+)=(?:\"([^\"\\\\]*(?:\\\\.[^\"\\\\]*)*)\"|([^\\s,]+))" + "([A-Za-z_][\\w-]*)\\s*=\\s*(?:\"([^\"\\\\]*(?:\\\\.[^\"\\\\]*)*)\"|([^\\s,]+))" ); + private static final Pattern PAYMENT_METHOD_ID = Pattern.compile("[a-z]+"); // --- Encoding helpers --- @@ -31,7 +32,12 @@ static String b64Encode(Object data) { /** Decode base64url to a parsed JSON object, or return the raw string if not JSON. */ static Object b64Decode(String encoded) { - byte[] bytes = ChallengeId.b64urlDecode(encoded); + byte[] bytes; + try { + bytes = ChallengeId.b64urlDecode(encoded); + } catch (IllegalArgumentException e) { + throw new ParseException("Invalid base64url encoding", e); + } String str = new String(bytes, StandardCharsets.UTF_8); try { return Json.parse(str); @@ -54,6 +60,7 @@ static Map parseAuthParams(String input) { Matcher m = AUTH_PARAM.matcher(input); while (m.find()) { String key = m.group(1); + if (params.containsKey(key)) throw new ParseException("Duplicate parameter: " + key); String value = m.group(2) != null ? m.group(2) : m.group(3); // Unescape backslash sequences in quoted strings if (m.group(2) != null) value = value.replace("\\\"", "\"").replace("\\\\", "\\"); @@ -62,6 +69,20 @@ static Map parseAuthParams(String input) { return params; } + static String requireString(Map map, String key) { + Object value = map.get(key); + if (!(value instanceof String) || ((String) value).isEmpty()) { + throw new ParseException("Missing " + key); + } + return (String) value; + } + + static void validatePaymentMethodId(String method) { + if (method == null || !PAYMENT_METHOD_ID.matcher(method).matches()) { + throw new ParseException("Invalid payment method ID"); + } + } + private static String quote(String value) { if (value.indexOf('\r') >= 0 || value.indexOf('\n') >= 0) { throw new IllegalArgumentException("Header values must not contain CR or LF"); @@ -98,6 +119,8 @@ static Credential parseAuthorization(String header) { Map challengeMap = (Map) challengeObj; if (!challengeMap.containsKey("id")) throw new ParseException("Credential challenge missing required field: id"); + String method = requireString(challengeMap, "method"); + validatePaymentMethodId(method); Map opaque = null; if (challengeMap.get("opaque") instanceof Map) { @@ -107,7 +130,7 @@ static Credential parseAuthorization(String header) { ChallengeEcho echo = new ChallengeEcho( str(challengeMap, "id"), str(challengeMap, "realm"), - str(challengeMap, "method"), + method, str(challengeMap, "intent"), str(challengeMap, "request"), str(challengeMap, "expires"), @@ -118,7 +141,7 @@ static Credential parseAuthorization(String header) { Object payload = envelope.get("payload"); if (payload == null) throw new ParseException("Credential missing required field: payload"); - String source = envelope.get("source") instanceof String ? (String) envelope.get("source") : header; + String source = envelope.get("source") instanceof String ? (String) envelope.get("source") : null; return new Credential(echo, payload, source); } @@ -170,12 +193,14 @@ static Receipt parsePaymentReceipt(String header) { String reference = str(map, "reference"); if (reference == null) throw new ParseException("Missing reference"); - String method = str(map, "method"); - if (method == null) method = ""; + String method = requireString(map, "method"); + validatePaymentMethodId(method); Object extra = map.get("extra"); - return new Receipt(status, timestamp, reference, method, str(map, "external_id"), extra); + String externalId = str(map, "externalId"); + if (externalId == null) externalId = str(map, "external_id"); + return new Receipt(status, timestamp, reference, method, externalId, extra); } static String formatPaymentReceipt(Receipt receipt) { @@ -186,7 +211,7 @@ static String formatPaymentReceipt(Receipt receipt) { if (receipt.method() != null && !receipt.method().isEmpty()) map.put("method", receipt.method()); if (receipt.externalId() != null) - map.put("external_id", receipt.externalId()); + map.put("externalId", receipt.externalId()); if (receipt.extra() != null) map.put("extra", receipt.extra()); return b64Encode(map); diff --git a/src/main/java/com/stripe/mpp/Receipt.java b/src/main/java/com/stripe/mpp/Receipt.java index eaf2c6f..8889004 100644 --- a/src/main/java/com/stripe/mpp/Receipt.java +++ b/src/main/java/com/stripe/mpp/Receipt.java @@ -34,8 +34,12 @@ public Receipt(String status, Instant timestamp, String reference, String method /** * Create a success receipt with the current timestamp. */ + public static Receipt success(String reference, String method, String externalId, Object extra) { + return new Receipt("success", Instant.now(), reference, method, externalId, extra); + } + public static Receipt success(String reference, String method, String externalId) { - return new Receipt("success", Instant.now(), reference, method, externalId, null); + return success(reference, method, externalId, null); } public static Receipt success(String reference, String method) { diff --git a/src/main/java/com/stripe/mpp/methods/stripe/Stripe.java b/src/main/java/com/stripe/mpp/methods/stripe/Stripe.java index 6f9ff7d..400a3b6 100644 --- a/src/main/java/com/stripe/mpp/methods/stripe/Stripe.java +++ b/src/main/java/com/stripe/mpp/methods/stripe/Stripe.java @@ -29,7 +29,7 @@ private Stripe() {} * @param networkId Stripe profile/network identifier sent to the client in the challenge */ public static StripeMethod method(String secretKey, String networkId) { - return method(secretKey, networkId, null, null); + return method(secretKey, networkId, List.of("card"), null); } /** @@ -37,7 +37,7 @@ public static StripeMethod method(String secretKey, String networkId) { * * @param secretKey Stripe secret API key * @param networkId Stripe network identifier sent to the client in the challenge - * @param paymentMethods allowed payment method types (e.g. {@code List.of("card", "link")}); may be null + * @param paymentMethods allowed payment method types (e.g. {@code List.of("card", "link")}); defaults to card if null * @param metadata optional metadata attached to created PaymentIntents; may be null */ public static StripeMethod method( @@ -47,7 +47,23 @@ public static StripeMethod method( Map metadata ) { return new StripeMethod( - secretKey, networkId, paymentMethods, metadata, + secretKey, networkId, paymentMethods, metadata, null, + StripeDefaults.DEFAULT_DECIMALS + ); + } + + /** + * Returns a {@link StripeMethod} with full configuration and a request-bound external ID. + */ + public static StripeMethod method( + String secretKey, + String networkId, + List paymentMethods, + Map metadata, + String externalId + ) { + return new StripeMethod( + secretKey, networkId, paymentMethods, metadata, externalId, StripeDefaults.DEFAULT_DECIMALS ); } diff --git a/src/main/java/com/stripe/mpp/methods/stripe/StripeApi.java b/src/main/java/com/stripe/mpp/methods/stripe/StripeApi.java index 940b589..7576ae4 100644 --- a/src/main/java/com/stripe/mpp/methods/stripe/StripeApi.java +++ b/src/main/java/com/stripe/mpp/methods/stripe/StripeApi.java @@ -6,6 +6,7 @@ import com.stripe.param.PaymentIntentCreateParams; import com.stripe.mpp.error.VerificationFailedException; +import java.util.List; import java.util.Map; import java.util.Objects; @@ -52,6 +53,7 @@ Result createAndConfirm( long amountMinorUnits, String currency, String spt, + List paymentMethodTypes, Map metadata ) { try { @@ -61,13 +63,7 @@ Result createAndConfirm( .setAmount(amountMinorUnits) .setCurrency(currency) .setConfirm(true) - .setAutomaticPaymentMethods( - PaymentIntentCreateParams.AutomaticPaymentMethods.builder() - .setEnabled(true) - .setAllowRedirects( - PaymentIntentCreateParams.AutomaticPaymentMethods.AllowRedirects.NEVER) - .build() - ) + .addAllPaymentMethodType(paymentMethodTypes) .putExtraParam("shared_payment_granted_token", spt); if (metadata != null && !metadata.isEmpty()) { diff --git a/src/main/java/com/stripe/mpp/methods/stripe/StripeChargeIntent.java b/src/main/java/com/stripe/mpp/methods/stripe/StripeChargeIntent.java index ffdb349..ce482fb 100644 --- a/src/main/java/com/stripe/mpp/methods/stripe/StripeChargeIntent.java +++ b/src/main/java/com/stripe/mpp/methods/stripe/StripeChargeIntent.java @@ -2,6 +2,7 @@ import com.stripe.mpp.Credential; import com.stripe.mpp.Receipt; +import com.stripe.mpp.error.InvalidChallengeException; import com.stripe.mpp.error.PaymentExpiredException; import com.stripe.mpp.error.VerificationFailedException; import com.stripe.mpp.server.Intent; @@ -10,7 +11,9 @@ import java.math.RoundingMode; import java.time.Instant; import java.time.format.DateTimeParseException; +import java.util.List; import java.util.Map; +import java.util.Objects; /** * Server-side intent that verifies Stripe payments using Shared Payment Granted Tokens (SPT). @@ -60,7 +63,14 @@ public Receipt verify(Credential credential, Map request) { if (spt == null) { throw new VerificationFailedException("missing spt in payload"); } - String externalId = (String) payload.get("externalId"); + String credentialExternalId = stringValue(payload.get("externalId"), "externalId"); + String requestExternalId = stringValue(request.get("externalId"), "externalId"); + if (requestExternalId != null && !Objects.equals(credentialExternalId, requestExternalId)) { + throw new InvalidChallengeException( + credential.challenge().id(), + "credential externalId does not match request externalId" + ); + } String expires = credential.challenge().expires(); if (expires != null) { @@ -86,8 +96,11 @@ public Receipt verify(Credential credential, Map request) { throw new VerificationFailedException("invalid amount: " + amount); } - Map metadata = extractMetadata(request); - StripeApi.Result result = stripeApi.createAndConfirm(secretKey, amountMinorUnits, currency, spt, metadata); + Map methodDetails = methodDetails(request); + List paymentMethodTypes = paymentMethodTypes(methodDetails); + Map metadata = extractMetadata(methodDetails); + StripeApi.Result result = stripeApi.createAndConfirm( + secretKey, amountMinorUnits, currency, spt, paymentMethodTypes, metadata); if ("requires_action".equals(result.status())) { throw new com.stripe.mpp.error.PaymentActionRequiredException( @@ -98,15 +111,43 @@ public Receipt verify(Credential credential, Map request) { "PaymentIntent " + result.id() + " has status: " + result.status()); } - return Receipt.success(result.id(), "stripe", externalId); + return Receipt.success(result.id(), "stripe", requestExternalId); } @SuppressWarnings("unchecked") - private static Map extractMetadata(Map request) { + private static Map methodDetails(Map request) { Object methodDetails = request.get("methodDetails"); - if (!(methodDetails instanceof Map)) return null; - Object meta = ((Map) methodDetails).get("metadata"); + if (!(methodDetails instanceof Map)) { + throw new VerificationFailedException("missing or invalid methodDetails"); + } + return (Map) methodDetails; + } + + @SuppressWarnings("unchecked") + private static List paymentMethodTypes(Map methodDetails) { + Object value = methodDetails.get("paymentMethodTypes"); + if (!(value instanceof List)) { + throw new VerificationFailedException("missing methodDetails.paymentMethodTypes"); + } + try { + return StripeMethod.requirePaymentMethodTypes((List) value); + } catch (ClassCastException | IllegalArgumentException e) { + throw new VerificationFailedException("invalid methodDetails.paymentMethodTypes"); + } + } + + @SuppressWarnings("unchecked") + private static Map extractMetadata(Map methodDetails) { + Object meta = methodDetails.get("metadata"); if (!(meta instanceof Map)) return null; return (Map) meta; } + + private static String stringValue(Object value, String field) { + if (value == null) return null; + if (!(value instanceof String)) { + throw new VerificationFailedException(field + " must be a string"); + } + return (String) value; + } } diff --git a/src/main/java/com/stripe/mpp/methods/stripe/StripeClientMethod.java b/src/main/java/com/stripe/mpp/methods/stripe/StripeClientMethod.java index be96e11..6601357 100644 --- a/src/main/java/com/stripe/mpp/methods/stripe/StripeClientMethod.java +++ b/src/main/java/com/stripe/mpp/methods/stripe/StripeClientMethod.java @@ -19,7 +19,7 @@ * StripeClientMethod client = new StripeClientMethod( * params -> stripeSDK.createSharedPaymentToken(params), * "card", // optional payment method type - * "order-123" // optional external ID for reconciliation + * "order-123" // optional local external ID; must also be bound in the challenge request * ); * * // On a 402 response: @@ -114,7 +114,12 @@ public Credential createCredential(Challenge challenge) { Map payload = new LinkedHashMap<>(); payload.put("spt", sptId); - if (externalId != null) payload.put("externalId", externalId); + Object requestExternalId = request.get("externalId"); + if (requestExternalId instanceof String) { + payload.put("externalId", requestExternalId); + } else if (externalId != null) { + throw new IllegalArgumentException("externalId must be bound by the challenge request"); + } return new Credential(challenge.toEcho(), payload, null); } diff --git a/src/main/java/com/stripe/mpp/methods/stripe/StripeMethod.java b/src/main/java/com/stripe/mpp/methods/stripe/StripeMethod.java index 88441f3..b1de504 100644 --- a/src/main/java/com/stripe/mpp/methods/stripe/StripeMethod.java +++ b/src/main/java/com/stripe/mpp/methods/stripe/StripeMethod.java @@ -24,6 +24,7 @@ public class StripeMethod implements Method { private final String networkId; private final List paymentMethods; private final Map metadata; + private final String externalId; private final StripeChargeIntent chargeIntent; StripeMethod( @@ -31,11 +32,13 @@ public class StripeMethod implements Method { String networkId, List paymentMethods, Map metadata, + String externalId, int decimals ) { this.networkId = networkId; - this.paymentMethods = paymentMethods; + this.paymentMethods = requirePaymentMethodTypes(paymentMethods); this.metadata = metadata; + this.externalId = externalId; this.chargeIntent = new StripeChargeIntent(secretKey, decimals, new StripeApi()); } @@ -52,8 +55,9 @@ public static Builder of(String secretKey, String networkId) { public static final class Builder { private final String secretKey; private final String networkId; - private List paymentMethods; + private List paymentMethods = List.of("card"); private Map metadata; + private String externalId; private Builder(String secretKey, String networkId) { this.secretKey = secretKey; @@ -72,8 +76,14 @@ public Builder metadata(Map metadata) { return this; } + /** External ID bound into the challenge request for receipt attribution. */ + public Builder externalId(String externalId) { + this.externalId = externalId; + return this; + } + public StripeMethod build() { - return new StripeMethod(secretKey, networkId, paymentMethods, metadata, StripeDefaults.DEFAULT_DECIMALS); + return new StripeMethod(secretKey, networkId, paymentMethods, metadata, externalId, StripeDefaults.DEFAULT_DECIMALS); } } @@ -91,11 +101,27 @@ public List> intents() { public Map transformRequest(Map request) { Map methodDetails = new LinkedHashMap<>(); methodDetails.put("networkId", networkId); - if (paymentMethods != null) methodDetails.put("paymentMethods", paymentMethods); + methodDetails.put("paymentMethodTypes", paymentMethods); if (metadata != null) methodDetails.put("metadata", metadata); Map result = new LinkedHashMap<>(request); result.put("methodDetails", methodDetails); + if (externalId != null && !result.containsKey("externalId")) result.put("externalId", externalId); return result; } + + static List requirePaymentMethodTypes(List paymentMethodTypes) { + if (paymentMethodTypes == null) { + return List.of("card"); + } + if (paymentMethodTypes.isEmpty()) { + throw new IllegalArgumentException("paymentMethods must be a non-empty list"); + } + for (String type : paymentMethodTypes) { + if (type == null || type.trim().isEmpty()) { + throw new IllegalArgumentException("paymentMethods must contain non-empty strings"); + } + } + return List.copyOf(paymentMethodTypes); + } } diff --git a/src/test/java/com/stripe/mpp/ParsingTest.java b/src/test/java/com/stripe/mpp/ParsingTest.java index 67546e9..d0b81db 100644 --- a/src/test/java/com/stripe/mpp/ParsingTest.java +++ b/src/test/java/com/stripe/mpp/ParsingTest.java @@ -40,12 +40,55 @@ void challengeRoundTrips() { @Test void challengeFromWwwAuthenticateHandlesMultipleSchemes() { // Only "Payment" schemes should be parsed - String header = "Bearer foo, Payment id=\"abc\", realm=\"x\", method=\"tempo\", intent=\"charge\", request=\"eyJ9\""; + String header = "Bearer foo, Payment id=\"abc\", realm=\"x\", method=\"tempo\", intent=\"charge\", request=\"e30\""; var challenges = Challenge.fromWwwAuthenticate(header); assertThat(challenges).hasSize(1); assertThat(challenges.get(0).id()).isEqualTo("abc"); } + @Test + void challengeFromWwwAuthenticateHandlesMergedPaymentChallenges() { + String first = "Payment id=\"one\", realm=\"api.example.com\", method=\"tempo\", intent=\"charge\", request=\"e30\""; + String second = "Payment id=\"two\", realm=\"api.example.com\", method=\"stripe\", intent=\"charge\", request=\"e30\""; + + var challenges = Challenge.fromWwwAuthenticate(first + ", Bearer realm=\"fallback\", " + second); + + assertThat(challenges).hasSize(2); + assertThat(challenges.get(0).id()).isEqualTo("one"); + assertThat(challenges.get(1).id()).isEqualTo("two"); + } + + @Test + void challengeFromWwwAuthenticateIgnoresPaymentInsideQuotes() { + String first = "Payment id=\"one\", realm=\"api, Payment realm\", method=\"tempo\", intent=\"charge\", request=\"e30\""; + String second = "Payment id=\"two\", realm=\"api.example.com\", method=\"stripe\", intent=\"charge\", request=\"e30\""; + + var challenges = Challenge.fromWwwAuthenticate(first + ", " + second); + + assertThat(challenges).hasSize(2); + assertThat(challenges.get(0).realm()).isEqualTo("api, Payment realm"); + assertThat(challenges.get(1).id()).isEqualTo("two"); + } + + @Test + void challengeFromWwwAuthenticateAllowsWhitespaceAroundParamEquals() { + String header = "Payment id=\"abc\", realm = \"api.example.com\", method=\"tempo\", intent=\"charge\", request=\"e30\""; + + var challenges = Challenge.fromWwwAuthenticate(header); + + assertThat(challenges).hasSize(1); + assertThat(challenges.get(0).realm()).isEqualTo("api.example.com"); + } + + @Test + void challengeFromWwwAuthenticateRejectsDuplicateParameters() { + String header = "Payment id=\"abc\", realm=\"api.example.com\", method=\"tempo\", intent=\"charge\", request=\"e30\", id=\"def\""; + + assertThatThrownBy(() -> Challenge.fromWwwAuthenticate(header)) + .isInstanceOf(com.stripe.mpp.error.ParseException.class) + .hasMessageContaining("Duplicate"); + } + @Test void challengeEscapesQuotedStringDescription() { Challenge original = Challenge.create("secret", "example.com", "tempo", "charge", Map.of(), @@ -116,6 +159,36 @@ void credentialParseFailsOnMissingPayload() { .hasMessageContaining("payload"); } + @Test + void credentialWithoutSourceParsesAsNullSource() { + String json = "{\"challenge\":{\"id\":\"abc\",\"realm\":\"r\",\"method\":\"tempo\",\"intent\":\"charge\"},\"payload\":{}}"; + String header = "Payment " + ChallengeId.b64urlEncode(json); + + Credential parsed = Credential.fromAuthorization(header); + + assertThat(parsed.source()).isNull(); + } + + @Test + void credentialParseRejectsInvalidMethodId() { + String json = "{\"challenge\":{\"id\":\"abc\",\"realm\":\"r\",\"method\":\"Tempo\",\"intent\":\"i\"},\"payload\":{}}"; + String bad = "Payment " + ChallengeId.b64urlEncode(json); + + assertThatThrownBy(() -> Credential.fromAuthorization(bad)) + .isInstanceOf(com.stripe.mpp.error.ParseException.class) + .hasMessageContaining("payment method ID"); + } + + @Test + void credentialParseRejectsNonStringMethod() { + String json = "{\"challenge\":{\"id\":\"abc\",\"realm\":\"r\",\"method\":true,\"intent\":\"i\"},\"payload\":{}}"; + String bad = "Payment " + ChallengeId.b64urlEncode(json); + + assertThatThrownBy(() -> Credential.fromAuthorization(bad)) + .isInstanceOf(com.stripe.mpp.error.ParseException.class) + .hasMessageContaining("method"); + } + // --- Receipt (Payment-Receipt) --- @Test @@ -134,6 +207,9 @@ void receiptRoundTrips() { assertThat(parsed.method()).isEqualTo("tempo"); assertThat(parsed.externalId()).isEqualTo("ext-456"); assertThat(parsed.timestamp()).isEqualTo(Instant.parse("2025-01-01T12:00:00Z")); + Map wireReceipt = (Map) Parsing.b64Decode(header); + assertThat(wireReceipt.get("externalId")).isEqualTo("ext-456"); + assertThat(wireReceipt.containsKey("external_id")).isFalse(); } @Test @@ -143,4 +219,60 @@ void receiptSuccessFactory() { assertThat(r.reference()).isEqualTo("ref-789"); assertThat(r.method()).isEqualTo("tempo"); } + + @Test + void receiptSuccessFactoryPreservesExtra() { + Map extra = Map.of("trace_id", "trace-123"); + + Receipt r = Receipt.success("ref-789", "tempo", "ext-000", extra); + + assertThat(r.extra()).isSameAs(extra); + } + + @Test + void receiptParseAcceptsLegacyExternalIdKey() { + String header = ChallengeId.b64urlEncode( + "{\"method\":\"tempo\",\"reference\":\"ref-123\",\"status\":\"success\"," + + "\"timestamp\":\"2025-01-01T12:00:00Z\",\"external_id\":\"legacy-ext\"}" + ); + + Receipt parsed = Receipt.fromPaymentReceipt(header); + + assertThat(parsed.externalId()).isEqualTo("legacy-ext"); + } + + @Test + void receiptParseRejectsMissingMethod() { + String header = ChallengeId.b64urlEncode( + "{\"reference\":\"ref-123\",\"status\":\"success\",\"timestamp\":\"2025-01-01T12:00:00Z\"}" + ); + + assertThatThrownBy(() -> Receipt.fromPaymentReceipt(header)) + .isInstanceOf(com.stripe.mpp.error.ParseException.class) + .hasMessageContaining("method"); + } + + @Test + void receiptParseRejectsInvalidMethodId() { + String header = ChallengeId.b64urlEncode( + "{\"method\":\"tempo-pay\",\"reference\":\"ref-123\",\"status\":\"success\"," + + "\"timestamp\":\"2025-01-01T12:00:00Z\"}" + ); + + assertThatThrownBy(() -> Receipt.fromPaymentReceipt(header)) + .isInstanceOf(com.stripe.mpp.error.ParseException.class) + .hasMessageContaining("payment method ID"); + } + + @Test + void receiptParseRejectsNonStringMethod() { + String header = ChallengeId.b64urlEncode( + "{\"method\":true,\"reference\":\"ref-123\",\"status\":\"success\"," + + "\"timestamp\":\"2025-01-01T12:00:00Z\"}" + ); + + assertThatThrownBy(() -> Receipt.fromPaymentReceipt(header)) + .isInstanceOf(com.stripe.mpp.error.ParseException.class) + .hasMessageContaining("method"); + } } diff --git a/src/test/java/com/stripe/mpp/methods/stripe/StripeChargeIntentTest.java b/src/test/java/com/stripe/mpp/methods/stripe/StripeChargeIntentTest.java index 10070fc..8ea4897 100644 --- a/src/test/java/com/stripe/mpp/methods/stripe/StripeChargeIntentTest.java +++ b/src/test/java/com/stripe/mpp/methods/stripe/StripeChargeIntentTest.java @@ -3,11 +3,13 @@ import com.stripe.mpp.ChallengeEcho; import com.stripe.mpp.Credential; import com.stripe.mpp.Receipt; +import com.stripe.mpp.error.InvalidChallengeException; import com.stripe.mpp.error.PaymentActionRequiredException; import com.stripe.mpp.error.PaymentExpiredException; import com.stripe.mpp.error.VerificationFailedException; import org.junit.jupiter.api.Test; +import java.util.List; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; @@ -16,11 +18,21 @@ class StripeChargeIntentTest { static final Map REQUEST = Map.of( - "amount", "10.00", "currency", "usd", "recipient", "net_xxx" + "amount", "10.00", "currency", "usd", "recipient", "net_xxx", + "methodDetails", Map.of("paymentMethodTypes", List.of("card")) ); static final Map REQUEST_WITH_METADATA = Map.of( "amount", "10.00", "currency", "usd", "recipient", "net_xxx", - "methodDetails", Map.of("networkId", "net_xxx", "metadata", Map.of("orderId", "order-42")) + "methodDetails", Map.of( + "networkId", "net_xxx", + "paymentMethodTypes", List.of("card", "link"), + "metadata", Map.of("orderId", "order-42") + ) + ); + static final Map REQUEST_WITH_EXTERNAL_ID = Map.of( + "amount", "10.00", "currency", "usd", "recipient", "net_xxx", + "externalId", "server-order-123", + "methodDetails", Map.of("paymentMethodTypes", List.of("card")) ); static final ChallengeEcho ECHO = new ChallengeEcho( "chal-id", "api.example.com", "stripe", "charge", "e30", "2099-01-01T00:00:00Z", null, null @@ -44,6 +56,8 @@ static class StubStripeApi extends StripeApi { String lastSpt; long lastAmount; String lastCurrency; + List lastPaymentMethodTypes; + Map lastMetadata; StubStripeApi(StripeApi.Result result) { this.result = result; @@ -52,11 +66,13 @@ static class StubStripeApi extends StripeApi { @Override StripeApi.Result createAndConfirm( String secretKey, long amountMinorUnits, String currency, - String spt, Map metadata + String spt, List paymentMethodTypes, Map metadata ) { this.lastSpt = spt; this.lastAmount = amountMinorUnits; this.lastCurrency = currency; + this.lastPaymentMethodTypes = paymentMethodTypes; + this.lastMetadata = metadata; return result; } } @@ -81,9 +97,31 @@ void successfulChargeReturnsReceipt() { @Test void externalIdIncludedInReceipt() { StubStripeApi api = new StubStripeApi(new StripeApi.Result("pi_abc123", "succeeded")); - Receipt receipt = intent(api).verify(credentialWithExternalId("spt_xxx", "order-42"), REQUEST); + Receipt receipt = intent(api).verify( + credentialWithExternalId("spt_xxx", "server-order-123"), + REQUEST_WITH_EXTERNAL_ID); - assertThat(receipt.externalId()).isEqualTo("order-42"); + assertThat(receipt.externalId()).isEqualTo("server-order-123"); + } + + @Test + void forgedPayloadExternalIdThrows() { + StubStripeApi api = new StubStripeApi(new StripeApi.Result("pi_abc123", "succeeded")); + + assertThatThrownBy(() -> intent(api).verify( + credentialWithExternalId("spt_xxx", "attacker-order-999"), + REQUEST_WITH_EXTERNAL_ID)) + .isInstanceOf(InvalidChallengeException.class) + .hasMessageContaining("externalId"); + } + + @Test + void payloadOnlyExternalIdIsNotAttributed() { + StubStripeApi api = new StubStripeApi(new StripeApi.Result("pi_abc123", "succeeded")); + + Receipt receipt = intent(api).verify(credentialWithExternalId("spt_xxx", "attacker-order-999"), REQUEST); + + assertThat(receipt.externalId()).isNull(); } @Test @@ -94,6 +132,7 @@ void amountConvertedToMinorUnits() { assertThat(api.lastAmount).isEqualTo(1000L); // "10.00" * 100 = 1000 cents assertThat(api.lastCurrency).isEqualTo("usd"); assertThat(api.lastSpt).isEqualTo("spt_xxx"); + assertThat(api.lastPaymentMethodTypes).containsExactly("card"); } @Test @@ -149,7 +188,7 @@ void stripeApiExceptionBecomesVerificationFailed() { @Override StripeApi.Result createAndConfirm( String secretKey, long amountMinorUnits, String currency, - String spt, Map metadata + String spt, List paymentMethodTypes, Map metadata ) { throw new VerificationFailedException("card_declined"); } @@ -163,8 +202,22 @@ StripeApi.Result createAndConfirm( @Test void metadataPassedToStripeApi() { StubStripeApi api = new StubStripeApi(new StripeApi.Result("pi_abc123", "succeeded")); - // Metadata extraction is tested indirectly — just verify no exception Receipt receipt = intent(api).verify(credential("spt_xxx"), REQUEST_WITH_METADATA); assertThat(receipt.status()).isEqualTo("success"); + assertThat(api.lastPaymentMethodTypes).containsExactly("card", "link"); + assertThat(api.lastMetadata).containsEntry("orderId", "order-42"); + } + + @Test + void missingPaymentMethodTypesThrows() { + StubStripeApi api = new StubStripeApi(new StripeApi.Result("pi_abc123", "succeeded")); + Map badRequest = Map.of( + "amount", "10.00", "currency", "usd", "recipient", "net_xxx", + "methodDetails", Map.of("networkId", "net_xxx") + ); + + assertThatThrownBy(() -> intent(api).verify(credential("spt_xxx"), badRequest)) + .isInstanceOf(VerificationFailedException.class) + .hasMessageContaining("paymentMethodTypes"); } } diff --git a/src/test/java/com/stripe/mpp/methods/stripe/StripeMethodTest.java b/src/test/java/com/stripe/mpp/methods/stripe/StripeMethodTest.java new file mode 100644 index 0000000..76af157 --- /dev/null +++ b/src/test/java/com/stripe/mpp/methods/stripe/StripeMethodTest.java @@ -0,0 +1,106 @@ +package com.stripe.mpp.methods.stripe; + +import com.stripe.mpp.Challenge; +import com.stripe.mpp.ChallengeId; +import com.stripe.mpp.Credential; +import com.stripe.mpp.Json; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class StripeMethodTest { + + @Test + void transformRequestUsesPaymentMethodTypesAndExternalId() { + StripeMethod method = StripeMethod.of("sk_test_xxx", "net_xxx") + .paymentMethods(List.of("card", "link")) + .metadata(Map.of("orderId", "order-42")) + .externalId("server-order-123") + .build(); + + Map transformed = method.transformRequest(Map.of("amount", "10.00", "currency", "usd")); + Map methodDetails = (Map) transformed.get("methodDetails"); + + assertThat(transformed).containsEntry("externalId", "server-order-123"); + assertThat(methodDetails.get("networkId")).isEqualTo("net_xxx"); + assertThat(methodDetails.get("paymentMethodTypes")).isEqualTo(List.of("card", "link")); + assertThat(methodDetails.get("metadata")).isEqualTo(Map.of("orderId", "order-42")); + } + + @Test + void defaultMethodUsesCardPaymentMethodType() { + StripeMethod method = Stripe.method("sk_test_xxx", "net_xxx"); + + Map transformed = method.transformRequest(Map.of("amount", "10.00", "currency", "usd")); + + Map methodDetails = (Map) transformed.get("methodDetails"); + assertThat(methodDetails.get("paymentMethodTypes")).isEqualTo(List.of("card")); + } + + @Test + void nullPaymentMethodsDefaultToCard() { + StripeMethod method = Stripe.method("sk_test_xxx", "net_xxx", null, null); + + Map transformed = method.transformRequest(Map.of("amount", "10.00", "currency", "usd")); + + Map methodDetails = (Map) transformed.get("methodDetails"); + assertThat(methodDetails.get("paymentMethodTypes")).isEqualTo(List.of("card")); + } + + @Test + void rejectsEmptyPaymentMethodTypes() { + assertThatThrownBy(() -> StripeMethod.of("sk_test_xxx", "net_xxx") + .paymentMethods(List.of()) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("paymentMethods"); + } + + @Test + void clientEchoesRequestBoundExternalId() { + Map request = Map.of( + "amount", "10.00", + "currency", "usd", + "externalId", "server-order-123", + "methodDetails", Map.of("networkId", "net_xxx") + ); + Challenge challenge = challenge(request); + StripeClientMethod client = new StripeClientMethod(params -> "spt_xxx", "card", "local-order-999"); + + Credential credential = client.createCredential(challenge); + Map payload = (Map) credential.payload(); + + assertThat(payload.get("spt")).isEqualTo("spt_xxx"); + assertThat(payload.get("externalId")).isEqualTo("server-order-123"); + } + + @Test + void clientRejectsLocalExternalIdWithoutRequestBinding() { + Challenge challenge = challenge(Map.of("amount", "10.00", "currency", "usd")); + StripeClientMethod client = new StripeClientMethod(params -> "spt_xxx", "card", "local-order-999"); + + assertThatThrownBy(() -> client.createCredential(challenge)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("externalId"); + } + + private static Challenge challenge(Map request) { + String requestB64 = ChallengeId.b64urlEncode(Json.compact(request)); + return new Challenge( + "ch_123", + "stripe", + "charge", + request, + "api.example.com", + requestB64, + null, + "2099-01-01T00:00:00Z", + null, + null + ); + } +}