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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
@@ -1 +1 @@
version=2.11.0
version=3.0.0
109 changes: 106 additions & 3 deletions src/main/java/com/authsignal/Webhook.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -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> T deserializePayload(JsonObject payload, Class<T> 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 {
Expand Down Expand Up @@ -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);
}
}
}
4 changes: 2 additions & 2 deletions src/main/java/com/authsignal/model/WebhookEvent.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.authsignal.model;

import java.util.HashMap;
import java.util.Map;

public class WebhookEvent {
public int version;
Expand All @@ -9,5 +9,5 @@ public class WebhookEvent {
public String source;
public String time;
public String tenantId;
public HashMap<String, String> data;
public Map<String, Object> data;
}
7 changes: 7 additions & 0 deletions src/main/java/com/authsignal/model/WebhookEventBatch.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.authsignal.model;

import java.util.List;

public class WebhookEventBatch {
public List<WebhookLogEvent> records;
}
13 changes: 13 additions & 0 deletions src/main/java/com/authsignal/model/WebhookLogEvent.java
Original file line number Diff line number Diff line change
@@ -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<String, Object> record;
}
142 changes: 110 additions & 32 deletions src/test/java/com/authsignal/WebhookTests.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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");
}
}
}
Loading