From 2bde43e4fa33ea73175c5ad1e4762a525fbb2c43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CAshutosh-Bhadauriya=E2=80=9D?= Date: Fri, 17 Jul 2026 09:35:30 +0530 Subject: [PATCH] wip --- gradle.properties | 2 +- src/main/java/com/authsignal/Webhook.java | 109 +++++++++++++- .../com/authsignal/model/WebhookEvent.java | 4 +- .../authsignal/model/WebhookEventBatch.java | 7 + .../com/authsignal/model/WebhookLogEvent.java | 13 ++ .../java/com/authsignal/WebhookTests.java | 142 ++++++++++++++---- 6 files changed, 239 insertions(+), 38 deletions(-) create mode 100644 src/main/java/com/authsignal/model/WebhookEventBatch.java create mode 100644 src/main/java/com/authsignal/model/WebhookLogEvent.java diff --git a/gradle.properties b/gradle.properties index 89bf082..317fe99 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1 @@ -version=2.11.0 \ No newline at end of file +version=3.0.0 \ No newline at end of file diff --git a/src/main/java/com/authsignal/Webhook.java b/src/main/java/com/authsignal/Webhook.java index 99fe69a..e511eb4 100644 --- a/src/main/java/com/authsignal/Webhook.java +++ b/src/main/java/com/authsignal/Webhook.java @@ -1,7 +1,11 @@ package com.authsignal; import com.authsignal.model.WebhookEvent; +import com.authsignal.model.WebhookEventBatch; +import com.authsignal.model.WebhookLogEvent; import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; import java.util.ArrayList; import java.util.Base64; @@ -20,6 +24,50 @@ public Webhook(String apiSecretKey) { public WebhookEvent constructEvent(String payload, String signature, int tolerance) throws InvalidSignatureException { + verifySignature(payload, signature, tolerance); + + JsonObject parsedPayload = parsePayload(payload); + + if (parsedPayload.has("records")) { + throw new InvalidPayloadException( + "Payload is a batch of log events. Use constructLogEventBatch instead."); + } + + WebhookEvent event = deserializePayload(parsedPayload, WebhookEvent.class); + validateEvent(event); + + return event; + } + + public WebhookEvent constructEvent(String payload, String signature) throws InvalidSignatureException { + return constructEvent(payload, signature, DEFAULT_TOLERANCE); + } + + public WebhookEventBatch constructLogEventBatch(String payload, String signature, int tolerance) + throws InvalidSignatureException { + verifySignature(payload, signature, tolerance); + + JsonObject parsedPayload = parsePayload(payload); + WebhookEventBatch batch = deserializePayload(parsedPayload, WebhookEventBatch.class); + + if (batch.records == null) { + throw new InvalidPayloadException("Payload format is invalid. Expected a 'records' array."); + } + + for (WebhookLogEvent event : batch.records) { + validateLogEvent(event); + } + + return batch; + } + + public WebhookEventBatch constructLogEventBatch(String payload, String signature) + throws InvalidSignatureException { + return constructLogEventBatch(payload, signature, DEFAULT_TOLERANCE); + } + + private void verifySignature(String payload, String signature, int tolerance) + throws InvalidSignatureException { SignatureHeaderData parsedSignature = parseSignature(signature); long secondsSinceEpoch = System.currentTimeMillis() / 1000; @@ -37,12 +85,57 @@ public WebhookEvent constructEvent(String payload, String signature, int toleran if (!match) { throw new InvalidSignatureException("Signature mismatch."); } + } + + private JsonObject parsePayload(String payload) { + try { + return JsonParser.parseString(payload).getAsJsonObject(); + } catch (Exception exception) { + throw new InvalidPayloadException("Payload format is invalid.", exception); + } + } - return new Gson().fromJson(payload, WebhookEvent.class); + private T deserializePayload(JsonObject payload, Class payloadClass) { + try { + return new Gson().fromJson(payload, payloadClass); + } catch (Exception exception) { + throw new InvalidPayloadException("Payload format is invalid.", exception); + } } - public WebhookEvent constructEvent(String payload, String signature) throws InvalidSignatureException { - return constructEvent(payload, signature, DEFAULT_TOLERANCE); + private void validateEvent(WebhookEvent event) { + validateEnvelope(event.version, event.type, event.id, event.source, event.time, event.tenantId); + + if (event.data == null) { + throw new InvalidPayloadException("Payload is missing required field 'data'."); + } + } + + private void validateLogEvent(WebhookLogEvent event) { + validateEnvelope(event.version, event.type, event.id, event.source, event.time, event.tenantId); + + if (event.record == null) { + throw new InvalidPayloadException("Payload is missing required field 'record'."); + } + } + + private void validateEnvelope( + int version, String type, String id, String source, String time, String tenantId) { + if (version <= 0) { + throw new InvalidPayloadException("Payload is missing required field 'version'."); + } + + requireField(type, "type"); + requireField(id, "id"); + requireField(source, "source"); + requireField(time, "time"); + requireField(tenantId, "tenantId"); + } + + private void requireField(String value, String name) { + if (value == null || value.trim().isEmpty()) { + throw new InvalidPayloadException("Payload is missing required field '" + name + "'."); + } } private SignatureHeaderData parseSignature(String value) throws InvalidSignatureException { @@ -114,4 +207,14 @@ public InvalidSignatureException(String message) { super(message); } } + + public static class InvalidPayloadException extends RuntimeException { + public InvalidPayloadException(String message) { + super(message); + } + + public InvalidPayloadException(String message, Throwable cause) { + super(message, cause); + } + } } diff --git a/src/main/java/com/authsignal/model/WebhookEvent.java b/src/main/java/com/authsignal/model/WebhookEvent.java index d9f82ec..74266a6 100644 --- a/src/main/java/com/authsignal/model/WebhookEvent.java +++ b/src/main/java/com/authsignal/model/WebhookEvent.java @@ -1,6 +1,6 @@ package com.authsignal.model; -import java.util.HashMap; +import java.util.Map; public class WebhookEvent { public int version; @@ -9,5 +9,5 @@ public class WebhookEvent { public String source; public String time; public String tenantId; - public HashMap data; + public Map data; } diff --git a/src/main/java/com/authsignal/model/WebhookEventBatch.java b/src/main/java/com/authsignal/model/WebhookEventBatch.java new file mode 100644 index 0000000..d20e794 --- /dev/null +++ b/src/main/java/com/authsignal/model/WebhookEventBatch.java @@ -0,0 +1,7 @@ +package com.authsignal.model; + +import java.util.List; + +public class WebhookEventBatch { + public List records; +} diff --git a/src/main/java/com/authsignal/model/WebhookLogEvent.java b/src/main/java/com/authsignal/model/WebhookLogEvent.java new file mode 100644 index 0000000..c3fd53d --- /dev/null +++ b/src/main/java/com/authsignal/model/WebhookLogEvent.java @@ -0,0 +1,13 @@ +package com.authsignal.model; + +import java.util.Map; + +public class WebhookLogEvent { + public int version; + public String type; + public String id; + public String source; + public String time; + public String tenantId; + public Map record; +} diff --git a/src/test/java/com/authsignal/WebhookTests.java b/src/test/java/com/authsignal/WebhookTests.java index b83c050..3614581 100644 --- a/src/test/java/com/authsignal/WebhookTests.java +++ b/src/test/java/com/authsignal/WebhookTests.java @@ -5,34 +5,33 @@ import com.authsignal.Webhook.InvalidSignatureException; import com.authsignal.model.WebhookEvent; +import com.authsignal.model.WebhookEventBatch; -import java.io.FileInputStream; -import java.io.IOException; -import java.util.Properties; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; public class WebhookTests { - private Webhook webhook; + private static final String API_SECRET_KEY = "test-secret-key"; + private final Webhook webhook; public WebhookTests() { - String secret = getProp("AUTHSIGNAL_SECRET"); - - webhook = new Webhook(secret); + webhook = new Webhook(API_SECRET_KEY); } - private String getProp(String name) { - String value = System.getenv(name); - - if (value == null) { - try { - Properties localProperties = new Properties(); - localProperties.load(new FileInputStream(System.getProperty("user.dir") + "/local.properties")); - value = localProperties.getProperty(name); - } catch (IOException e) { - throw new RuntimeException("Failed to load properties file", e); - } + private String createSignature(String payload) { + try { + long timestamp = System.currentTimeMillis() / 1000; + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(API_SECRET_KEY.getBytes(), "HmacSHA256")); + byte[] hmacBytes = mac.doFinal((timestamp + "." + payload).getBytes()); + String signature = Base64.getEncoder().encodeToString(hmacBytes).replace("=", ""); + return "t=" + timestamp + ",v2=" + signature; + } catch (Exception e) { + throw new RuntimeException("Failed to compute test signature", e); } - - return value; } @Test @@ -95,13 +94,8 @@ public void testValidSignature() { + "}" + "}"; - // Ignore tolerance window - int tolerance = -1; - - String signature = "t=1740016316,v2=NwFcIT68pK7g+m365Jj4euXj/ke3GSnkTpMPcRVi5q4"; - try { - WebhookEvent event = webhook.constructEvent(payload, signature, tolerance); + WebhookEvent event = webhook.constructEvent(payload, createSignature(payload)); assertNotNull(event); @@ -132,17 +126,101 @@ public void testValidSignatureWhenTwoApiKeysActive() { + "}" + "}"; - // Ignore tolerance window - int tolerance = -1; - - String signature = "t=1740016037,v2=zI5rg1XJtKH8dXTX9VCSwy07qTPJliXkK9ppgNjmzqw,v2=KMg8mXXGO/SmNNmcszKXI4UaEVHLc21YNWthHfispQo"; - try { - WebhookEvent event = webhook.constructEvent(payload, signature, tolerance); + String signature = createSignature(payload) + ",v2=invalid_signature"; + WebhookEvent event = webhook.constructEvent(payload, signature); assertNotNull(event); } catch (InvalidSignatureException ex) { fail("Expected a valid event to be constructed"); } } + + @Test + public void testEventWithCustomVariables() { + String payload = "{" + + "\"version\":1," + + "\"id\":\"bc1598bc-e5d6-4c69-9afb-1a6fe3469d6e\"," + + "\"source\":\"https://authsignal.com\"," + + "\"time\":\"2025-02-20T01:51:56.070Z\"," + + "\"tenantId\":\"7752d28e-e627-4b1b-bb81-b45d68d617bc\"," + + "\"type\":\"sms.created\"," + + "\"data\":{" + + "\"actionCode\":\"smsVerify\"," + + "\"customVariables\":{" + + "\"action_journeyType\":\"ForgotChangePassword\"," + + "\"retryCount\":2," + + "\"isRecovery\":true," + + "\"channels\":[\"sms\",\"email\"]" + + "}" + + "}" + + "}"; + + try { + WebhookEvent event = webhook.constructEvent(payload, createSignature(payload)); + + assertTrue(event.data.get("customVariables") instanceof Map); + Map customVariables = (Map) event.data.get("customVariables"); + assertEquals("ForgotChangePassword", customVariables.get("action_journeyType")); + assertEquals(2.0, customVariables.get("retryCount")); + assertEquals(true, customVariables.get("isRecovery")); + assertTrue(customVariables.get("channels") instanceof List); + assertEquals("sms", ((List) customVariables.get("channels")).get(0)); + } catch (InvalidSignatureException ex) { + fail("Expected an event with custom variables to be constructed"); + } + } + + @Test + public void testLogEventBatch() { + String payload = "{\"records\":[{" + + "\"version\":1," + + "\"id\":\"bc1598bc-e5d6-4c69-9afb-1a6fe3469d6e\"," + + "\"source\":\"https://authsignal.com\"," + + "\"time\":\"2025-02-20T01:51:56.070Z\"," + + "\"tenantId\":\"7752d28e-e627-4b1b-bb81-b45d68d617bc\"," + + "\"type\":\"action.log_created\"," + + "\"record\":{" + + "\"userId\":\"b9f74d36-fcfc-4efc-87f1-3664ab5a7fb0\"," + + "\"customVariables\":{\"journeyType\":\"accountRecovery\"}" + + "}" + + "}]}"; + + try { + WebhookEventBatch batch = webhook.constructLogEventBatch(payload, createSignature(payload)); + + assertEquals(1, batch.records.size()); + assertTrue(batch.records.get(0).record.get("customVariables") instanceof Map); + } catch (InvalidSignatureException ex) { + fail("Expected a log event batch to be constructed"); + } + } + + @Test + public void testLogEventBatchPassedToConstructEvent() { + String payload = "{\"records\":[]}"; + + try { + webhook.constructEvent(payload, createSignature(payload)); + fail("Expected an InvalidPayloadException to be thrown"); + } catch (Webhook.InvalidPayloadException ex) { + assertTrue(ex.getMessage().contains("constructLogEventBatch")); + } catch (InvalidSignatureException ex) { + fail("Expected a valid signature"); + } + } + + @Test + public void testInvalidPayload() { + String payload = "not-json"; + + try { + webhook.constructEvent(payload, createSignature(payload)); + fail("Expected an InvalidPayloadException to be thrown"); + } catch (Webhook.InvalidPayloadException ex) { + assertEquals("Payload format is invalid.", ex.getMessage()); + } catch (InvalidSignatureException ex) { + fail("Expected a valid signature"); + } + } }