Skip to content

Commit f8682fc

Browse files
refactor(webhooks): switch to verifyAndParse* API (CHA-3071)
Replaces verifyAndDecodeWebhook / decompressWebhookBody on App with the cross-SDK contract documented at https://getstream.io/chat/docs/node/webhooks_overview/. Static helpers on App: Primitives: ungzipPayload - gzip magic-byte detection + inflate decodeSqsPayload - base64 then ungzip-if-magic (String -> byte[]) decodeSnsPayload - alias for decodeSqsPayload verifySignature - constant-time HMAC-SHA256 comparison (parameter order matches the cross-SDK spec) parseEvent - JSON -> typed Event via Jackson Composite (return Event): verifyAndParseWebhook verifyAndParseSqs verifyAndParseSns Each composite has a singleton-secret overload that pulls the API secret from Client.getInstance(), so handler code stays terse. The composite functions auto-detect compression from body bytes, keeping the same handler correct whether or not Stream is currently compressing payloads, and behind middleware that auto-decompresses. Backward compatibility: * App.verifyWebhook(body, signature) -> bool kept unchanged. * App.verifyWebhookSignature(...) overloads kept; they now delegate to verifySignature internally. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 0c69036 commit f8682fc

2 files changed

Lines changed: 256 additions & 276 deletions

File tree

src/main/java/io/getstream/chat/java/models/App.java

Lines changed: 120 additions & 150 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@
3434
import java.util.Base64;
3535
import java.util.Date;
3636
import java.util.List;
37-
import java.util.Locale;
3837
import java.util.Map;
3938
import java.util.zip.GZIPInputStream;
4039
import javax.crypto.Mac;
@@ -1447,7 +1446,11 @@ public static DeletePushProviderRequest deletePushProvider(
14471446
}
14481447

14491448
/**
1450-
* Validates if hmac signature is correct for message body.
1449+
* Validates if hmac signature is correct for the message body.
1450+
*
1451+
* <p>Kept for backward compatibility. New integrations should call {@link
1452+
* #verifyAndParseWebhook(byte[], String)} (or the SQS / SNS variants), which also handle gzip
1453+
* payload compression.
14511454
*
14521455
* @param body raw body from http request converted to a string.
14531456
* @param signature the signature provided in X-Signature header
@@ -1458,7 +1461,8 @@ public boolean verifyWebhook(@NotNull String body, @NotNull String signature) {
14581461
}
14591462

14601463
/**
1461-
* Validates if hmac signature is correct for message body.
1464+
* Validates if hmac signature is correct for message body. Backward-compatible alias for {@link
1465+
* #verifySignature(byte[], String, String)}.
14621466
*
14631467
* @param apiSecret the secret key
14641468
* @param body raw body from http request converted to a string.
@@ -1467,202 +1471,173 @@ public boolean verifyWebhook(@NotNull String body, @NotNull String signature) {
14671471
*/
14681472
public static boolean verifyWebhookSignature(
14691473
@NotNull String apiSecret, @NotNull String body, @NotNull String signature) {
1470-
return verifyWebhookSignature(apiSecret, body.getBytes(StandardCharsets.UTF_8), signature);
1474+
return verifySignature(body.getBytes(StandardCharsets.UTF_8), signature, apiSecret);
14711475
}
14721476

14731477
/**
1474-
* Validates if hmac signature is correct for message body.
1478+
* Validates if hmac signature is correct for the message body using the singleton client's API
1479+
* secret.
14751480
*
14761481
* @param body the message body
14771482
* @param signature the signature provided in X-Signature header
14781483
* @return true if the signature is valid
14791484
*/
14801485
public static boolean verifyWebhookSignature(@NotNull String body, @NotNull String signature) {
1481-
String apiSecret = Client.getInstance().getApiSecret();
1482-
return verifyWebhookSignature(apiSecret, body, signature);
1486+
return verifySignature(
1487+
body.getBytes(StandardCharsets.UTF_8), signature, Client.getInstance().getApiSecret());
14831488
}
14841489

14851490
/**
1486-
* Validates if hmac signature is correct for the raw (uncompressed) body bytes.
1491+
* Constant-time HMAC-SHA256 verification of {@code signature} against the digest of {@code body}
1492+
* using {@code secret} as the key.
14871493
*
1488-
* <p>Stream computes {@code X-Signature} over the uncompressed JSON, so when webhook compression
1489-
* is enabled callers must decompress the request body first (see {@link
1490-
* #decompressWebhookBody(byte[], String)}) and pass the resulting bytes here.
1494+
* <p>The signature is always computed over the <b>uncompressed</b> JSON bytes, so callers that
1495+
* decoded a gzipped or base64-wrapped payload must pass the inflated bytes here.
14911496
*
1492-
* @param apiSecret the app's API secret
1493-
* @param body the uncompressed JSON body bytes
1494-
* @param signature the signature provided in {@code X-Signature} header
1497+
* @param body the uncompressed body bytes
1498+
* @param signature the signature provided in {@code X-Signature}
1499+
* @param secret the app's API secret
14951500
* @return true if the signature matches
14961501
*/
1497-
public static boolean verifyWebhookSignature(
1498-
@NotNull String apiSecret, @NotNull byte[] body, @NotNull String signature) {
1502+
public static boolean verifySignature(
1503+
@NotNull byte[] body, @NotNull String signature, @NotNull String secret) {
14991504
try {
1500-
Key sk = new SecretKeySpec(apiSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
1505+
Key sk = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
15011506
Mac mac = Mac.getInstance(sk.getAlgorithm());
15021507
mac.init(sk);
15031508
final byte[] hmac = mac.doFinal(body);
1504-
return constantTimeEquals(bytesToHex(hmac), signature);
1509+
return MessageDigest.isEqual(
1510+
bytesToHex(hmac).getBytes(StandardCharsets.UTF_8),
1511+
signature.getBytes(StandardCharsets.UTF_8));
15051512
} catch (NoSuchAlgorithmException e) {
15061513
throw new IllegalStateException("Should not happen. Could not find HmacSHA256", e);
15071514
} catch (InvalidKeyException e) {
15081515
throw new IllegalStateException("error building signature, invalid key", e);
15091516
}
15101517
}
15111518

1512-
/**
1513-
* Decompresses an outbound webhook body. Equivalent to {@link #decompressWebhookBody(byte[],
1514-
* String, String)} with {@code payloadEncoding == null}.
1515-
*/
1516-
public static byte[] decompressWebhookBody(
1517-
@NotNull byte[] body, @Nullable String contentEncoding) {
1518-
return decompressWebhookBody(body, contentEncoding, null);
1519-
}
1519+
private static final byte[] GZIP_MAGIC = new byte[] {0x1f, (byte) 0x8b, 0x08};
15201520

15211521
/**
1522-
* Decompresses an outbound webhook body, optionally undoing a transport-level wrapper first.
1523-
*
1524-
* <p>The returned bytes are the uncompressed JSON the server signed. Decode order matches the
1525-
* inverse of how the server built the message:
1522+
* Returns {@code body} unchanged unless it starts with the gzip magic ({@code 1f 8b 08}), in
1523+
* which case the gzip stream is inflated and the decompressed bytes are returned.
15261524
*
1527-
* <ol>
1528-
* <li>If {@code payloadEncoding} is {@code "base64"}, base64-decode the body. This is the
1529-
* wrapper Stream uses for SQS / SNS firehose so the message stays valid UTF-8 over
1530-
* transport.
1531-
* <li>If {@code contentEncoding} is {@code "gzip"}, gunzip the result.
1532-
* </ol>
1533-
*
1534-
* <p>This SDK only supports {@code gzip} for compression and {@code base64} for the transport
1535-
* wrapper. Any other value (including {@code br} / {@code zstd}) raises {@link
1536-
* IllegalStateException} so callers can surface a clear error and the operator can flip the app
1537-
* back to {@code gzip} on the dashboard. {@code null} / {@code ""} for either argument is a
1538-
* no-op, which keeps the HTTP webhook path identical to before this method existed.
1539-
*
1540-
* @param body raw HTTP request body / SQS message body / SNS notification message
1541-
* @param contentEncoding value of the {@code Content-Encoding} header / message attribute
1542-
* (case-insensitive); {@code null} when absent
1543-
* @param payloadEncoding transport wrapper applied after compression (today: {@code "base64"} for
1544-
* SQS / SNS firehose, {@code null} for HTTP webhooks)
1545-
* @return uncompressed body bytes (the JSON Stream signed)
1525+
* <p>Magic-byte detection (rather than relying on a header) lets the same handler stay correct
1526+
* when middleware auto-decompresses the request before your code sees it.
15461527
*/
1547-
public static byte[] decompressWebhookBody(
1548-
@NotNull byte[] body, @Nullable String contentEncoding, @Nullable String payloadEncoding) {
1549-
byte[] working = body;
1550-
1551-
if (payloadEncoding != null) {
1552-
String pe = payloadEncoding.trim().toLowerCase(Locale.ROOT);
1553-
if (!pe.isEmpty()) {
1554-
if (!"base64".equals(pe) && !"b64".equals(pe)) {
1555-
throw new IllegalStateException(
1556-
"unsupported webhook payload_encoding: "
1557-
+ payloadEncoding
1558-
+ ". This SDK only supports base64.");
1559-
}
1560-
try {
1561-
working = Base64.getDecoder().decode(working);
1562-
} catch (IllegalArgumentException e) {
1563-
throw new IllegalStateException(
1564-
"failed to base64-decode webhook body (payload_encoding: " + payloadEncoding + ")",
1565-
e);
1566-
}
1567-
}
1568-
}
1569-
1570-
if (contentEncoding == null || contentEncoding.isEmpty()) {
1571-
return working;
1528+
public static byte[] ungzipPayload(@NotNull byte[] body) {
1529+
if (body.length < 3
1530+
|| body[0] != GZIP_MAGIC[0]
1531+
|| body[1] != GZIP_MAGIC[1]
1532+
|| body[2] != GZIP_MAGIC[2]) {
1533+
return body;
15721534
}
1573-
String encoding = contentEncoding.trim().toLowerCase(Locale.ROOT);
1574-
if (encoding.isEmpty()) {
1575-
return working;
1576-
}
1577-
if (!"gzip".equals(encoding)) {
1578-
throw new IllegalStateException(
1579-
"unsupported webhook Content-Encoding: "
1580-
+ contentEncoding
1581-
+ ". This SDK only supports gzip; set webhook_compression_algorithm to \"gzip\" on"
1582-
+ " the app config.");
1583-
}
1584-
try (GZIPInputStream in = new GZIPInputStream(new ByteArrayInputStream(working))) {
1535+
try (GZIPInputStream in = new GZIPInputStream(new ByteArrayInputStream(body))) {
15851536
return readAll(in);
15861537
} catch (IOException e) {
1587-
throw new IllegalStateException(
1588-
"failed to decompress webhook body (Content-Encoding: " + contentEncoding + ")", e);
1538+
throw new IllegalStateException("failed to decompress gzip payload", e);
15891539
}
15901540
}
15911541

15921542
/**
1593-
* Convenience overload of {@link #verifyAndDecodeWebhook(String, byte[], String, String, String)}
1594-
* for HTTP webhooks (no transport wrapper).
1543+
* Reverses the SQS firehose envelope: the message {@code Body} is base64-decoded and, when the
1544+
* result begins with the gzip magic, it is gzip-decompressed. The same call works whether or not
1545+
* Stream is currently compressing payloads.
1546+
*
1547+
* @param body the SQS message {@code Body}
1548+
* @return the raw JSON bytes Stream signed
15951549
*/
1596-
public static byte[] verifyAndDecodeWebhook(
1597-
@NotNull String apiSecret,
1598-
@NotNull byte[] body,
1599-
@NotNull String signature,
1600-
@Nullable String contentEncoding) {
1601-
return verifyAndDecodeWebhook(apiSecret, body, signature, contentEncoding, null);
1550+
public static byte[] decodeSqsPayload(@NotNull String body) {
1551+
byte[] decoded;
1552+
try {
1553+
decoded = Base64.getDecoder().decode(body);
1554+
} catch (IllegalArgumentException e) {
1555+
throw new IllegalStateException("failed to base64-decode payload", e);
1556+
}
1557+
return ungzipPayload(decoded);
16021558
}
16031559

16041560
/**
1605-
* Decompresses (when {@code Content-Encoding} / {@code payload_encoding} are set) and verifies
1606-
* the HMAC signature of an outbound Stream message, returning the raw JSON bytes when the
1607-
* signature matches.
1608-
*
1609-
* <p>This is the recommended entry point for handlers, regardless of transport:
1610-
*
1611-
* <ul>
1612-
* <li><b>HTTP webhooks</b>: {@code body} is the request body, {@code signature} comes from
1613-
* {@code X-Signature}, {@code contentEncoding} from {@code Content-Encoding}, {@code
1614-
* payloadEncoding} is {@code null}.
1615-
* <li><b>SQS / SNS firehose</b>: {@code body} is the SQS {@code Body} or SNS {@code Message},
1616-
* {@code signature} / {@code contentEncoding} / {@code payloadEncoding} come from the
1617-
* corresponding message attributes.
1618-
* </ul>
1619-
*
1620-
* The signature is always computed over the innermost (uncompressed, base64-decoded) JSON, so the
1621-
* verification rule is invariant across transports.
1561+
* Byte-for-byte identical to {@link #decodeSqsPayload(String)}; exposed under both names so call
1562+
* sites read intent.
1563+
*/
1564+
public static byte[] decodeSnsPayload(@NotNull String message) {
1565+
return decodeSqsPayload(message);
1566+
}
1567+
1568+
/**
1569+
* Parse a JSON-encoded webhook event into a typed {@link Event}. Unknown event types still parse
1570+
* successfully because {@link Event#getType()} is a free-form string.
16221571
*
1623-
* @param apiSecret the app's API secret
1624-
* @param body raw transport bytes
1625-
* @param signature value of the {@code X-Signature} header / message attribute
1626-
* @param contentEncoding compression applied before transport ({@code "gzip"} or {@code null})
1627-
* @param payloadEncoding transport wrapper applied after compression ({@code "base64"} or {@code
1628-
* null})
1629-
* @return the uncompressed JSON body bytes
1630-
* @throws SecurityException if the signature does not match
1572+
* @throws IllegalStateException when the bytes are not valid JSON
16311573
*/
1632-
public static byte[] verifyAndDecodeWebhook(
1633-
@NotNull String apiSecret,
1634-
@NotNull byte[] body,
1635-
@NotNull String signature,
1636-
@Nullable String contentEncoding,
1637-
@Nullable String payloadEncoding) {
1638-
byte[] decompressed = decompressWebhookBody(body, contentEncoding, payloadEncoding);
1639-
if (!verifyWebhookSignature(apiSecret, decompressed, signature)) {
1574+
public static @NotNull Event parseEvent(@NotNull byte[] payload) {
1575+
try {
1576+
return new com.fasterxml.jackson.databind.ObjectMapper().readValue(payload, Event.class);
1577+
} catch (IOException e) {
1578+
throw new IllegalStateException("failed to parse webhook event", e);
1579+
}
1580+
}
1581+
1582+
private static @NotNull Event verifyAndParseInternal(
1583+
@NotNull byte[] payload, @NotNull String signature, @NotNull String secret) {
1584+
if (!verifySignature(payload, signature, secret)) {
16401585
throw new SecurityException("invalid webhook signature");
16411586
}
1642-
return decompressed;
1587+
return parseEvent(payload);
1588+
}
1589+
1590+
/**
1591+
* Decompresses {@code body} when gzipped, verifies the HMAC {@code signature}, and returns the
1592+
* parsed {@link Event}. Works for HTTP webhooks regardless of whether payload compression is
1593+
* enabled.
1594+
*
1595+
* @param body raw HTTP request body bytes Stream signed
1596+
* @param signature value of the {@code X-Signature} header
1597+
* @param secret the app's API secret
1598+
* @return the parsed event
1599+
* @throws SecurityException when the signature does not match
1600+
* @throws IllegalStateException when the gzip envelope is malformed or the payload is not JSON
1601+
*/
1602+
public static @NotNull Event verifyAndParseWebhook(
1603+
@NotNull byte[] body, @NotNull String signature, @NotNull String secret) {
1604+
return verifyAndParseInternal(ungzipPayload(body), signature, secret);
1605+
}
1606+
1607+
/** Singleton-secret overload: uses the API secret of the configured {@link Client} singleton. */
1608+
public static @NotNull Event verifyAndParseWebhook(
1609+
@NotNull byte[] body, @NotNull String signature) {
1610+
return verifyAndParseWebhook(body, signature, Client.getInstance().getApiSecret());
16431611
}
16441612

16451613
/**
1646-
* Convenience overload of {@link #verifyAndDecodeWebhook(byte[], String, String, String)} for
1647-
* HTTP webhooks (no transport wrapper). Uses the configured singleton {@link Client} secret.
1614+
* Decode the SQS {@code Body} (base64, then gzip-if-magic), verify the HMAC {@code signature}
1615+
* from the {@code X-Signature} message attribute, and return the parsed {@link Event}.
16481616
*/
1649-
public static byte[] verifyAndDecodeWebhook(
1650-
@NotNull byte[] body, @NotNull String signature, @Nullable String contentEncoding) {
1651-
return verifyAndDecodeWebhook(
1652-
Client.getInstance().getApiSecret(), body, signature, contentEncoding, null);
1617+
public static @NotNull Event verifyAndParseSqs(
1618+
@NotNull String messageBody, @NotNull String signature, @NotNull String secret) {
1619+
return verifyAndParseInternal(decodeSqsPayload(messageBody), signature, secret);
1620+
}
1621+
1622+
/** Singleton-secret overload of {@link #verifyAndParseSqs(String, String, String)}. */
1623+
public static @NotNull Event verifyAndParseSqs(
1624+
@NotNull String messageBody, @NotNull String signature) {
1625+
return verifyAndParseSqs(messageBody, signature, Client.getInstance().getApiSecret());
16531626
}
16541627

16551628
/**
1656-
* Verifies and decodes a Stream message using the API secret of the configured singleton {@link
1657-
* Client}, supporting both HTTP webhooks and SQS / SNS envelopes via {@code payloadEncoding}.
1629+
* Decode the SNS notification {@code Message} (identical to SQS handling), verify the HMAC {@code
1630+
* signature} from the {@code X-Signature} message attribute, and return the parsed {@link Event}.
16581631
*/
1659-
public static byte[] verifyAndDecodeWebhook(
1660-
@NotNull byte[] body,
1661-
@NotNull String signature,
1662-
@Nullable String contentEncoding,
1663-
@Nullable String payloadEncoding) {
1664-
return verifyAndDecodeWebhook(
1665-
Client.getInstance().getApiSecret(), body, signature, contentEncoding, payloadEncoding);
1632+
public static @NotNull Event verifyAndParseSns(
1633+
@NotNull String message, @NotNull String signature, @NotNull String secret) {
1634+
return verifyAndParseInternal(decodeSnsPayload(message), signature, secret);
1635+
}
1636+
1637+
/** Singleton-secret overload of {@link #verifyAndParseSns(String, String, String)}. */
1638+
public static @NotNull Event verifyAndParseSns(
1639+
@NotNull String message, @NotNull String signature) {
1640+
return verifyAndParseSns(message, signature, Client.getInstance().getApiSecret());
16661641
}
16671642

16681643
private static byte[] readAll(InputStream in) throws IOException {
@@ -1675,11 +1650,6 @@ private static byte[] readAll(InputStream in) throws IOException {
16751650
return out.toByteArray();
16761651
}
16771652

1678-
private static boolean constantTimeEquals(@NotNull String a, @NotNull String b) {
1679-
return MessageDigest.isEqual(
1680-
a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8));
1681-
}
1682-
16831653
private static String bytesToHex(byte[] hash) {
16841654
StringBuilder hexString = new StringBuilder(2 * hash.length);
16851655
for (byte b : hash) {

0 commit comments

Comments
 (0)