diff --git a/README.md b/README.md index e041f80..d11a0a0 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,24 @@ Test the endpoint using the [Tempo CLI](https://mpp.dev): tempo request https://your-api.com/your-endpoint ``` +### Tempo API relay + +Delegate Moderato charge validation and broadcast to Tempo API by adding the server-side API key +to the method builder: + +```java +TempoMethod tempo = TempoMethod.of() + .testnet() + .relay(System.getenv("TEMPO_API_KEY")) + .build(); +``` + +`MppHandler.charge(...)` validates immediately before broadcast. Applications that expose those +phases separately can use `validateCredential(...)` for a non-mutating pre-check and +`broadcastCredential(...)` when accepting the payment. The latter always re-validates first. + +See the runnable [Tempo relay example](examples/tempo-relay). + ### Mainnet ```java diff --git a/build.gradle b/build.gradle index d621aa9..2a1c5e9 100644 --- a/build.gradle +++ b/build.gradle @@ -26,10 +26,13 @@ repositories { dependencies { implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.1' implementation 'com.stripe:stripe-java:25.3.0' + implementation 'org.bouncycastle:bcprov-jdk18on:1.84' testImplementation 'org.junit.jupiter:junit-jupiter:5.10.3' testImplementation 'org.assertj:assertj-core:3.26.0' - testImplementation 'org.web3j:crypto:4.9.8' + testImplementation('org.web3j:crypto:4.9.8') { + exclude group: 'org.bouncycastle', module: 'bcprov-jdk15on' + } testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } @@ -40,6 +43,12 @@ test { // --- Integration test source set --- sourceSets { + example { + java.srcDir 'examples/tempo-relay/src/main/java' + compileClasspath += sourceSets.main.output + configurations.runtimeClasspath + runtimeClasspath += output + compileClasspath + } + integrationTest { java.srcDir 'src/integrationTest/java' compileClasspath += sourceSets.main.output + configurations.testRuntimeClasspath @@ -48,10 +57,23 @@ sourceSets { } configurations { + exampleImplementation.extendsFrom implementation + exampleRuntimeOnly.extendsFrom runtimeOnly integrationTestImplementation.extendsFrom testImplementation integrationTestRuntimeOnly.extendsFrom testRuntimeOnly } +tasks.register('runTempoRelayExample', JavaExec) { + description = "Runs the Tempo API relay-backed charge example." + group = 'application' + classpath = sourceSets.example.runtimeClasspath + mainClass = 'com.stripe.mpp.examples.TempoRelayServer' +} + +tasks.named('test') { + dependsOn tasks.named('compileExampleJava') +} + tasks.register('integrationTest', Test) { description = 'Runs integration tests against a live Tempo node (requires docker compose up).' group = 'verification' diff --git a/examples/tempo-relay/README.md b/examples/tempo-relay/README.md new file mode 100644 index 0000000..5ab89bf --- /dev/null +++ b/examples/tempo-relay/README.md @@ -0,0 +1,25 @@ +# Tempo relay charge + +This example runs a Java HTTP endpoint that issues a route-bound Moderato pathUSD charge, +validates the credential through Tempo API, and asks the relay to finalize it. + +```sh +export TEMPO_API_KEY=tempo:sk:... +export MPP_RECIPIENT=0xYourRecipientAddress +export MPP_SECRET_KEY=$(openssl rand -base64 32) +./gradlew runTempoRelayExample +``` + +The API key stays in the server process and needs the `mpp:write` scope. + +| Route | Description | +| --- | --- | +| `GET /api/health` | Free health check | +| `GET /api/photo` | `0.01` pathUSD relay-backed charge | + +The paid flow is: + +1. The server returns a `tempo/charge` challenge. +2. The payer signs a pull transaction and retries with an MPP credential. +3. The Java SDK calls `POST /v1/mpp/validate`, then `POST /v1/mpp/broadcast`. +4. The relay receipt is returned in `Payment-Receipt`. diff --git a/examples/tempo-relay/src/main/java/com/stripe/mpp/examples/TempoRelayServer.java b/examples/tempo-relay/src/main/java/com/stripe/mpp/examples/TempoRelayServer.java new file mode 100644 index 0000000..ebfe8bb --- /dev/null +++ b/examples/tempo-relay/src/main/java/com/stripe/mpp/examples/TempoRelayServer.java @@ -0,0 +1,97 @@ +package com.stripe.mpp.examples; + +import com.stripe.mpp.Challenge; +import com.stripe.mpp.Json; +import com.stripe.mpp.Mpp; +import com.stripe.mpp.error.PaymentException; +import com.stripe.mpp.methods.tempo.TempoDefaults; +import com.stripe.mpp.methods.tempo.TempoMethod; +import com.stripe.mpp.server.ChargeRequest; +import com.stripe.mpp.server.MppHandler; +import com.stripe.mpp.server.VerifyResult; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +/** Minimal HTTP server that accepts a Moderato pathUSD charge through Tempo API's relay. */ +public final class TempoRelayServer { + private TempoRelayServer() {} + + public static void main(String[] args) throws IOException { + String apiKey = required("TEMPO_API_KEY"); + String recipient = required("MPP_RECIPIENT"); + String secretKey = required("MPP_SECRET_KEY"); + int port = Integer.parseInt(System.getenv().getOrDefault("PORT", "8080")); + + TempoMethod tempo = TempoMethod.of().testnet().relay(apiKey).build(); + MppHandler payments = Mpp.create(tempo, "localhost:" + port, secretKey); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", port), 0); + + server.createContext("/api/health", exchange -> { + if (!"GET".equals(exchange.getRequestMethod())) { + send(exchange, 405, Map.of("error", "method not allowed")); + return; + } + send(exchange, 200, Map.of("status", "ok")); + }); + server.createContext("/api/photo", exchange -> { + if (!"GET".equals(exchange.getRequestMethod())) { + send(exchange, 405, Map.of("error", "method not allowed")); + return; + } + + ChargeRequest charge = ChargeRequest.of( + tempo.chargeIntent(), "0.01", TempoDefaults.TESTNET_PATH_USD, recipient + ).description("Relay-backed Java example") + .meta(Map.of("route", "/api/photo")); + try { + VerifyResult result = payments.charge( + exchange.getRequestHeaders().getFirst("Authorization"), charge + ); + if (result instanceof VerifyResult.Challenged) { + exchange.getResponseHeaders().add( + "WWW-Authenticate", + ((VerifyResult.Challenged) result).challenge().toWwwAuthenticate() + ); + send(exchange, 402, Map.of("error", "payment required")); + return; + } + + VerifyResult.Verified verified = (VerifyResult.Verified) result; + exchange.getResponseHeaders().set( + "Payment-Receipt", verified.receipt().toPaymentReceipt() + ); + send(exchange, 200, Map.of("ok", true, "message", "relay payment accepted")); + } catch (PaymentException error) { + Challenge retry = payments.challenge(charge); + exchange.getResponseHeaders().add("WWW-Authenticate", retry.toWwwAuthenticate()); + exchange.getResponseHeaders().set("Content-Type", "application/problem+json"); + send(exchange, error.getHttpStatus(), error.toProblemDetails(retry.id())); + } + }); + + server.start(); + System.out.println("Tempo relay example listening on http://127.0.0.1:" + port); + } + + private static String required(String name) { + String value = System.getenv(name); + if (value == null || value.isBlank()) throw new IllegalArgumentException("Set " + name); + return value; + } + + private static void send(HttpExchange exchange, int status, Map body) + throws IOException { + byte[] bytes = Json.compact(body).getBytes(StandardCharsets.UTF_8); + if (exchange.getResponseHeaders().getFirst("Content-Type") == null) { + exchange.getResponseHeaders().set("Content-Type", "application/json"); + } + exchange.sendResponseHeaders(status, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + } +} diff --git a/src/main/java/com/stripe/mpp/Challenge.java b/src/main/java/com/stripe/mpp/Challenge.java index f2216f9..3945b21 100644 --- a/src/main/java/com/stripe/mpp/Challenge.java +++ b/src/main/java/com/stripe/mpp/Challenge.java @@ -20,6 +20,7 @@ public final class Challenge { private final String expires; private final String description; private final Map opaque; + private final String opaqueRaw; public Challenge( String id, @@ -32,6 +33,25 @@ public Challenge( String expires, String description, Map opaque + ) { + this( + id, method, intent, request, realm, requestB64, digest, expires, description, + opaque, ChallengeId.encodeOpaque(opaque) + ); + } + + private Challenge( + String id, + String method, + String intent, + Map request, + String realm, + String requestB64, + String digest, + String expires, + String description, + Map opaque, + String opaqueRaw ) { this.id = id; this.method = method; @@ -43,6 +63,7 @@ public Challenge( this.expires = expires; this.description = description; this.opaque = opaque; + this.opaqueRaw = opaqueRaw; } public String id() { return id; } @@ -54,7 +75,10 @@ public Challenge( public String digest() { return digest; } public String expires() { return expires; } public String description() { return description; } + /** Decoded metadata when the opaque value contains a JSON object. */ public Map opaque() { return opaque; } + /** Exact base64url opaque value carried in the header and bound by the challenge ID. */ + public String opaqueRaw() { return opaqueRaw; } /** * Create a new challenge with a cryptographically bound ID. @@ -70,8 +94,9 @@ public static Challenge create( Map meta ) { String requestB64 = ChallengeId.b64urlEncode(Json.compact(request)); - String id = ChallengeId.generate(secretKey, realm, method, intent, request, expires, null, meta); - return new Challenge(id, method, intent, request, realm, requestB64, null, expires, description, meta); + String opaqueRaw = ChallengeId.encodeOpaque(meta); + String id = ChallengeId.generateWithOpaque(secretKey, realm, method, intent, request, expires, null, opaqueRaw); + return new Challenge(id, method, intent, request, realm, requestB64, null, expires, description, meta, opaqueRaw); } public static Challenge create( @@ -100,11 +125,8 @@ public static List fromWwwAuthenticate(List wwwAuthenticateHe ); } Map request = ChallengeId.b64urlDecodeToMap(requestB64); - Map opaque = null; - String opaqueVal = params.get("opaque"); - if (opaqueVal != null && !opaqueVal.isEmpty()) { - opaque = ChallengeId.b64urlDecodeToMap(opaqueVal); - } + String opaqueRaw = params.get("opaque"); + Map opaque = Parsing.decodeOpaque(opaqueRaw); challenges.add(new Challenge( id, method, @@ -115,7 +137,8 @@ public static List fromWwwAuthenticate(List wwwAuthenticateHe params.get("digest"), params.get("expires"), params.get("description"), - opaque + opaque, + opaqueRaw )); } } @@ -247,7 +270,9 @@ public static List toWwwAuthenticate(List challenges) { * Convert to the echo form included inside a Credential. */ public ChallengeEcho toEcho() { - return new ChallengeEcho(id, realm, method, intent, requestB64, expires, digest, opaque); + return ChallengeEcho.fromWire( + id, realm, method, intent, requestB64, expires, digest, opaqueRaw, opaque + ); } @Override @@ -264,12 +289,16 @@ public boolean equals(Object o) { && Objects.equals(digest, challenge.digest) && Objects.equals(expires, challenge.expires) && Objects.equals(description, challenge.description) - && Objects.equals(opaque, challenge.opaque); + && Objects.equals(opaque, challenge.opaque) + && Objects.equals(opaqueRaw, challenge.opaqueRaw); } @Override public int hashCode() { - return Objects.hash(id, method, intent, request, realm, requestB64, digest, expires, description, opaque); + return Objects.hash( + id, method, intent, request, realm, requestB64, digest, expires, + description, opaque, opaqueRaw + ); } @Override @@ -285,6 +314,7 @@ public String toString() { + ", expires=" + expires + ", description=" + description + ", opaque=" + opaque + + ", opaqueRaw=" + opaqueRaw + "]"; } } diff --git a/src/main/java/com/stripe/mpp/ChallengeEcho.java b/src/main/java/com/stripe/mpp/ChallengeEcho.java index 573d081..7c3d3e5 100644 --- a/src/main/java/com/stripe/mpp/ChallengeEcho.java +++ b/src/main/java/com/stripe/mpp/ChallengeEcho.java @@ -4,7 +4,9 @@ import java.util.Objects; /** - * The challenge fields echoed back inside a Credential's Authorization header. + * The original challenge fields copied into a Credential's Authorization header. + * This is not a new challenge; encoded request and opaque values are preserved for + * stateless challenge verification. */ public final class ChallengeEcho { private final String id; @@ -15,6 +17,7 @@ public final class ChallengeEcho { private final String expires; private final String digest; private final Map opaque; + private final String opaqueRaw; public ChallengeEcho( String id, @@ -25,6 +28,23 @@ public ChallengeEcho( String expires, String digest, Map opaque + ) { + this( + id, realm, method, intent, request, expires, digest, opaque, + ChallengeId.encodeOpaque(opaque) + ); + } + + private ChallengeEcho( + String id, + String realm, + String method, + String intent, + String request, + String expires, + String digest, + Map opaque, + String opaqueRaw ) { this.id = id; this.realm = realm; @@ -34,6 +54,24 @@ public ChallengeEcho( this.expires = expires; this.digest = digest; this.opaque = opaque; + this.opaqueRaw = opaqueRaw; + } + + /** Create an echo with an exact wire-format opaque value. */ + static ChallengeEcho fromWire( + String id, + String realm, + String method, + String intent, + String request, + String expires, + String digest, + String opaqueRaw, + Map opaque + ) { + return new ChallengeEcho( + id, realm, method, intent, request, expires, digest, opaque, opaqueRaw + ); } public String id() { return id; } @@ -43,7 +81,10 @@ public ChallengeEcho( public String request() { return request; } public String expires() { return expires; } public String digest() { return digest; } + /** Decoded metadata when the opaque value contains a JSON object. */ public Map opaque() { return opaque; } + /** Exact base64url opaque value echoed unchanged from the original challenge. */ + public String opaqueRaw() { return opaqueRaw; } @Override public boolean equals(Object o) { @@ -57,12 +98,13 @@ public boolean equals(Object o) { && Objects.equals(request, that.request) && Objects.equals(expires, that.expires) && Objects.equals(digest, that.digest) - && Objects.equals(opaque, that.opaque); + && Objects.equals(opaque, that.opaque) + && Objects.equals(opaqueRaw, that.opaqueRaw); } @Override public int hashCode() { - return Objects.hash(id, realm, method, intent, request, expires, digest, opaque); + return Objects.hash(id, realm, method, intent, request, expires, digest, opaque, opaqueRaw); } @Override @@ -76,6 +118,7 @@ public String toString() { + ", expires=" + expires + ", digest=" + digest + ", opaque=" + opaque + + ", opaqueRaw=" + opaqueRaw + "]"; } } diff --git a/src/main/java/com/stripe/mpp/ChallengeId.java b/src/main/java/com/stripe/mpp/ChallengeId.java index fbec666..b3a4417 100644 --- a/src/main/java/com/stripe/mpp/ChallengeId.java +++ b/src/main/java/com/stripe/mpp/ChallengeId.java @@ -22,9 +22,25 @@ public static String generate( String expires, String digest, Map opaque + ) { + return generateWithOpaque( + secretKey, realm, method, intent, request, expires, digest, + encodeOpaque(opaque) + ); + } + + /** Generate an ID using the exact wire-format opaque value. */ + public static String generateWithOpaque( + String secretKey, + String realm, + String method, + String intent, + Map request, + String expires, + String digest, + String opaque ) { String requestB64 = b64urlEncode(Json.compact(request)); - String opaqueB64 = opaque != null ? b64urlEncode(Json.compact(opaque)) : ""; String input = String.join("|", realm, @@ -33,7 +49,7 @@ public static String generate( requestB64, expires != null ? expires : "", digest != null ? digest : "", - opaqueB64 + opaque != null ? opaque : "" ); byte[] hmac = hmacSha256( @@ -43,6 +59,14 @@ public static String generate( return b64urlEncodeBytes(hmac); } + /** + * Encode opaque metadata to the wire form bound by {@link #generateWithOpaque} + * (base64url of compact JSON), or null when there is no metadata. + */ + public static String encodeOpaque(Map opaque) { + return opaque != null ? b64urlEncode(Json.compact(opaque)) : null; + } + public static String b64urlEncode(String str) { return b64urlEncodeBytes(str.getBytes(StandardCharsets.UTF_8)); } diff --git a/src/main/java/com/stripe/mpp/Credential.java b/src/main/java/com/stripe/mpp/Credential.java index b86ee21..88841e8 100644 --- a/src/main/java/com/stripe/mpp/Credential.java +++ b/src/main/java/com/stripe/mpp/Credential.java @@ -1,5 +1,7 @@ package com.stripe.mpp; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.Objects; /** @@ -34,6 +36,30 @@ public String toAuthorization() { return Parsing.formatAuthorization(this); } + /** + * Build the credential's wire envelope ({@code challenge}, {@code payload}, {@code source?}) + * using the given representation of the echoed request — the base64url string for the + * Authorization header, or the decoded map for the relay API. This is the single canonical + * serialization of the echoed challenge fields. + */ + public Map toEnvelope(Object request) { + Map challengeMap = new LinkedHashMap<>(); + challengeMap.put("id", challenge.id()); + challengeMap.put("realm", challenge.realm()); + challengeMap.put("method", challenge.method()); + challengeMap.put("intent", challenge.intent()); + challengeMap.put("request", request); + if (challenge.expires() != null) challengeMap.put("expires", challenge.expires()); + if (challenge.digest() != null) challengeMap.put("digest", challenge.digest()); + if (challenge.opaqueRaw() != null) challengeMap.put("opaque", challenge.opaqueRaw()); + + Map envelope = new LinkedHashMap<>(); + envelope.put("challenge", challengeMap); + envelope.put("payload", payload); + if (source != null) envelope.put("source", source); + return envelope; + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/src/main/java/com/stripe/mpp/Json.java b/src/main/java/com/stripe/mpp/Json.java index 0189fad..f4caa62 100644 --- a/src/main/java/com/stripe/mpp/Json.java +++ b/src/main/java/com/stripe/mpp/Json.java @@ -21,7 +21,7 @@ public static String compact(Object value) { } @SuppressWarnings("unchecked") - static Map parseMap(String json) { + public static Map parseMap(String json) { try { return MAPPER.readValue(json, Map.class); } catch (Exception e) { diff --git a/src/main/java/com/stripe/mpp/Parsing.java b/src/main/java/com/stripe/mpp/Parsing.java index ef0c41f..9640d26 100644 --- a/src/main/java/com/stripe/mpp/Parsing.java +++ b/src/main/java/com/stripe/mpp/Parsing.java @@ -102,7 +102,7 @@ static String formatWwwAuthenticate(Challenge challenge) { if (challenge.expires() != null) parts.add("expires=" + quote(challenge.expires())); if (challenge.digest() != null) parts.add("digest=" + quote(challenge.digest())); if (challenge.description() != null) parts.add("description=" + quote(challenge.description())); - if (challenge.opaque() != null) parts.add("opaque=" + quote(b64Encode(challenge.opaque()))); + if (challenge.opaqueRaw() != null) parts.add("opaque=" + quote(challenge.opaqueRaw())); return "Payment " + String.join(", ", parts); } @@ -122,12 +122,20 @@ static Credential parseAuthorization(String header) { String method = requireString(challengeMap, "method"); validatePaymentMethodId(method); + Object opaqueValue = challengeMap.get("opaque"); Map opaque = null; - if (challengeMap.get("opaque") instanceof Map) { - opaque = (Map) challengeMap.get("opaque"); + String opaqueRaw = null; + if (opaqueValue instanceof String) { + opaqueRaw = (String) opaqueValue; + opaque = decodeOpaque(opaqueRaw); + } else if (opaqueValue instanceof Map) { + opaque = (Map) opaqueValue; + opaqueRaw = ChallengeId.encodeOpaque(opaque); + } else if (opaqueValue != null) { + throw new ParseException("Credential challenge has invalid field: opaque"); } - ChallengeEcho echo = new ChallengeEcho( + ChallengeEcho echo = ChallengeEcho.fromWire( str(challengeMap, "id"), str(challengeMap, "realm"), method, @@ -135,6 +143,7 @@ static Credential parseAuthorization(String header) { str(challengeMap, "request"), str(challengeMap, "expires"), str(challengeMap, "digest"), + opaqueRaw, opaque ); @@ -151,24 +160,7 @@ private static String str(Map map, String key) { } static String formatAuthorization(Credential credential) { - ChallengeEcho echo = credential.challenge(); - - Map challengeMap = new java.util.LinkedHashMap<>(); - challengeMap.put("id", echo.id()); - challengeMap.put("realm", echo.realm()); - challengeMap.put("method", echo.method()); - challengeMap.put("intent", echo.intent()); - challengeMap.put("request", echo.request()); - if (echo.expires() != null) challengeMap.put("expires", echo.expires()); - if (echo.digest() != null) challengeMap.put("digest", echo.digest()); - if (echo.opaque() != null) challengeMap.put("opaque", echo.opaque()); - - Map envelope = new java.util.LinkedHashMap<>(); - envelope.put("challenge", challengeMap); - envelope.put("payload", credential.payload()); - if (credential.source() != null) envelope.put("source", credential.source()); - - return "Payment " + b64Encode(envelope); + return "Payment " + b64Encode(credential.toEnvelope(credential.challenge().request())); } // --- Payment-Receipt --- @@ -224,4 +216,13 @@ private static String stripScheme(String header, String scheme) { if (!lower.startsWith(scheme)) return header; return header.substring(scheme.length()).stripLeading(); } + + static Map decodeOpaque(String opaque) { + if (opaque == null) return null; + try { + return ChallengeId.b64urlDecodeToMap(opaque); + } catch (ParseException e) { + return null; + } + } } diff --git a/src/main/java/com/stripe/mpp/error/PaymentException.java b/src/main/java/com/stripe/mpp/error/PaymentException.java index b16b5b2..222433d 100644 --- a/src/main/java/com/stripe/mpp/error/PaymentException.java +++ b/src/main/java/com/stripe/mpp/error/PaymentException.java @@ -1,6 +1,7 @@ package com.stripe.mpp.error; -import java.util.HashMap; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.Map; public class PaymentException extends RuntimeException { @@ -9,12 +10,27 @@ public class PaymentException extends RuntimeException { private final int httpStatus; private final String type; private final String title; + /** Safe structured context emitted as the RFC 9457 {@code details} extension member. */ + private final Map details; public PaymentException(String message, int httpStatus, String type, String title) { + this(message, httpStatus, type, title, null); + } + + public PaymentException( + String message, + int httpStatus, + String type, + String title, + Map details + ) { super(message); this.httpStatus = httpStatus; this.type = type; this.title = title; + this.details = details == null || details.isEmpty() + ? Map.of() + : Collections.unmodifiableMap(new LinkedHashMap<>(details)); } public PaymentException(String message) { @@ -24,20 +40,25 @@ public PaymentException(String message) { public int getHttpStatus() { return httpStatus; } public String getType() { return type; } public String getTitle() { return title; } + public Map getDetails() { return details; } public Map toProblemDetails() { return toProblemDetails(null); } public Map toProblemDetails(String challengeId) { - Map details = new HashMap<>(); - details.put("type", type); - details.put("title", title); - details.put("status", httpStatus); - details.put("detail", getMessage()); + Map problem = new LinkedHashMap<>(); + problem.put("type", type); + problem.put("title", title); + problem.put("status", httpStatus); + // "detail" is the RFC 9457 message; "details" is structured extension data. + problem.put("detail", getMessage()); + if (!details.isEmpty()) { + problem.put("details", details); + } if (challengeId != null) { - details.put("challengeId", challengeId); + problem.put("challengeId", challengeId); } - return details; + return problem; } } diff --git a/src/main/java/com/stripe/mpp/error/VerificationFailedException.java b/src/main/java/com/stripe/mpp/error/VerificationFailedException.java index eab4545..3895653 100644 --- a/src/main/java/com/stripe/mpp/error/VerificationFailedException.java +++ b/src/main/java/com/stripe/mpp/error/VerificationFailedException.java @@ -1,9 +1,15 @@ package com.stripe.mpp.error; +import java.util.Map; + public class VerificationFailedException extends PaymentException { public VerificationFailedException(String reason) { + this(reason, null); + } + + public VerificationFailedException(String reason, Map details) { super(reason != null ? "Payment verification failed: " + reason + "." : "Payment verification failed.", - 402, BASE_URI + "/verification-failed", "Verification Failed"); + 402, BASE_URI + "/verification-failed", "Verification Failed", details); } public VerificationFailedException() { diff --git a/src/main/java/com/stripe/mpp/methods/tempo/TempoChargeIntent.java b/src/main/java/com/stripe/mpp/methods/tempo/TempoChargeIntent.java index 476cec6..b38c3a6 100644 --- a/src/main/java/com/stripe/mpp/methods/tempo/TempoChargeIntent.java +++ b/src/main/java/com/stripe/mpp/methods/tempo/TempoChargeIntent.java @@ -4,6 +4,7 @@ import com.stripe.mpp.Receipt; import com.stripe.mpp.error.VerificationFailedException; import com.stripe.mpp.server.Intent; +import com.stripe.mpp.server.ValidationResult; import java.math.BigInteger; import java.util.List; @@ -177,3 +178,29 @@ private boolean matchTransferLogs(Map receipt, Map request) { + return relay.validate(credential); + } + + @Override + public Receipt broadcast(Credential credential, Map request) { + return relay.broadcast(credential); + } + + @Override + public Receipt verify(Credential credential, Map request) { + return relay.verify(credential); + } +} diff --git a/src/main/java/com/stripe/mpp/methods/tempo/TempoDefaults.java b/src/main/java/com/stripe/mpp/methods/tempo/TempoDefaults.java index 5c42017..83a3b42 100644 --- a/src/main/java/com/stripe/mpp/methods/tempo/TempoDefaults.java +++ b/src/main/java/com/stripe/mpp/methods/tempo/TempoDefaults.java @@ -1,6 +1,7 @@ package com.stripe.mpp.methods.tempo; -class TempoDefaults { +/** Well-known Tempo network constants. */ +public class TempoDefaults { static final String MAINNET_RPC = "https://rpc.tempo.xyz"; static final String TESTNET_RPC = "https://rpc.moderato.tempo.xyz"; static final int MAINNET_CHAIN_ID = 4217; @@ -8,7 +9,7 @@ class TempoDefaults { static final int DEFAULT_DECIMALS = 6; /** USDC contract on Tempo mainnet (chain 4217). Pass as {@code currency} to {@code charge()}. */ - static final String MAINNET_USDC = "0x20C000000000000000000000b9537d11c60E8b50"; + public static final String MAINNET_USDC = "0x20C000000000000000000000b9537d11c60E8b50"; /** PATH_USD contract on Tempo testnet / Moderato (chain 42431). Pass as {@code currency} to {@code charge()}. */ - static final String TESTNET_PATH_USD = "0x20c0000000000000000000000000000000000000"; + public static final String TESTNET_PATH_USD = "0x20c0000000000000000000000000000000000000"; } diff --git a/src/main/java/com/stripe/mpp/methods/tempo/TempoMethod.java b/src/main/java/com/stripe/mpp/methods/tempo/TempoMethod.java index 9e02095..2042c58 100644 --- a/src/main/java/com/stripe/mpp/methods/tempo/TempoMethod.java +++ b/src/main/java/com/stripe/mpp/methods/tempo/TempoMethod.java @@ -8,6 +8,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; /** * MPP payment method for Tempo. @@ -15,6 +16,7 @@ *
{@code
  * TempoMethod tempo = TempoMethod.of().build();              // mainnet
  * TempoMethod tempo = TempoMethod.of().testnet().build();    // testnet
+ * TempoMethod tempo = TempoMethod.of().testnet().relay(apiKey).build();
  * TempoMethod tempo = TempoMethod.custom("http://localhost:8545", 1337).build();
  * }
*/ @@ -25,14 +27,16 @@ public class TempoMethod implements Method { private final TempoChargeIntent chargeIntent; TempoMethod(String rpcUrl, int chainId) { - this(rpcUrl, chainId, TempoDefaults.DEFAULT_DECIMALS); + this(rpcUrl, chainId, TempoDefaults.DEFAULT_DECIMALS, null); } - TempoMethod(String rpcUrl, int chainId, int decimals) { + TempoMethod(String rpcUrl, int chainId, int decimals, TempoRelay relay) { this.rpcUrl = rpcUrl; this.chainId = chainId; this.decimals = decimals; - this.chargeIntent = new TempoChargeIntent(rpcUrl, new TempoRpc()); + this.chargeIntent = relay == null + ? new TempoChargeIntent(rpcUrl, new TempoRpc()) + : new TempoRelayChargeIntent(rpcUrl, relay); } /** Starts a builder defaulting to Tempo mainnet. Call {@link Builder#testnet()} to switch. */ @@ -48,6 +52,7 @@ public static Builder custom(String rpcUrl, int chainId) { public static final class Builder { private String rpcUrl = TempoDefaults.MAINNET_RPC; private int chainId = TempoDefaults.MAINNET_CHAIN_ID; + private TempoRelay relay; private Builder() {} @@ -63,8 +68,19 @@ public Builder testnet() { return this; } + /** Delegate Tempo charge validation and broadcast to Tempo API's MPP relay. */ + public Builder relay(String apiKey) { + return relay(TempoRelay.builder(apiKey).build()); + } + + /** Delegate Tempo charge validation and broadcast to the configured MPP relay. */ + public Builder relay(TempoRelay relay) { + this.relay = Objects.requireNonNull(relay, "relay"); + return this; + } + public TempoMethod build() { - return new TempoMethod(rpcUrl, chainId); + return new TempoMethod(rpcUrl, chainId, TempoDefaults.DEFAULT_DECIMALS, relay); } } diff --git a/src/main/java/com/stripe/mpp/methods/tempo/TempoRelay.java b/src/main/java/com/stripe/mpp/methods/tempo/TempoRelay.java new file mode 100644 index 0000000..68ee86f --- /dev/null +++ b/src/main/java/com/stripe/mpp/methods/tempo/TempoRelay.java @@ -0,0 +1,254 @@ +package com.stripe.mpp.methods.tempo; + +import com.stripe.mpp.ChallengeId; +import com.stripe.mpp.Credential; +import com.stripe.mpp.Json; +import com.stripe.mpp.Receipt; +import com.stripe.mpp.error.PaymentException; +import com.stripe.mpp.error.PaymentExpiredException; +import com.stripe.mpp.error.VerificationFailedException; +import com.stripe.mpp.server.ValidationResult; +import org.bouncycastle.jcajce.provider.digest.Keccak; +import org.bouncycastle.util.encoders.DecoderException; +import org.bouncycastle.util.encoders.Hex; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Duration; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** Configuration and client for Tempo API's MPP relay. */ +public final class TempoRelay { + public static final URI DEFAULT_API_BASE_URL = URI.create("https://api.tempo.xyz/"); + + private static final Duration TIMEOUT = Duration.ofSeconds(30); + private static final String IDEMPOTENCY_KEY_PREFIX = "mpp_java_"; + // Relay error codes safe to forward to payers as failure details; "expired" maps to a + // typed exception before this set is consulted, and all remaining codes map to a + // generic failure. + private static final Set SAFE_ERROR_CODES = Set.of( + "already_used", + "broadcast_failed", + "invalid_payment", + "insufficient_funds", + "simulation_failed", + "unsupported", + "temporarily_unavailable" + ); + + private final String apiKey; + private final URI validateUrl; + private final URI broadcastUrl; + private final HttpClient http; + + private TempoRelay(Builder builder) { + this.apiKey = builder.apiKey; + URI apiBaseUrl = normalizeBaseUrl(builder.apiBaseUrl); + this.validateUrl = apiBaseUrl.resolve("v1/mpp/validate"); + this.broadcastUrl = apiBaseUrl.resolve("v1/mpp/broadcast"); + this.http = builder.http != null + ? builder.http + : HttpClient.newBuilder().connectTimeout(TIMEOUT).build(); + } + + /** Start configuring Tempo API's relay with an API key that has the {@code mpp:write} scope. */ + public static Builder builder(String apiKey) { + return new Builder(apiKey); + } + + ValidationResult validate(Credential credential) { + Map request = challengeRequest(credential); + post(validateUrl, relayBody(credential, request), null); + return new ValidationResult(credential, request, Map.of()); + } + + Receipt broadcast(Credential credential) { + String body = relayBody(credential, challengeRequest(credential)); + return toReceipt(post(broadcastUrl, body, idempotencyKey(credential, body))); + } + + /** Validate then broadcast, building the relay request body once. */ + Receipt verify(Credential credential) { + String body = relayBody(credential, challengeRequest(credential)); + post(validateUrl, body, null); + return toReceipt(post(broadcastUrl, body, idempotencyKey(credential, body))); + } + + private static String relayBody(Credential credential, Map request) { + Map input = credential.toEnvelope(request); + // The relay rejects an empty source; omit it like the reference SDKs do. + if ("".equals(credential.source())) input.remove("source"); + return Json.compact(input); + } + + private static Receipt toReceipt(Map response) { + Object value = response.get("receipt"); + if (!(value instanceof Map)) throw failure(); + Map receipt = (Map) value; + Object method = receipt.get("method"); + Object reference = receipt.get("reference"); + Object timestamp = receipt.get("timestamp"); + Object externalId = receipt.get("externalId"); + if (!"tempo".equals(method) || !(reference instanceof String) + || !(timestamp instanceof String) + || (externalId != null && !(externalId instanceof String))) { + throw failure(); + } + + try { + return new Receipt( + "success", + Instant.parse((String) timestamp), + (String) reference, + (String) method, + (String) externalId, + null + ); + } catch (RuntimeException e) { + throw failure(); + } + } + + /** POST to the relay and return the parsed response, which is always a success envelope. */ + private Map post(URI url, String body, String idempotencyKey) { + HttpRequest.Builder request = HttpRequest.newBuilder() + .uri(url) + .timeout(TIMEOUT) + .header("Accept", "application/json") + .header("Content-Type", "application/json") + .header("tempo-api-key", apiKey) + .POST(HttpRequest.BodyPublishers.ofString(body)); + if (idempotencyKey != null) request.header("idempotency-key", idempotencyKey); + + HttpResponse response; + try { + response = http.send(request.build(), HttpResponse.BodyHandlers.ofString()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw failure(); + } catch (Exception e) { + throw failure(); + } + + if (response.statusCode() < 200 || response.statusCode() >= 300) throw failure(); + Map parsed; + try { + parsed = Json.parseMap(response.body()); + } catch (RuntimeException e) { + throw failure(); + } + if (!Boolean.TRUE.equals(parsed.get("success"))) throw failure(parsed); + return parsed; + } + + /** + * The request the relay validates against is always decoded from the credential's own + * HMAC-bound challenge, never taken from the caller. + */ + private static Map challengeRequest(Credential credential) { + try { + return ChallengeId.b64urlDecodeToMap(credential.challenge().request()); + } catch (RuntimeException e) { + throw new VerificationFailedException("invalid challenge request"); + } + } + + @SuppressWarnings("unchecked") + private static String idempotencyKey(Credential credential, String body) { + Object payloadValue = credential.payload(); + if (payloadValue instanceof Map) { + Map payload = (Map) payloadValue; + Object signature = payload.get("signature"); + if ("transaction".equals(payload.get("type")) && signature instanceof String) { + String value = (String) signature; + try { + if (value.startsWith("0x") && value.length() > 2) { + byte[] transaction = Hex.decodeStrict(value, 2, value.length() - 2); + return IDEMPOTENCY_KEY_PREFIX + "0x" + + Hex.toHexString(new Keccak.Digest256().digest(transaction)); + } + } catch (DecoderException ignored) { + // Fall through to the canonical credential hash. + } + } + } + + try { + byte[] hash = MessageDigest.getInstance("SHA-256") + .digest(body.getBytes(StandardCharsets.UTF_8)); + return IDEMPOTENCY_KEY_PREFIX + "0x" + Hex.toHexString(hash); + } catch (Exception e) { + throw new IllegalStateException("SHA-256 unavailable", e); + } + } + + private static PaymentException failure(Map response) { + Object errorValue = response.get("error"); + if (!(errorValue instanceof Map)) return failure(); + Object codeValue = ((Map) errorValue).get("code"); + if (!(codeValue instanceof String)) return failure(); + String code = (String) codeValue; + if ("expired".equals(code)) return new PaymentExpiredException(); + if (!SAFE_ERROR_CODES.contains(code)) return failure(); + + Map details = new LinkedHashMap<>(); + details.put("code", code); + if ("temporarily_unavailable".equals(code)) details.put("retry", "same_credential"); + return new VerificationFailedException(null, details); + } + + private static VerificationFailedException failure() { + return new VerificationFailedException(); + } + + private static URI normalizeBaseUrl(URI value) { + Objects.requireNonNull(value, "apiBaseUrl"); + if (!"http".equalsIgnoreCase(value.getScheme()) + && !"https".equalsIgnoreCase(value.getScheme())) { + throw new IllegalArgumentException("Relay API base URL must use HTTP or HTTPS"); + } + if (value.getQuery() != null || value.getFragment() != null) { + throw new IllegalArgumentException("Relay API base URL must not include a query or fragment"); + } + String base = value.toString(); + return URI.create(base.endsWith("/") ? base : base + "/"); + } + + /** Builder for {@link TempoRelay}. */ + public static final class Builder { + private final String apiKey; + private URI apiBaseUrl = DEFAULT_API_BASE_URL; + private HttpClient http; + + private Builder(String apiKey) { + if (apiKey == null || apiKey.isBlank()) { + throw new IllegalArgumentException("Tempo API key is required"); + } + this.apiKey = apiKey; + } + + /** Override the Tempo API base URL, preserving any path prefix. */ + public Builder apiBaseUrl(URI apiBaseUrl) { + this.apiBaseUrl = apiBaseUrl; + return this; + } + + /** Override the HTTP client used for relay calls. */ + public Builder httpClient(HttpClient http) { + this.http = Objects.requireNonNull(http, "http"); + return this; + } + + public TempoRelay build() { + return new TempoRelay(this); + } + } +} diff --git a/src/main/java/com/stripe/mpp/server/Intent.java b/src/main/java/com/stripe/mpp/server/Intent.java index c74c44f..eb0709b 100644 --- a/src/main/java/com/stripe/mpp/server/Intent.java +++ b/src/main/java/com/stripe/mpp/server/Intent.java @@ -17,8 +17,43 @@ public interface Intent { String name(); /** - * Verify the credential against the request and return a receipt on success. - * Throw a {@link PaymentException} subclass on failure. + * Validate a credential without broadcasting or consuming it. + * + *

This is an advisory pre-check. Implementations must not settle, reserve, or otherwise + * mutate payment state. Legacy intents that only implement {@link #verify} do not support + * validation-only calls. */ - Receipt verify(Credential credential, Map request) throws PaymentException; + default ValidationResult validate(Credential credential, Map request) + throws PaymentException { + throw new UnsupportedOperationException( + name() + " does not support non-mutating credential validation" + ); + } + + /** + * Perform the terminal payment operation and return its receipt. + * + *

Callers accepting payment must validate immediately before invoking this hook. New + * intents should implement both {@link #validate} and this method, and inherit the default + * {@link #verify} composition. + */ + default Receipt broadcast(Credential credential, Map request) + throws PaymentException { + throw new UnsupportedOperationException( + name() + " does not support credential broadcast" + ); + } + + /** + * Validate and broadcast the credential, returning a receipt on success. + * + *

Existing intents may continue overriding this combined hook. New intents should + * implement {@link #validate} and {@link #broadcast}; this default re-validates immediately + * before the terminal operation. + */ + default Receipt verify(Credential credential, Map request) + throws PaymentException { + validate(credential, request); + return broadcast(credential, request); + } } diff --git a/src/main/java/com/stripe/mpp/server/MppHandler.java b/src/main/java/com/stripe/mpp/server/MppHandler.java index 00f416e..7893862 100644 --- a/src/main/java/com/stripe/mpp/server/MppHandler.java +++ b/src/main/java/com/stripe/mpp/server/MppHandler.java @@ -1,5 +1,11 @@ package com.stripe.mpp.server; +import com.stripe.mpp.Challenge; +import com.stripe.mpp.Credential; +import com.stripe.mpp.Receipt; +import com.stripe.mpp.error.MalformedCredentialException; +import com.stripe.mpp.error.ParseException; + import java.util.LinkedHashMap; import java.util.Map; @@ -50,6 +56,35 @@ public static MppHandler create(Method method, String realm, String secretKey, M public String secretKey() { return secretKey; } public Map defaults() { return defaults; } + /** + * Validate a credential without broadcasting or consuming it. + * + *

The echoed challenge is HMAC-checked, matched to this handler, and checked for expiry + * before the intent's non-mutating validation hook runs. + */ + public ValidationResult validateCredential(String authorization, Intent intent) { + return validateCredential(Verify.parseCredential(authorization), intent); + } + + /** Validate a parsed credential without broadcasting or consuming it. */ + public ValidationResult validateCredential(Credential credential, Intent intent) { + Map request = prepareCredential(credential, intent); + return intent.validate(credential, request); + } + + /** + * Re-validate and perform the terminal payment operation for a credential. + */ + public Receipt broadcastCredential(String authorization, Intent intent) { + return broadcastCredential(Verify.parseCredential(authorization), intent); + } + + /** Re-validate and perform the terminal payment operation for a parsed credential. */ + public Receipt broadcastCredential(Credential credential, Intent intent) { + Map request = prepareCredential(credential, intent); + return intent.verify(credential, request); + } + /** * Verify a payment credential or issue a new challenge. * @@ -72,9 +107,7 @@ public VerifyResult charge( Map meta, String expires ) { - if (!method.intents().contains(intent.getClass())) { - throw new IllegalArgumentException("Method does not support " + intent.getClass().getSimpleName() + " intents"); - } + requireSupported(intent); String resolvedCurrency = currency != null ? currency : (String) defaults.get("currency"); String resolvedRecipient = recipient != null ? recipient : (String) defaults.get("recipient"); @@ -116,6 +149,19 @@ public VerifyResult charge(String authorization, ChargeRequest req) { req.recipient(), req.description(), req.meta(), req.expires()); } + /** + * Mint a fresh payment challenge for the given charge, e.g. for the WWW-Authenticate + * header of an error response. + */ + public Challenge challenge(ChargeRequest req) { + requireSupported(req.intent()); + Map request = buildRequest(chargeDescriptor(req)); + return Verify.createChallenge( + method.name(), req.intent(), request, realm, secretKey, + req.description(), req.meta(), req.expires() + ); + } + /** * Create a {@link ChargeDescriptor} pre-configured with the given parameters for use * with {@link com.stripe.mpp.Mpp#compose}. @@ -163,4 +209,19 @@ Map buildRequest(ChargeDescriptor d) { return method.transformRequest(request); } + + private Map prepareCredential(Credential credential, Intent intent) { + requireSupported(intent); + try { + return Verify.assertCredential(credential, intent, realm, secretKey, method.name()); + } catch (ParseException e) { + throw new MalformedCredentialException(e.getMessage()); + } + } + + private void requireSupported(Intent intent) { + if (method.intents().stream().noneMatch(type -> type.isInstance(intent))) { + throw new IllegalArgumentException("Method does not support " + intent.getClass().getSimpleName() + " intents"); + } + } } diff --git a/src/main/java/com/stripe/mpp/server/ValidationResult.java b/src/main/java/com/stripe/mpp/server/ValidationResult.java new file mode 100644 index 0000000..2cfa461 --- /dev/null +++ b/src/main/java/com/stripe/mpp/server/ValidationResult.java @@ -0,0 +1,54 @@ +package com.stripe.mpp.server; + +import com.stripe.mpp.ChallengeEcho; +import com.stripe.mpp.Credential; + +import java.util.Map; +import java.util.Objects; + +/** The non-mutating result of validating a payment credential. */ +public final class ValidationResult { + private final Credential credential; + private final Map request; + private final Map details; + + public ValidationResult( + Credential credential, + Map request, + Map details + ) { + this.credential = Objects.requireNonNull(credential, "credential"); + this.request = Map.copyOf(request); + this.details = details == null ? Map.of() : Map.copyOf(details); + } + + public Credential credential() { return credential; } + public ChallengeEcho challenge() { return credential.challenge(); } + public Map request() { return request; } + public Map details() { return details; } + public String method() { return credential.challenge().method(); } + public String intent() { return credential.challenge().intent(); } + public String source() { return credential.source(); } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof ValidationResult)) return false; + ValidationResult that = (ValidationResult) o; + return Objects.equals(credential, that.credential) + && Objects.equals(request, that.request) + && Objects.equals(details, that.details); + } + + @Override + public int hashCode() { + return Objects.hash(credential, request, details); + } + + @Override + public String toString() { + return "ValidationResult[credential=" + credential + + ", request=" + request + + ", details=" + details + "]"; + } +} diff --git a/src/main/java/com/stripe/mpp/server/Verify.java b/src/main/java/com/stripe/mpp/server/Verify.java index 4c8024f..336fbf4 100644 --- a/src/main/java/com/stripe/mpp/server/Verify.java +++ b/src/main/java/com/stripe/mpp/server/Verify.java @@ -6,8 +6,10 @@ import com.stripe.mpp.Credential; import com.stripe.mpp.Json; import com.stripe.mpp.Receipt; +import com.stripe.mpp.error.InvalidChallengeException; +import com.stripe.mpp.error.MalformedCredentialException; import com.stripe.mpp.error.ParseException; -import com.stripe.mpp.error.PaymentException; +import com.stripe.mpp.error.PaymentExpiredException; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; @@ -41,88 +43,96 @@ public static VerifyResult verifyOrChallenge( Map meta, String expires ) { - if (authorization == null) { + Credential credential; + try { + credential = parseCredential(authorization); + } catch (MalformedCredentialException e) { return new VerifyResult.Challenged(createChallenge(methodName, intent, request, realm, secretKey, description, meta, expires)); } - String paymentScheme = extractPaymentScheme(authorization); - if (paymentScheme == null) { + try { + Map echoRequest = assertCredential(credential, intent, realm, secretKey, methodName); + // Canonical JSON avoids Integer-vs-Long mismatches after decoding. + if (!Json.compact(echoRequest).equals(Json.compact(request))) { + throw new InvalidChallengeException(credential.challenge().id(), "request does not match"); + } + if (!Objects.equals(credential.challenge().opaqueRaw(), ChallengeId.encodeOpaque(meta))) { + throw new InvalidChallengeException(credential.challenge().id(), "opaque data does not match"); + } + } catch (ParseException | InvalidChallengeException | PaymentExpiredException e) { return new VerifyResult.Challenged(createChallenge(methodName, intent, request, realm, secretKey, description, meta, expires)); } - Credential credential; + Receipt receipt = intent.verify(credential, request); + return new VerifyResult.Verified(credential, receipt); + } + + /** Parse the Authorization header into a Credential. */ + static Credential parseCredential(String authorization) { + if (authorization == null) { + throw new MalformedCredentialException("missing Authorization header"); + } + String payment = extractPaymentScheme(authorization); + if (payment == null) { + throw new MalformedCredentialException("missing Payment scheme"); + } try { - credential = Credential.fromAuthorization(paymentScheme); + return Credential.fromAuthorization(payment); } catch (ParseException e) { - return new VerifyResult.Challenged(createChallenge(methodName, intent, request, realm, secretKey, description, meta, expires)); + throw new MalformedCredentialException(e.getMessage()); } + } + /** + * Verify challenge provenance (HMAC binding, method/route match) and expiry for a parsed + * credential. Whether the echoed request and opaque match the server's expectations stays + * with the caller: the charge path checks them against the current route, while standalone + * lifecycle calls trust the HMAC-bound echo as-is. + * + * @return the request decoded from the echoed challenge + */ + static Map assertCredential( + Credential credential, + Intent intent, + String realm, + String secretKey, + String methodName + ) { ChallengeEcho echo = credential.challenge(); - - // Decode the echoed request and opaque back to maps for HMAC verification - Map echoRequest; - Map echoOpaque; - try { - echoRequest = (echo.request() == null || echo.request().isEmpty()) - ? Map.of() - : ChallengeId.b64urlDecodeToMap(echo.request()); - echoOpaque = echo.opaque(); - } catch (ParseException e) { - return new VerifyResult.Challenged(createChallenge(methodName, intent, request, realm, secretKey, description, meta, expires)); + if (echo == null || echo.id() == null || echo.realm() == null + || echo.method() == null || echo.intent() == null + || echo.request() == null || echo.request().isEmpty()) { + throw new InvalidChallengeException(null, "missing required challenge fields"); } - // Recompute the challenge ID and compare using constant-time comparison - String expectedId = ChallengeId.generate( + Map echoRequest = ChallengeId.b64urlDecodeToMap(echo.request()); + String echoOpaque = echo.opaqueRaw(); + + String expectedId = ChallengeId.generateWithOpaque( secretKey, echo.realm(), echo.method(), echo.intent(), echoRequest, echo.expires(), echo.digest(), echoOpaque ); if (!secureCompare(echo.id(), expectedId)) { - return new VerifyResult.Challenged(createChallenge(methodName, intent, request, realm, secretKey, description, meta, expires)); + throw new InvalidChallengeException(echo.id(), "challenge binding does not match"); } - // Verify echoed fields match the current server state - if (!realm.equals(echo.realm()) || !methodName.equals(echo.method()) || !intent.name().equals(echo.intent())) { - return new VerifyResult.Challenged(createChallenge(methodName, intent, request, realm, secretKey, description, meta, expires)); + if (!realm.equals(echo.realm()) || !methodName.equals(echo.method()) + || !intent.name().equals(echo.intent())) { + throw new InvalidChallengeException(echo.id(), "method or route does not match"); } - // Verify echoed request matches expected request. Compare via canonical JSON - // rather than Java object equality to avoid Integer vs Long mismatches that - // arise when Jackson deserializes numeric values from the echoed base64 request. - if (!Json.compact(echoRequest).equals(Json.compact(request))) { - return new VerifyResult.Challenged(createChallenge(methodName, intent, request, realm, secretKey, description, meta, expires)); - } - - // Verify echoed meta/opaque matches - if (echoOpaque != null || meta != null) { - String echoOpaqueJson = Json.compact(echoOpaque != null ? echoOpaque : Map.of()); - String metaJson = Json.compact(meta != null ? meta : Map.of()); - if (!echoOpaqueJson.equals(metaJson)) { - return new VerifyResult.Challenged(createChallenge(methodName, intent, request, realm, secretKey, description, meta, expires)); - } - } - - // Challenges must always have an expiry (fail closed) if (echo.expires() == null) { - return new VerifyResult.Challenged(createChallenge(methodName, intent, request, realm, secretKey, description, meta, expires)); + throw new InvalidChallengeException(echo.id(), "missing expiry"); } - - // Reject expired challenges try { - Instant expiry = Instant.parse(echo.expires()); - if (expiry.isBefore(Instant.now())) { - return new VerifyResult.Challenged(createChallenge(methodName, intent, request, realm, secretKey, description, meta, expires)); + if (Instant.parse(echo.expires()).isBefore(Instant.now())) { + throw new PaymentExpiredException(echo.expires()); } } catch (DateTimeParseException e) { - return new VerifyResult.Challenged(createChallenge(methodName, intent, request, realm, secretKey, description, meta, expires)); + throw new InvalidChallengeException(echo.id(), "invalid expiry"); } - // Delegate to the intent for payment-method-specific verification - try { - Receipt receipt = intent.verify(credential, request); - return new VerifyResult.Verified(credential, receipt); - } catch (PaymentException e) { - throw e; - } + return echoRequest; } static Challenge createChallenge( @@ -151,6 +161,7 @@ static String extractPaymentScheme(String header) { } static boolean secureCompare(String a, String b) { + if (a == null || b == null) return false; return MessageDigest.isEqual( a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8) diff --git a/src/test/java/com/stripe/mpp/ChallengeIdTest.java b/src/test/java/com/stripe/mpp/ChallengeIdTest.java index fd474a1..20cb8ce 100644 --- a/src/test/java/com/stripe/mpp/ChallengeIdTest.java +++ b/src/test/java/com/stripe/mpp/ChallengeIdTest.java @@ -55,4 +55,22 @@ void generateWithExpires() { String withoutExpires = ChallengeId.generate("secret", "example.com", "tempo", "charge", request, null, null, null); assertThat(withExpires).isNotEqualTo(withoutExpires); } + + @Test + void rawOpaqueUsesTheExactWireValue() { + Map request = Map.of("amount", "10"); + Map meta = Map.of("route", "/paid"); + String opaque = ChallengeId.b64urlEncode(Json.compact(meta)); + + assertThat(ChallengeId.generateWithOpaque( + "secret", "example.com", "tempo", "charge", request, null, null, opaque + )).isEqualTo(ChallengeId.generate( + "secret", "example.com", "tempo", "charge", request, null, null, meta + )); + assertThat(ChallengeId.generateWithOpaque( + "secret", "example.com", "tempo", "charge", request, null, null, "raw" + )).isNotEqualTo(ChallengeId.generateWithOpaque( + "secret", "example.com", "tempo", "charge", request, null, null, "RAW" + )); + } } diff --git a/src/test/java/com/stripe/mpp/IntegrationTest.java b/src/test/java/com/stripe/mpp/IntegrationTest.java index c203d3d..3b8d727 100644 --- a/src/test/java/com/stripe/mpp/IntegrationTest.java +++ b/src/test/java/com/stripe/mpp/IntegrationTest.java @@ -1,9 +1,11 @@ package com.stripe.mpp; +import com.stripe.mpp.error.InvalidChallengeException; import com.stripe.mpp.error.PaymentException; import com.stripe.mpp.server.*; import org.junit.jupiter.api.Test; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -37,6 +39,53 @@ public List> intents() { } } + static class SplitChargeIntent implements Intent { + int validations; + int broadcasts; + + @Override + public String name() { return "charge"; } + + @Override + public ValidationResult validate(Credential credential, Map request) { + validations++; + return new ValidationResult(credential, request, Map.of("risk", "accepted")); + } + + @Override + public Receipt broadcast(Credential credential, Map request) { + broadcasts++; + return Receipt.success("split-ref", "test"); + } + } + + static class VerifiableMethod implements Method { + @Override + public String name() { return "test"; } + + @Override + public List> intents() { + return List.of(SplitChargeIntent.class); + } + } + + static class HybridSplitIntent extends SplitChargeIntent { + int legacyVerifications; + + @Override + public Receipt verify(Credential credential, Map request) { + legacyVerifications++; + return Receipt.success("legacy-ref", "test"); + } + } + + static class HybridSplitMethod implements Method { + @Override public String name() { return "test"; } + @Override public List> intents() { + return List.of(HybridSplitIntent.class); + } + } + // --- Tests --- @Test @@ -96,6 +145,202 @@ void validCredentialIsVerified() { assertThat(verified.receipt().reference()).isEqualTo("tx-ref-12345"); } + @Test + void standaloneLifecycleSeparatesValidationAndBroadcast() { + MppHandler server = Mpp.create(new VerifiableMethod(), "api.example.com", "super-secret"); + SplitChargeIntent intent = new SplitChargeIntent(); + VerifyResult challenged = server.charge( + null, intent, "10.000000", "USDC", "0xRecipient" + ); + Challenge challenge = ((VerifyResult.Challenged) challenged).challenge(); + Credential credential = new Credential(challenge.toEcho(), Map.of("sig", "x"), "payer"); + + ValidationResult validation = server.validateCredential(credential, intent); + assertThat(validation.method()).isEqualTo("test"); + assertThat(validation.intent()).isEqualTo("charge"); + assertThat(validation.source()).isEqualTo("payer"); + assertThat(validation.details()).containsEntry("risk", "accepted"); + assertThat(intent.validations).isEqualTo(1); + assertThat(intent.broadcasts).isZero(); + + Receipt receipt = server.broadcastCredential(credential, intent); + assertThat(receipt.reference()).isEqualTo("split-ref"); + assertThat(intent.validations).isEqualTo(2); + assertThat(intent.broadcasts).isEqualTo(1); + } + + @Test + void broadcastCredentialSupportsLegacyVerifyOnlyIntents() { + MppHandler server = Mpp.create(new TestMethod(), "api.example.com", "super-secret"); + ChargeIntent intent = new ChargeIntent(); + Challenge challenge = ((VerifyResult.Challenged) server.charge( + null, intent, "10.000000", "USDC", "0xRecipient" + )).challenge(); + + Receipt receipt = server.broadcastCredential( + new Credential(challenge.toEcho(), Map.of("sig", "x"), null), intent + ); + + assertThat(receipt.reference()).isEqualTo("tx-ref-12345"); + } + + @Test + void anOverriddenVerifyControlsChargeAndBroadcastPaths() { + MppHandler server = Mpp.create( + new HybridSplitMethod(), "api.example.com", "super-secret" + ); + HybridSplitIntent intent = new HybridSplitIntent(); + Challenge challenge = ((VerifyResult.Challenged) server.charge( + null, intent, "10.000000", "USDC", "0xRecipient" + )).challenge(); + Credential credential = new Credential( + challenge.toEcho(), Map.of("sig", "x"), null + ); + + Receipt standalone = server.broadcastCredential(credential, intent); + VerifyResult combined = server.charge( + credential.toAuthorization(), intent, "10.000000", "USDC", "0xRecipient", + null, null, challenge.expires() + ); + + assertThat(standalone.reference()).isEqualTo("legacy-ref"); + assertThat(combined).isInstanceOf(VerifyResult.Verified.class); + assertThat(((VerifyResult.Verified) combined).receipt().reference()).isEqualTo("legacy-ref"); + assertThat(intent.legacyVerifications).isEqualTo(2); + assertThat(intent.validations).isZero(); + assertThat(intent.broadcasts).isZero(); + } + + @Test + void specOpaqueCredentialPassesRouteBindingAndSplitLifecycle() { + String secret = "super-secret"; + SplitChargeIntent intent = new SplitChargeIntent(); + MppHandler server = Mpp.create(new VerifiableMethod(), "api.example.com", secret); + Map meta = Map.of("route", "/api/photo"); + String expires = "2099-01-01T00:00:00Z"; + Challenge challenge = ((VerifyResult.Challenged) server.charge( + null, intent, "10.000000", "USDC", "0xRecipient", + null, meta, expires + )).challenge(); + Map echo = new LinkedHashMap<>(); + echo.put("id", challenge.id()); + echo.put("realm", challenge.realm()); + echo.put("method", challenge.method()); + echo.put("intent", challenge.intent()); + echo.put("request", challenge.requestB64()); + echo.put("expires", challenge.expires()); + echo.put("opaque", challenge.opaqueRaw()); + String authorization = "Payment " + ChallengeId.b64urlEncode(Json.compact(Map.of( + "challenge", echo, + "payload", Map.of("sig", "x") + ))); + + VerifyResult result = server.charge( + authorization, intent, "10.000000", "USDC", "0xRecipient", + null, meta, expires + ); + + assertThat(result).isInstanceOf(VerifyResult.Verified.class); + assertThat(intent.validations).isEqualTo(1); + assertThat(intent.broadcasts).isEqualTo(1); + } + + @Test + void differentOpaqueMetadataForcesAChallengeBeforeLifecycleHooks() { + SplitChargeIntent intent = new SplitChargeIntent(); + MppHandler server = Mpp.create(new VerifiableMethod(), "api.example.com", "super-secret"); + String expires = "2099-01-01T00:00:00Z"; + Challenge challenge = ((VerifyResult.Challenged) server.charge( + null, intent, "10.000000", "USDC", "0xRecipient", + null, Map.of("route", "/api/photo"), expires + )).challenge(); + + VerifyResult result = server.charge( + new Credential(challenge.toEcho(), Map.of("sig", "x"), null).toAuthorization(), + intent, "10.000000", "USDC", "0xRecipient", + null, Map.of("route", "/api/video"), expires + ); + + assertThat(result).isInstanceOf(VerifyResult.Challenged.class); + assertThat(intent.validations).isZero(); + assertThat(intent.broadcasts).isZero(); + } + + @Test + void standaloneStringApiNormalizesMalformedEchoRequest() { + MppHandler server = Mpp.create(new VerifiableMethod(), "api.example.com", "super-secret"); + SplitChargeIntent intent = new SplitChargeIntent(); + Map challenge = new LinkedHashMap<>(); + challenge.put("id", "untrusted"); + challenge.put("realm", "api.example.com"); + challenge.put("method", "test"); + challenge.put("intent", "charge"); + challenge.put("request", "not-base64url!"); + challenge.put("expires", "2099-01-01T00:00:00Z"); + String authorization = "Payment " + ChallengeId.b64urlEncode(Json.compact(Map.of( + "challenge", challenge, + "payload", Map.of("sig", "x") + ))); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> server.validateCredential(authorization, intent) + ).isInstanceOf(com.stripe.mpp.error.MalformedCredentialException.class) + .hasMessageContaining("base64url"); + } + + @Test + void standaloneLifecycleRejectsTamperedChallenge() { + MppHandler server = Mpp.create(new VerifiableMethod(), "api.example.com", "super-secret"); + SplitChargeIntent intent = new SplitChargeIntent(); + Challenge challenge = ((VerifyResult.Challenged) server.charge( + null, intent, "10.000000", "USDC", "0xRecipient" + )).challenge(); + ChallengeEcho echo = challenge.toEcho(); + Credential credential = new Credential( + new ChallengeEcho( + "tampered", echo.realm(), echo.method(), echo.intent(), echo.request(), + echo.expires(), echo.digest(), echo.opaque() + ), + Map.of("sig", "x"), + null + ); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> server.validateCredential(credential, intent) + ).isInstanceOf(InvalidChallengeException.class); + assertThat(intent.validations).isZero(); + assertThat(intent.broadcasts).isZero(); + } + + @Test + void standaloneLifecycleRejectsMissingEchoRequest() { + String secret = "super-secret"; + String realm = "api.example.com"; + String expires = "2099-01-01T00:00:00Z"; + MppHandler server = Mpp.create(new VerifiableMethod(), realm, secret); + SplitChargeIntent intent = new SplitChargeIntent(); + String id = ChallengeId.generateWithOpaque( + secret, realm, "test", "charge", Map.of(), expires, null, null + ); + + for (String request : new String[] { null, "" }) { + Credential credential = new Credential( + new ChallengeEcho( + id, realm, "test", "charge", request, expires, null, null + ), + Map.of("sig", "x"), + null + ); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> server.validateCredential(credential, intent) + ).isInstanceOf(InvalidChallengeException.class) + .hasMessageContaining("missing required challenge fields"); + } + assertThat(intent.validations).isZero(); + assertThat(intent.broadcasts).isZero(); + } + @Test void tamperedChallengeIdForcesNewChallenge() { MppHandler server = Mpp.create(new TestMethod(), "api.example.com", "super-secret"); diff --git a/src/test/java/com/stripe/mpp/ParsingTest.java b/src/test/java/com/stripe/mpp/ParsingTest.java index a59c68a..a22f8aa 100644 --- a/src/test/java/com/stripe/mpp/ParsingTest.java +++ b/src/test/java/com/stripe/mpp/ParsingTest.java @@ -3,6 +3,7 @@ import org.junit.jupiter.api.Test; import java.time.Instant; +import java.util.LinkedHashMap; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; @@ -137,6 +138,19 @@ void challengeRejectsOversizedRequestParameter() { .hasMessageContaining("Request parameter exceeds"); } + @Test + void challengePreservesOpaqueThatIsNotJson() { + String opaque = ChallengeId.b64urlEncode("xy wrong"); + String header = "Payment id=\"abc\", realm=\"api\", method=\"tempo\", " + + "intent=\"charge\", request=\"e30\", opaque=\"" + opaque + "\""; + + Challenge challenge = Challenge.fromWwwAuthenticate(header).get(0); + + assertThat(challenge.opaqueRaw()).isEqualTo(opaque); + assertThat(challenge.opaque()).isNull(); + assertThat(challenge.toWwwAuthenticate()).isEqualTo(header); + } + // --- Credential (Authorization) --- @Test @@ -201,6 +215,82 @@ void credentialParseRejectsNonStringMethod() { .hasMessageContaining("method"); } + @Test + void credentialPreservesSpecOpaqueStringAndIgnoresInjectedMeta() { + String opaque = ChallengeId.b64urlEncode("{\"route\":\"/api/photo\"}"); + Map challenge = new LinkedHashMap<>(); + challenge.put("id", "abc"); + challenge.put("realm", "api"); + challenge.put("method", "tempo"); + challenge.put("intent", "charge"); + challenge.put("request", "e30"); + challenge.put("opaque", opaque); + challenge.put("meta", Map.of("route", "/forged")); + String header = "Payment " + ChallengeId.b64urlEncode(Json.compact(Map.of( + "challenge", challenge, + "payload", Map.of("type", "transaction", "signature", "0x1234") + ))); + + Credential credential = Credential.fromAuthorization(header); + + assertThat(credential.challenge().opaqueRaw()).isEqualTo(opaque); + assertThat(credential.challenge().opaque()).containsEntry("route", "/api/photo"); + @SuppressWarnings("unchecked") + Map roundTrip = (Map) Parsing.b64Decode( + credential.toAuthorization().substring("Payment ".length()) + ); + @SuppressWarnings("unchecked") + Map roundTripChallenge = + (Map) roundTrip.get("challenge"); + assertThat(roundTripChallenge.get("opaque")).isEqualTo(opaque); + assertThat(roundTripChallenge).doesNotContainKey("meta"); + } + + @Test + void credentialNormalizesLegacyObjectOpaqueToWireString() { + Map legacyOpaque = Map.of("route", "/legacy"); + String header = "Payment " + ChallengeId.b64urlEncode(Json.compact(Map.of( + "challenge", Map.of( + "id", "abc", + "realm", "api", + "method", "tempo", + "intent", "charge", + "request", "e30", + "opaque", legacyOpaque + ), + "payload", Map.of() + ))); + + Credential credential = Credential.fromAuthorization(header); + + String expected = ChallengeId.b64urlEncode(Json.compact(legacyOpaque)); + assertThat(credential.challenge().opaqueRaw()).isEqualTo(expected); + assertThat(credential.challenge().opaque()).isEqualTo(legacyOpaque); + @SuppressWarnings("unchecked") + Map roundTrip = (Map) Parsing.b64Decode( + credential.toAuthorization().substring("Payment ".length()) + ); + @SuppressWarnings("unchecked") + Map roundTripChallenge = + (Map) roundTrip.get("challenge"); + assertThat(roundTripChallenge.get("opaque")).isEqualTo(expected); + } + + @Test + void credentialRejectsInvalidOpaqueType() { + String header = "Payment " + ChallengeId.b64urlEncode(Json.compact(Map.of( + "challenge", Map.of( + "id", "abc", "realm", "api", "method", "tempo", "intent", "charge", + "request", "e30", "opaque", true + ), + "payload", Map.of() + ))); + + assertThatThrownBy(() -> Credential.fromAuthorization(header)) + .isInstanceOf(com.stripe.mpp.error.ParseException.class) + .hasMessageContaining("opaque"); + } + // --- Receipt (Payment-Receipt) --- @Test diff --git a/src/test/java/com/stripe/mpp/ValueTypesTest.java b/src/test/java/com/stripe/mpp/ValueTypesTest.java index 52ceeb8..13bdf58 100644 --- a/src/test/java/com/stripe/mpp/ValueTypesTest.java +++ b/src/test/java/com/stripe/mpp/ValueTypesTest.java @@ -1,6 +1,7 @@ package com.stripe.mpp; import com.stripe.mpp.server.VerifyResult; +import com.stripe.mpp.server.ValidationResult; import org.junit.jupiter.api.Test; import java.time.Instant; @@ -49,6 +50,9 @@ void challengePreservesRecordStyleApiAndValueSemantics() { assertThat(challenge.expires()).isEqualTo("2099-01-01T00:00:00Z"); assertThat(challenge.description()).isEqualTo("description"); assertThat(challenge.opaque()).isSameAs(opaque); + assertThat(challenge.opaqueRaw()).isEqualTo( + ChallengeId.b64urlEncode(Json.compact(opaque)) + ); assertThat(challenge).isEqualTo(equivalent).hasSameHashCodeAs(equivalent); } @@ -84,9 +88,25 @@ void challengeEchoPreservesRecordStyleApiAndValueSemantics() { assertThat(echo.expires()).isEqualTo("2099-01-01T00:00:00Z"); assertThat(echo.digest()).isEqualTo("sha-256=abc"); assertThat(echo.opaque()).isSameAs(opaque); + assertThat(echo.opaqueRaw()).isEqualTo( + ChallengeId.b64urlEncode(Json.compact(opaque)) + ); assertThat(echo).isEqualTo(equivalent).hasSameHashCodeAs(equivalent); } + @Test + void challengeEchoCanPreserveRawOpaqueWithoutDecodedMetadata() { + String opaque = ChallengeId.b64urlEncode("xy wrong"); + + ChallengeEcho echo = ChallengeEcho.fromWire( + "id", "realm", "tempo", "charge", "request", null, null, + opaque, Parsing.decodeOpaque(opaque) + ); + + assertThat(echo.opaqueRaw()).isEqualTo(opaque); + assertThat(echo.opaque()).isNull(); + } + @Test void credentialPreservesRecordStyleApiAndValueSemantics() { ChallengeEcho echo = new ChallengeEcho("id", "realm", "tempo", "charge", "request", null, null, null); @@ -134,4 +154,27 @@ void verifyResultsPreserveRecordStyleApiAndValueSemantics() { assertThat(verified.receipt()).isSameAs(receipt); assertThat(verified).isEqualTo(equivalentVerified).hasSameHashCodeAs(equivalentVerified); } + + @Test + void validationResultPreservesRecordStyleApiAndValueSemantics() { + ChallengeEcho echo = new ChallengeEcho( + "id", "realm", "tempo", "charge", "request", null, null, null + ); + Credential credential = new Credential(echo, Map.of("hash", "0xabc"), "payer"); + ValidationResult result = new ValidationResult( + credential, Map.of("amount", "1"), Map.of("mode", "push") + ); + ValidationResult equivalent = new ValidationResult( + credential, Map.of("amount", "1"), Map.of("mode", "push") + ); + + assertThat(result.credential()).isSameAs(credential); + assertThat(result.challenge()).isSameAs(echo); + assertThat(result.request()).containsEntry("amount", "1"); + assertThat(result.details()).containsEntry("mode", "push"); + assertThat(result.method()).isEqualTo("tempo"); + assertThat(result.intent()).isEqualTo("charge"); + assertThat(result.source()).isEqualTo("payer"); + assertThat(result).isEqualTo(equivalent).hasSameHashCodeAs(equivalent); + } } diff --git a/src/test/java/com/stripe/mpp/methods/tempo/TempoRelayTest.java b/src/test/java/com/stripe/mpp/methods/tempo/TempoRelayTest.java new file mode 100644 index 0000000..38c0026 --- /dev/null +++ b/src/test/java/com/stripe/mpp/methods/tempo/TempoRelayTest.java @@ -0,0 +1,380 @@ +package com.stripe.mpp.methods.tempo; + +import com.stripe.mpp.Challenge; +import com.stripe.mpp.ChallengeEcho; +import com.stripe.mpp.ChallengeId; +import com.stripe.mpp.Credential; +import com.stripe.mpp.Json; +import com.stripe.mpp.Mpp; +import com.stripe.mpp.Receipt; +import com.stripe.mpp.error.PaymentExpiredException; +import com.stripe.mpp.error.VerificationFailedException; +import com.stripe.mpp.server.MppHandler; +import com.stripe.mpp.server.ValidationResult; +import com.stripe.mpp.server.VerifyResult; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class TempoRelayTest { + private static final Map REQUEST = Map.of( + "amount", "10000", + "currency", TempoDefaults.TESTNET_PATH_USD, + "methodDetails", Map.of("chainId", TempoDefaults.TESTNET_CHAIN_ID), + "recipient", "0xabcdef1234567890abcdef1234567890abcdef12" + ); + private static final ChallengeEcho ECHO = new ChallengeEcho( + "challenge_123", + "api.example.com", + "tempo", + "charge", + ChallengeId.b64urlEncode(Json.compact(REQUEST)), + "2099-01-01T00:00:00Z", + null, + null + ); + private static final Credential CREDENTIAL = new Credential( + ECHO, + Map.of("signature", "0x1234", "type", "transaction"), + "did:pkh:eip155:42431:0x123" + ); + + @Test + void validationIsNonMutatingAndPreservesTheConfiguredBasePath() throws Exception { + try (RelayServer server = new RelayServer()) { + server.respond(call -> Reply.json(200, Map.of("success", true))); + TempoChargeIntent intent = intent(server); + + ValidationResult result = intent.validate(CREDENTIAL, Map.of("untrusted", true)); + + assertThat(result.credential()).isSameAs(CREDENTIAL); + assertThat(result.request()).isEqualTo(REQUEST); + assertThat(server.calls).hasSize(1); + Call call = server.calls.get(0); + assertThat(call.path).isEqualTo("/relay/v1/mpp/validate"); + assertThat(call.apiKey).isEqualTo("test-api-key"); + assertThat(call.idempotencyKey).isNull(); + assertThat(call.body).containsEntry("source", CREDENTIAL.source()); + assertThat(challenge(call).get("request")).isEqualTo(REQUEST); + } + } + + @Test + void omitsAnEmptyCredentialSource() throws Exception { + try (RelayServer server = new RelayServer()) { + server.respond(call -> Reply.json(200, Map.of("success", true))); + Credential credential = new Credential(ECHO, CREDENTIAL.payload(), ""); + + intent(server).validate(credential, REQUEST); + + assertThat(server.calls.get(0).body).doesNotContainKey("source"); + } + } + + @Test + void malformedChallengeRequestFailsWithoutCallingTheRelay() throws Exception { + try (RelayServer server = new RelayServer()) { + TempoChargeIntent intent = intent(server); + Credential credential = new Credential( + new ChallengeEcho( + "id", "api.example.com", "tempo", "charge", "not-base64url!", + "2099-01-01T00:00:00Z", null, null + ), + CREDENTIAL.payload(), + CREDENTIAL.source() + ); + + assertThatThrownBy(() -> intent.validate(credential, REQUEST)) + .isInstanceOf(VerificationFailedException.class) + .hasMessageContaining("invalid challenge request"); + assertThat(server.calls).isEmpty(); + } + } + + @Test + void forwardsTheExactSpecOpaqueValue() throws Exception { + try (RelayServer server = new RelayServer()) { + server.respond(call -> Reply.json(200, Map.of("success", true))); + String opaque = ChallengeId.b64urlEncode("xy wrong"); + String header = "Payment id=\"challenge_opaque\", realm=\"api.example.com\", " + + "method=\"tempo\", intent=\"charge\", request=\"" + + ChallengeId.b64urlEncode(Json.compact(REQUEST)) + + "\", expires=\"2099-01-01T00:00:00Z\", opaque=\"" + opaque + "\""; + Challenge challenge = Challenge.fromWwwAuthenticate(header).get(0); + Credential credential = new Credential( + challenge.toEcho(), CREDENTIAL.payload(), CREDENTIAL.source() + ); + + intent(server).validate(credential, REQUEST); + + assertThat(challenge(server.calls.get(0)).get("opaque")).isEqualTo(opaque); + } + } + + @Test + void verifyValidatesThenBroadcastsAndReturnsTheRelayReceipt() throws Exception { + try (RelayServer server = new RelayServer()) { + server.respond(call -> call.path.endsWith("/validate") + ? Reply.json(200, Map.of("success", true)) + : successReceipt()); + TempoChargeIntent intent = intent(server); + + Receipt receipt = intent.verify(CREDENTIAL, REQUEST); + + assertThat(server.paths()).containsExactly( + "/relay/v1/mpp/validate", + "/relay/v1/mpp/broadcast" + ); + assertThat(receipt.status()).isEqualTo("success"); + assertThat(receipt.method()).isEqualTo("tempo"); + assertThat(receipt.reference()).isEqualTo("0xabc"); + assertThat(receipt.externalId()).isEqualTo("order_123"); + assertThat(receipt.timestamp()).isEqualTo(Instant.parse("2026-07-22T00:00:00Z")); + } + } + + @Test + void handlerAcceptsTheRelayIntentAndUsesItsSplitLifecycle() throws Exception { + try (RelayServer server = new RelayServer()) { + server.respond(call -> call.path.endsWith("/validate") + ? Reply.json(200, Map.of("success", true)) + : successReceipt()); + TempoRelay relay = TempoRelay.builder("test-api-key") + .apiBaseUrl(server.baseUrl()) + .build(); + TempoMethod method = TempoMethod.of().testnet().relay(relay).build(); + TempoChargeIntent intent = method.chargeIntent(); + MppHandler handler = Mpp.create(method, "api.example.com", "secret"); + Challenge challenge = ((VerifyResult.Challenged) handler.charge( + null, intent, "0.010000", TempoDefaults.TESTNET_PATH_USD, + "0xabcdef1234567890abcdef1234567890abcdef12" + )).challenge(); + Credential credential = new Credential( + challenge.toEcho(), CREDENTIAL.payload(), CREDENTIAL.source() + ); + + VerifyResult result = handler.charge( + credential.toAuthorization(), intent, "0.010000", + TempoDefaults.TESTNET_PATH_USD, + "0xabcdef1234567890abcdef1234567890abcdef12" + ); + + assertThat(result).isInstanceOf(VerifyResult.Verified.class); + assertThat(server.paths()).containsExactly( + "/relay/v1/mpp/validate", + "/relay/v1/mpp/broadcast" + ); + } + } + + @Test + void transactionBroadcastUsesAStableTransactionHashIdempotencyKey() throws Exception { + try (RelayServer server = new RelayServer()) { + server.respond(call -> successReceipt()); + TempoChargeIntent intent = intent(server); + + intent.broadcast(CREDENTIAL, REQUEST); + intent.broadcast(CREDENTIAL, REQUEST); + + assertThat(server.calls).hasSize(2); + String first = server.calls.get(0).idempotencyKey; + assertThat(first).isEqualTo( + "mpp_java_0x56570de287d73cd1cb6092bb8fdee6173974955fdef345ae579ee9f475ea7432" + ); + assertThat(server.calls.get(1).idempotencyKey).isEqualTo(first); + } + } + + @Test + void proofBroadcastUsesTheCrossSdkCanonicalCredentialHash() throws Exception { + try (RelayServer server = new RelayServer()) { + server.respond(call -> successReceipt()); + TempoChargeIntent intent = intent(server); + Credential proof = new Credential( + ECHO, + Map.of("proof", "proof_123", "type", "proof"), + CREDENTIAL.source() + ); + + intent.broadcast(proof, REQUEST); + + assertThat(server.calls.get(0).idempotencyKey).isEqualTo( + "mpp_java_0x2f1e58d9f7fa16847ec115a9d6262177de8ab45dd184b21891aa42d88d8e4770" + ); + } + } + + @Test + void mapsRelayErrorsWithoutExposingPrivateMessages() throws Exception { + try (RelayServer server = new RelayServer()) { + TempoChargeIntent intent = intent(server); + server.respond(call -> Reply.json(200, Map.of( + "error", Map.of("code", "temporarily_unavailable", "message", "private detail"), + "success", false + ))); + + assertThatThrownBy(() -> intent.validate(CREDENTIAL, REQUEST)) + .isInstanceOfSatisfying(VerificationFailedException.class, error -> { + assertThat(error.getMessage()).isEqualTo("Payment verification failed."); + assertThat(error.getMessage()).doesNotContain("private detail"); + assertThat(error.getDetails()).containsEntry("code", "temporarily_unavailable"); + assertThat(error.getDetails()).containsEntry("retry", "same_credential"); + assertThat(error.toProblemDetails()).containsEntry("details", error.getDetails()); + }); + + server.respond(call -> Reply.json(200, Map.of( + "error", Map.of("code", "expired", "message", "private detail"), + "success", false + ))); + assertThatThrownBy(() -> intent.validate(CREDENTIAL, REQUEST)) + .isInstanceOf(PaymentExpiredException.class) + .hasMessage("Payment has expired."); + + server.respond(call -> Reply.json(200, Map.of( + "error", Map.of("code", "insufficient_funds", "message", "private detail"), + "success", false + ))); + assertThatThrownBy(() -> intent.validate(CREDENTIAL, REQUEST)) + .isInstanceOfSatisfying(VerificationFailedException.class, error -> { + assertThat(error.getMessage()).isEqualTo("Payment verification failed."); + assertThat(error.getDetails()).containsEntry("code", "insufficient_funds"); + }); + + server.respond(call -> Reply.json(403, Map.of( + "error", Map.of("code", "insufficient_funds", "message", "private detail") + ))); + assertThatThrownBy(() -> intent.validate(CREDENTIAL, REQUEST)) + .isInstanceOfSatisfying(VerificationFailedException.class, error -> { + assertThat(error.getMessage()).isEqualTo("Payment verification failed."); + assertThat(error.getDetails()).isEmpty(); + }); + } + } + + @Test + void rejectsMalformedOrMismatchedRelayReceipts() throws Exception { + try (RelayServer server = new RelayServer()) { + TempoChargeIntent intent = intent(server); + server.respond(call -> Reply.json(200, Map.of( + "receipt", Map.of( + "method", "stripe", + "reference", "0xabc", + "timestamp", "2026-07-22T00:00:00Z" + ), + "success", true + ))); + + assertThatThrownBy(() -> intent.broadcast(CREDENTIAL, REQUEST)) + .isInstanceOf(VerificationFailedException.class) + .hasMessage("Payment verification failed."); + } + } + + private static TempoChargeIntent intent(RelayServer server) { + TempoRelay relay = TempoRelay.builder("test-api-key") + .apiBaseUrl(server.baseUrl()) + .build(); + return TempoMethod.of().testnet().relay(relay).build().chargeIntent(); + } + + @SuppressWarnings("unchecked") + private static Map challenge(Call call) { + return (Map) call.body.get("challenge"); + } + + private static Reply successReceipt() { + return Reply.json(200, Map.of( + "receipt", Map.of( + "externalId", "order_123", + "method", "tempo", + "reference", "0xabc", + "timestamp", "2026-07-22T00:00:00Z" + ), + "success", true + )); + } + + private static final class Call { + final String path; + final String apiKey; + final String idempotencyKey; + final Map body; + + Call(HttpExchange exchange) throws IOException { + this.path = exchange.getRequestURI().getPath(); + this.apiKey = exchange.getRequestHeaders().getFirst("tempo-api-key"); + this.idempotencyKey = exchange.getRequestHeaders().getFirst("idempotency-key"); + String json = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + this.body = Json.parseMap(json); + } + } + + private static final class Reply { + final int status; + final String body; + + Reply(int status, String body) { + this.status = status; + this.body = body; + } + + static Reply json(int status, Map body) { + return new Reply(status, Json.compact(body)); + } + } + + private static final class RelayServer implements AutoCloseable { + final HttpServer server; + final List calls = new ArrayList<>(); + volatile Function responder; + + RelayServer() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", this::handle); + server.start(); + } + + void respond(Function responder) { + this.responder = responder; + } + + URI baseUrl() { + return URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/relay"); + } + + List paths() { + List paths = new ArrayList<>(); + for (Call call : calls) paths.add(call.path); + return paths; + } + + void handle(HttpExchange exchange) throws IOException { + Call call = new Call(exchange); + calls.add(call); + Reply reply = responder.apply(call); + byte[] body = reply.body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(reply.status, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + } + + @Override + public void close() { + server.stop(0); + } + } +}