Skip to content

Commit 5820a76

Browse files
authored
feat: add webhooks and callbacks support (#147)
This PR adds webhooks and callbacks support to the Java SDK by implementing HMAC-based signature verification functionality. This enables secure event-driven integration with external systems through standardized webhook and callback mechanisms. Key changes: - Implemented HMAC signature verification system with configurable algorithms and encoding - Added digest codec factory supporting multiple encoding formats (Hex, Base64, Base64Url) - Comprehensive test suite covering various scenarios and edge cases
1 parent ff999c4 commit 5820a76

6 files changed

Lines changed: 991 additions & 0 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ Core lib's Maven group ID is `io.apimatic`, and its artifact ID is `core`.
7575
| [`TestHelper`](./src/main/java/io/apimatic/core/utilities/TestHelper.java) | Contains utility methods for comparing objects, arrays and files |
7676
| [`AdditionalProperties`](./src/main/java/io/apimatic/core/types/AdditionalProperties.java) | A generic class for managing additional properties in a model. |
7777
| [`ConversionHelper`](./src/main/java/io/apimatic/core/utilities/ConversionHelper.java) | A Helper class for the coversion of type (provided as function) for all structures (array, map, array of map, n-dimensional arrays etc) supported in the SDK. |
78+
| [`HmacSignatureVerifier`](./src/main/java/io/apimatic/core/security/HmacSignatureVerifier.java) | HMAC-based signature verifier for HTTP requests. |
79+
| [`DigestCodecFactory`](./src/main/java/io/apimatic/core/security/DigestCodecFactory.java) | Factory class for creating digest codecs based on encoding type (Hex, Base64, Base64Url). |
7880

7981
## Interfaces
8082

@@ -85,6 +87,7 @@ Core lib's Maven group ID is `io.apimatic`, and its artifact ID is `core`.
8587
| [`RequestSupplier`](./src/main/java/io/apimatic/core/request/async/RequestSupplier.java) | A Request Supplier that supplies the request |
8688
| [`TypeCombinator`](./src/main/java/io/apimatic/core/annotations/TypeCombinator.java) | This is a container of annotations for oneOf/anyOf cases |
8789
| [`PaginationStrategy`](./src/main/java/io/apimatic/core/types/pagination/PaginationStrategy.java) | Provides the functionality to apply pagination parameters and return new request |
90+
| [`DigestCodec`](./src/main/java/io/apimatic/core/security/DigestCodec.java) | Interface for encoding and decoding digest values |
8891

8992
## Links
9093

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package io.apimatic.core.security;
2+
3+
/**
4+
* Interface for encoding and decoding digest values.
5+
*/
6+
public interface DigestCodec {
7+
/**
8+
* Encodes a byte array digest into a string representation.
9+
*
10+
* @param bytes The byte array to encode.
11+
* @return The encoded string representation.
12+
*/
13+
String encode(byte[] bytes);
14+
15+
/**
16+
* Decodes a string representation back into a byte array.
17+
*
18+
* @param encoded The encoded string to decode.
19+
* @return The decoded byte array.
20+
*/
21+
byte[] decode(String encoded);
22+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package io.apimatic.core.security;
2+
3+
import java.util.Base64;
4+
5+
/**
6+
* Factory class for creating digest codecs based on encoding type.
7+
*/
8+
public final class DigestCodecFactory {
9+
10+
private DigestCodecFactory() { } // Prevent instantiation
11+
12+
/**
13+
* Creates a Hex codec.
14+
* @return a DigestCodec for Hex encoding/decoding
15+
*/
16+
public static DigestCodec hex() {
17+
return new HexDigestCodec();
18+
}
19+
20+
/**
21+
* Creates a Base64 codec.
22+
* @return a DigestCodec for Base64 encoding/decoding
23+
*/
24+
public static DigestCodec base64() {
25+
return new Base64DigestCodec();
26+
}
27+
28+
/**
29+
* Creates a Base64Url codec.
30+
* @return a DigestCodec for Base64Url encoding/decoding
31+
*/
32+
public static DigestCodec base64Url() {
33+
return new Base64UrlDigestCodec();
34+
}
35+
36+
private static final int HEX_RADIX = 16;
37+
private static final int HEX_BYTE_MASK = 0xff;
38+
private static final int HEX_BYTE_LENGTH = 2;
39+
private static final int HEX_SHIFT = 4;
40+
41+
/**
42+
* Codec for Hex encoding/decoding.
43+
*/
44+
private static class HexDigestCodec implements DigestCodec {
45+
@Override
46+
public String encode(byte[] bytes) {
47+
StringBuilder sb = new StringBuilder();
48+
for (byte b : bytes) {
49+
sb.append(String.format("%02x", b & HEX_BYTE_MASK));
50+
}
51+
return sb.toString();
52+
}
53+
54+
@Override
55+
public byte[] decode(String encoded) {
56+
int len = encoded.length();
57+
if (len % HEX_BYTE_LENGTH != 0) {
58+
throw new IllegalArgumentException("Invalid hex string length.");
59+
}
60+
byte[] result = new byte[len / HEX_BYTE_LENGTH];
61+
for (int i = 0; i < len; i += HEX_BYTE_LENGTH) {
62+
result[i / HEX_BYTE_LENGTH] = (byte) (
63+
(Character.digit(encoded.charAt(i), HEX_RADIX) << HEX_SHIFT)
64+
+ Character.digit(encoded.charAt(i + 1), HEX_RADIX));
65+
}
66+
return result;
67+
}
68+
}
69+
70+
/**
71+
* Codec for Base64 encoding/decoding.
72+
*/
73+
private static class Base64DigestCodec implements DigestCodec {
74+
@Override
75+
public String encode(byte[] bytes) {
76+
return Base64.getEncoder().encodeToString(bytes);
77+
}
78+
79+
@Override
80+
public byte[] decode(String encoded) {
81+
return Base64.getDecoder().decode(encoded);
82+
}
83+
}
84+
85+
/**
86+
* Codec for Base64Url encoding/decoding.
87+
*/
88+
private static class Base64UrlDigestCodec implements DigestCodec {
89+
@Override
90+
public String encode(byte[] bytes) {
91+
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
92+
}
93+
94+
@Override
95+
public byte[] decode(String encoded) {
96+
return Base64.getUrlDecoder().decode(encoded);
97+
}
98+
}
99+
}
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
package io.apimatic.core.security;
2+
3+
import io.apimatic.coreinterfaces.http.request.Request;
4+
import io.apimatic.coreinterfaces.security.SignatureVerifier;
5+
import io.apimatic.coreinterfaces.security.VerificationResult;
6+
7+
import javax.crypto.Mac;
8+
import javax.crypto.spec.SecretKeySpec;
9+
import java.nio.charset.StandardCharsets;
10+
import java.security.MessageDigest;
11+
import java.util.Map;
12+
import java.util.concurrent.CompletableFuture;
13+
import java.util.function.Function;
14+
15+
/**
16+
* HMAC-based signature verifier for HTTP requests.
17+
* <p>
18+
* Supports signature templates such as:
19+
* <ul>
20+
* <li>{@code Sha256={digest}}</li>
21+
* <li>{@code Sha256={digest}=abc}</li>
22+
* <li>{@code signature="{digest}"; ts=1690000000}</li>
23+
* </ul>
24+
* The template is matched inside the header value (noise tolerated before/after).
25+
*/
26+
public class HmacSignatureVerifier implements SignatureVerifier {
27+
private static final String SIGNATURE_VALUE_PLACEHOLDER = "{digest}";
28+
29+
/** Name of the header carrying the provided signature (lookup is case-insensitive). */
30+
private final String signatureHeaderName;
31+
32+
/** HMAC algorithm used for signature generation (default: HmacSHA256). */
33+
private final String algorithm;
34+
35+
/** Initialized key spec; used to create a new Mac per verification call. */
36+
private final SecretKeySpec keySpec;
37+
38+
/** Template containing "{digest}". */
39+
private final String signatureValueTemplate;
40+
41+
/** Resolves the bytes to sign from the request. */
42+
private final Function<Request, byte[]> requestBytesResolver;
43+
44+
/** Codec used to decode (and possibly encode) digest text ↔ bytes (e.g., hex/base64). */
45+
private final DigestCodec digestCodec;
46+
47+
/**
48+
* Initializes a new instance of the HmacSignatureVerifier class.
49+
*
50+
* @param secretKey Secret key for HMAC computation.
51+
* @param signatureHeaderName Name of the header containing the signature.
52+
* @param digestCodec Encoding type for the signature.
53+
* @param requestBytesResolver Optional custom resolver for extracting data to sign.
54+
* @param algorithm Algorithm (default HmacSHA256).
55+
* @param signatureValueTemplate Template for signature format.
56+
*/
57+
public HmacSignatureVerifier(
58+
final String secretKey,
59+
final String signatureHeaderName,
60+
final DigestCodec digestCodec,
61+
final Function<Request, byte[]> requestBytesResolver,
62+
final String algorithm,
63+
final String signatureValueTemplate
64+
) {
65+
66+
if (secretKey == null || secretKey.trim().isEmpty()) {
67+
throw new IllegalArgumentException("Secret key cannot be null or Empty.");
68+
}
69+
if (signatureHeaderName == null || signatureHeaderName.trim().isEmpty()) {
70+
throw new IllegalArgumentException("Signature header cannot be null or Empty.");
71+
}
72+
if (signatureValueTemplate == null || signatureValueTemplate.trim().isEmpty()) {
73+
throw new IllegalArgumentException("Signature value template cannot be null or Empty.");
74+
}
75+
if (requestBytesResolver == null) {
76+
throw new IllegalArgumentException(
77+
"Request signature template resolver function cannot be null.");
78+
}
79+
if (digestCodec == null) {
80+
throw new IllegalArgumentException("Digest encoding cannot be null.");
81+
}
82+
if (algorithm == null || algorithm.trim().isEmpty()) {
83+
throw new IllegalArgumentException("Algorithm cannot be null or Empty.");
84+
}
85+
86+
this.signatureHeaderName = signatureHeaderName;
87+
this.algorithm = algorithm;
88+
this.keySpec = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), algorithm);
89+
this.signatureValueTemplate = signatureValueTemplate;
90+
this.requestBytesResolver = requestBytesResolver;
91+
this.digestCodec = digestCodec;
92+
}
93+
94+
/**
95+
* Verifies the HMAC signature of the specified HTTP request.
96+
*
97+
* @param request The HTTP request to verify.
98+
* @return A CompletableFuture containing the verification result.
99+
*/
100+
@Override
101+
public CompletableFuture<VerificationResult> verifyAsync(final Request request) {
102+
return CompletableFuture.supplyAsync(() -> {
103+
try {
104+
String headerValue = request.getHeaders().asSimpleMap().entrySet().stream()
105+
.filter(e -> e.getKey() != null
106+
&& e.getKey().equalsIgnoreCase(signatureHeaderName))
107+
.map(Map.Entry::getValue)
108+
.findFirst()
109+
.orElse(null);
110+
111+
if (headerValue == null) {
112+
return VerificationResult.failure(
113+
"Signature header '" + signatureHeaderName + "' is missing.");
114+
}
115+
116+
byte[] provided = extractSignature(headerValue);
117+
if (provided == null || provided.length == 0) {
118+
return VerificationResult.failure(
119+
"Malformed signature header '" + signatureHeaderName + "'.");
120+
}
121+
122+
byte[] message = requestBytesResolver.apply(request);
123+
// HMAC per call (thread-safe)
124+
Mac mac = Mac.getInstance(algorithm);
125+
mac.init(keySpec);
126+
byte[] computed = mac.doFinal(message);
127+
128+
return MessageDigest.isEqual(provided, computed)
129+
? VerificationResult.success()
130+
: VerificationResult.failure("Signature verification failed.");
131+
} catch (Exception ex) {
132+
return VerificationResult.failure("Exception: " + ex.getMessage());
133+
}
134+
});
135+
}
136+
137+
/**
138+
* Extracts the digest value from the signature header according to the template
139+
* and decodes the signature from the header value.
140+
*
141+
* @param headerValue The value of the signature header.
142+
* @return The decoded signature as a byte array, or null if extraction fails.
143+
*/
144+
private byte[] extractSignature(final String headerValue) {
145+
try {
146+
int index = signatureValueTemplate.indexOf(SIGNATURE_VALUE_PLACEHOLDER);
147+
if (index < 0) {
148+
return new byte[0];
149+
}
150+
151+
String prefix = signatureValueTemplate.substring(0, index);
152+
String suffix = signatureValueTemplate.substring(
153+
index + SIGNATURE_VALUE_PLACEHOLDER.length());
154+
155+
// find prefix anywhere (case-insensitive)
156+
int prefixAt = indexOfIgnoreCase(headerValue, prefix, 0);
157+
if (prefixAt < 0) {
158+
return new byte[0];
159+
}
160+
161+
int digestStart = prefixAt + prefix.length();
162+
163+
// find suffix after the digest start (case-insensitive)
164+
final int digestEnd;
165+
if (suffix.isEmpty()) {
166+
digestEnd = headerValue.length();
167+
} else {
168+
digestEnd = indexOfIgnoreCase(headerValue, suffix, digestStart);
169+
if (digestEnd < 0) {
170+
return new byte[0];
171+
}
172+
}
173+
174+
if (digestEnd < digestStart) {
175+
return new byte[0];
176+
}
177+
178+
String digest = headerValue.substring(digestStart, digestEnd).trim();
179+
// strip optional quotes
180+
if (digest.length() >= 2 && digest.charAt(0) == '"'
181+
&& digest.charAt(digest.length() - 1) == '"') {
182+
digest = digest.substring(1, digest.length() - 1);
183+
}
184+
185+
byte[] decoded = digestCodec.decode(digest);
186+
return (decoded == null || decoded.length == 0) ? null : decoded;
187+
} catch (Exception e) {
188+
return new byte[0];
189+
}
190+
}
191+
192+
/**
193+
* Finds the index of the first case-insensitive {@code needle} in {@code haystack}
194+
* starting from {@code fromIndex}, or -1 if not found.
195+
*
196+
* @param haystack The string to search in.
197+
* @param needle The substring to search for.
198+
* @param fromIndex The index to start searching from.
199+
* @return The index of the first occurrence, or -1 if not found.
200+
*/
201+
private static int indexOfIgnoreCase(
202+
final String haystack,
203+
final String needle,
204+
final int fromIndex
205+
) {
206+
if (needle.isEmpty()) {
207+
return fromIndex;
208+
}
209+
int max = haystack.length() - needle.length();
210+
for (int i = Math.max(0, fromIndex); i <= max; i++) {
211+
if (haystack.regionMatches(true, i, needle, 0, needle.length())) {
212+
return i;
213+
}
214+
}
215+
return -1;
216+
}
217+
}

0 commit comments

Comments
 (0)