Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 119 additions & 28 deletions src/main/java/com/stripe/mpp/Challenge.java
Original file line number Diff line number Diff line change
Expand Up @@ -86,29 +86,33 @@ public static Challenge create(
public static List<Challenge> fromWwwAuthenticate(List<String> wwwAuthenticateHeaders) {
List<Challenge> 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<String, String> params = Parsing.parseAuthParams(authParams);
Map<String, Object> opaque = null;
String opaqueVal = params.get("opaque");
if (opaqueVal != null && !opaqueVal.isEmpty()) {
opaque = ChallengeId.b64urlDecodeToMap(opaqueVal);
for (String authParams : extractPaymentAuthParamChunks(header)) {
Map<String, String> 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<String, Object> request = ChallengeId.b64urlDecodeToMap(requestB64);
Map<String, Object> 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;
}
Expand All @@ -118,12 +122,99 @@ public static List<Challenge> fromWwwAuthenticate(List<String> 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<String> chunks = extractPaymentAuthParamChunks(header);
return chunks.isEmpty() ? null : chunks.get(0);
}

private static List<String> extractPaymentAuthParamChunks(String header) {
List<AuthScheme> schemes = authSchemes(header, 0);
List<String> 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<AuthScheme> authSchemes(String header, int offset) {
List<AuthScheme> 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<Challenge> fromWwwAuthenticate(String wwwAuthenticate) {
Expand Down
7 changes: 6 additions & 1 deletion src/main/java/com/stripe/mpp/ChallengeId.java
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,12 @@ public static byte[] b64urlDecode(String encoded) {
/** Decode a base64url string to a JSON map. */
@SuppressWarnings("unchecked")
public static Map<String, Object> 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);
Expand Down
41 changes: 33 additions & 8 deletions src/main/java/com/stripe/mpp/Parsing.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---

Expand All @@ -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);
Expand All @@ -54,6 +60,7 @@ static Map<String, String> 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("\\\\", "\\");
Expand All @@ -62,6 +69,20 @@ static Map<String, String> parseAuthParams(String input) {
return params;
}

static String requireString(Map<String, ?> 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");
Expand Down Expand Up @@ -98,6 +119,8 @@ static Credential parseAuthorization(String header) {
Map<String, Object> challengeMap = (Map<String, Object>) challengeObj;

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

Map<String, Object> opaque = null;
if (challengeMap.get("opaque") instanceof Map) {
Expand All @@ -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"),
Expand All @@ -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);
}

Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand Down
6 changes: 5 additions & 1 deletion src/main/java/com/stripe/mpp/Receipt.java
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
22 changes: 19 additions & 3 deletions src/main/java/com/stripe/mpp/methods/stripe/Stripe.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,15 @@ 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);
}

/**
* Returns a {@link StripeMethod} with full configuration.
*
* @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(
Expand All @@ -47,7 +47,23 @@ public static StripeMethod method(
Map<String, String> 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<String> paymentMethods,
Map<String, String> metadata,
String externalId
) {
return new StripeMethod(
secretKey, networkId, paymentMethods, metadata, externalId,
StripeDefaults.DEFAULT_DECIMALS
);
}
Expand Down
10 changes: 3 additions & 7 deletions src/main/java/com/stripe/mpp/methods/stripe/StripeApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -52,6 +53,7 @@ Result createAndConfirm(
long amountMinorUnits,
String currency,
String spt,
List<String> paymentMethodTypes,
Map<String, String> metadata
) {
try {
Expand All @@ -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()) {
Expand Down
Loading
Loading