Skip to content

Commit 26320f0

Browse files
feat: add webhook HMAC verification helper methods
feat: add webhook HMAC verification helper methods
1 parent 63d8c9f commit 26320f0

24 files changed

Lines changed: 535 additions & 2 deletions

README.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,23 @@ LithicClient client = LithicOkHttpClient.builder()
4343
.build();
4444
```
4545

46-
Alternately, set the environment variable `LITHIC_API_KEY` and use `LithicOkHttpClient.fromEnv()`:
46+
Alternately, use `LithicOkHttpClient.fromEnv()` to read client arguments from environment variables:
4747

4848
```java
4949
LithicClient client = LithicClient.fromEnv();
50+
51+
// Note: you can also call fromEnv() from the client builder, for example if you need to set additional properties
52+
LithicClient client = LithicOkHttpClient.builder()
53+
.fromEnv()
54+
// ... set properties on the builder
55+
.build();
5056
```
5157

58+
| Property | Environment variable | Required | Default value |
59+
| ------------- | ----------------------- | -------- | ------------- |
60+
| apiKey | `LITHIC_API_KEY` | true ||
61+
| webhookSecret | `LITHIC_WEBHOOK_SECRET` | false ||
62+
5263
Read the documentation for more configuration options.
5364

5465
---

lithic-java-client-okhttp/src/main/kotlin/com/lithic/api/client/okhttp/LithicOkHttpClient.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package com.lithic.api.client.okhttp
22

3+
import com.fasterxml.jackson.databind.json.JsonMapper
34
import com.lithic.api.client.LithicClient
45
import com.lithic.api.client.LithicClientImpl
56
import com.lithic.api.core.ClientOptions
7+
import java.time.Clock
68
import java.time.Duration
79

810
class LithicOkHttpClient private constructor() {
@@ -25,6 +27,10 @@ class LithicOkHttpClient private constructor() {
2527

2628
fun baseUrl(baseUrl: String) = apply { this.baseUrl = baseUrl }
2729

30+
fun jsonMapper(jsonMapper: JsonMapper) = apply { clientOptions.jsonMapper(jsonMapper) }
31+
32+
fun clock(clock: Clock) = apply { clientOptions.clock(clock) }
33+
2834
fun apiKey(apiKey: String) = apply { clientOptions.apiKey(apiKey) }
2935

3036
fun headers(headers: Map<String, Iterable<String>>) = apply {
@@ -51,6 +57,10 @@ class LithicOkHttpClient private constructor() {
5157
clientOptions.responseValidation(responseValidation)
5258
}
5359

60+
fun webhookSecret(webhookSecret: String?) = apply {
61+
clientOptions.webhookSecret(webhookSecret)
62+
}
63+
5464
fun fromEnv() = apply { clientOptions.fromEnv() }
5565

5666
fun build(): LithicClient {

lithic-java-client-okhttp/src/main/kotlin/com/lithic/api/client/okhttp/LithicOkHttpClientAsync.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package com.lithic.api.client.okhttp
22

3+
import com.fasterxml.jackson.databind.json.JsonMapper
34
import com.lithic.api.client.LithicClientAsync
45
import com.lithic.api.client.LithicClientAsyncImpl
56
import com.lithic.api.core.ClientOptions
7+
import java.time.Clock
68
import java.time.Duration
79

810
class LithicOkHttpClientAsync private constructor() {
@@ -25,6 +27,10 @@ class LithicOkHttpClientAsync private constructor() {
2527

2628
fun baseUrl(baseUrl: String) = apply { this.baseUrl = baseUrl }
2729

30+
fun jsonMapper(jsonMapper: JsonMapper) = apply { clientOptions.jsonMapper(jsonMapper) }
31+
32+
fun clock(clock: Clock) = apply { clientOptions.clock(clock) }
33+
2834
fun apiKey(apiKey: String) = apply { clientOptions.apiKey(apiKey) }
2935

3036
fun headers(headers: Map<String, Iterable<String>>) = apply {
@@ -51,6 +57,10 @@ class LithicOkHttpClientAsync private constructor() {
5157
clientOptions.responseValidation(responseValidation)
5258
}
5359

60+
fun webhookSecret(webhookSecret: String?) = apply {
61+
clientOptions.webhookSecret(webhookSecret)
62+
}
63+
5464
fun fromEnv() = apply { clientOptions.fromEnv() }
5565

5666
fun build(): LithicClientAsync {

lithic-java-core/src/main/kotlin/com/lithic/api/client/LithicClient.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ interface LithicClient {
2424

2525
fun transactions(): TransactionService
2626

27+
fun webhooks(): WebhookService
28+
2729
/** API status check */
2830
@JvmOverloads
2931
fun apiStatus(

lithic-java-core/src/main/kotlin/com/lithic/api/client/LithicClientAsync.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ interface LithicClientAsync {
2525

2626
fun transactions(): TransactionServiceAsync
2727

28+
fun webhooks(): WebhookServiceAsync
29+
2830
/** API status check */
2931
@JvmOverloads
3032
fun apiStatus(

lithic-java-core/src/main/kotlin/com/lithic/api/client/LithicClientAsyncImpl.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ constructor(
4444
TransactionServiceAsyncImpl(clientOptions)
4545
}
4646

47+
private val webhooks: WebhookServiceAsync by lazy { WebhookServiceAsyncImpl(clientOptions) }
48+
4749
override fun accounts(): AccountServiceAsync = accounts
4850

4951
override fun accountHolders(): AccountHolderServiceAsync = accountHolders
@@ -60,6 +62,8 @@ constructor(
6062

6163
override fun transactions(): TransactionServiceAsync = transactions
6264

65+
override fun webhooks(): WebhookServiceAsync = webhooks
66+
6367
private val apiStatusHandler: Handler<ApiStatus> =
6468
jsonHandler<ApiStatus>(clientOptions.jsonMapper).withErrorHandler(errorHandler)
6569

lithic-java-core/src/main/kotlin/com/lithic/api/client/LithicClientImpl.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ constructor(
4141

4242
private val transactions: TransactionService by lazy { TransactionServiceImpl(clientOptions) }
4343

44+
private val webhooks: WebhookService by lazy { WebhookServiceImpl(clientOptions) }
45+
4446
override fun accounts(): AccountService = accounts
4547

4648
override fun accountHolders(): AccountHolderService = accountHolders
@@ -57,6 +59,8 @@ constructor(
5759

5860
override fun transactions(): TransactionService = transactions
5961

62+
override fun webhooks(): WebhookService = webhooks
63+
6064
private val apiStatusHandler: Handler<ApiStatus> =
6165
jsonHandler<ApiStatus>(clientOptions.jsonMapper).withErrorHandler(errorHandler)
6266

lithic-java-core/src/main/kotlin/com/lithic/api/core/ClientOptions.kt

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,16 @@ import com.google.common.collect.ArrayListMultimap
55
import com.google.common.collect.ListMultimap
66
import com.lithic.api.core.http.HttpClient
77
import com.lithic.api.core.http.RetryingHttpClient
8+
import java.time.Clock
89

910
class ClientOptions
1011
private constructor(
1112
@get:JvmName("httpClient") val httpClient: HttpClient,
1213
@get:JvmName("jsonMapper") val jsonMapper: JsonMapper,
14+
@get:JvmName("clock") val clock: Clock,
1315
@get:JvmName("headers") val headers: ListMultimap<String, String>,
1416
@get:JvmName("responseValidation") val responseValidation: Boolean,
17+
@get:JvmName("webhookSecret") val webhookSecret: String?,
1518
) {
1619

1720
companion object {
@@ -29,15 +32,19 @@ private constructor(
2932

3033
private var httpClient: HttpClient? = null
3134
private var jsonMapper: JsonMapper? = null
35+
private var clock: Clock? = null
3236
private var headers: MutableMap<String, MutableList<String>> = mutableMapOf()
3337
private var responseValidation: Boolean = false
3438
private var maxRetries: Int = 2
3539
private var apiKey: String? = null
40+
private var webhookSecret: String? = null
3641

3742
fun httpClient(httpClient: HttpClient) = apply { this.httpClient = httpClient }
3843

3944
fun jsonMapper(jsonMapper: JsonMapper) = apply { this.jsonMapper = jsonMapper }
4045

46+
fun clock(clock: Clock) = apply { this.clock = clock }
47+
4148
fun headers(headers: Map<String, Iterable<String>>) = apply {
4249
this.headers.clear()
4350
putAllHeaders(headers)
@@ -65,7 +72,12 @@ private constructor(
6572

6673
fun apiKey(apiKey: String) = apply { this.apiKey = apiKey }
6774

68-
fun fromEnv() = apply { System.getenv("LITHIC_API_KEY")?.let { apiKey(it) } }
75+
fun webhookSecret(webhookSecret: String?) = apply { this.webhookSecret = webhookSecret }
76+
77+
fun fromEnv() = apply {
78+
System.getenv("LITHIC_API_KEY")?.let { apiKey(it) }
79+
System.getenv("LITHIC_WEBHOOK_SECRET")?.let { webhookSecret(it) }
80+
}
6981

7082
fun build(): ClientOptions {
7183
checkNotNull(httpClient) { "`httpClient` is required but was not set" }
@@ -87,8 +99,10 @@ private constructor(
8799
.maxRetries(maxRetries)
88100
.build(),
89101
jsonMapper ?: jsonMapper(),
102+
clock ?: Clock.systemUTC(),
90103
headers.toUnmodifiable(),
91104
responseValidation,
105+
webhookSecret,
92106
)
93107
}
94108
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
@file:Suppress("OVERLOADS_INTERFACE") // See https://youtrack.jetbrains.com/issue/KT-36102
2+
3+
package com.lithic.api.services.async
4+
5+
import com.google.common.collect.ListMultimap
6+
import com.lithic.api.models.Event
7+
8+
interface WebhookServiceAsync {
9+
10+
fun unwrap(payload: String, headers: ListMultimap<String, String>, secret: String?): Event
11+
12+
fun verifySignature(payload: String, headers: ListMultimap<String, String>, secret: String?)
13+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package com.lithic.api.services.async
2+
3+
import com.fasterxml.jackson.core.JsonProcessingException
4+
import com.google.common.collect.ListMultimap
5+
import com.lithic.api.core.ClientOptions
6+
import com.lithic.api.core.http.HttpResponse.Handler
7+
import com.lithic.api.errors.LithicError
8+
import com.lithic.api.errors.LithicException
9+
import com.lithic.api.models.Event
10+
import com.lithic.api.services.errorHandler
11+
import java.security.MessageDigest
12+
import java.time.Duration
13+
import java.time.Instant
14+
import java.util.Base64
15+
import javax.crypto.Mac
16+
import javax.crypto.spec.SecretKeySpec
17+
18+
class WebhookServiceAsyncImpl
19+
constructor(
20+
private val clientOptions: ClientOptions,
21+
) : WebhookServiceAsync {
22+
23+
private val errorHandler: Handler<LithicError> = errorHandler(clientOptions.jsonMapper)
24+
25+
override fun unwrap(
26+
payload: String,
27+
headers: ListMultimap<String, String>,
28+
secret: String?
29+
): Event {
30+
verifySignature(payload, headers, secret)
31+
return try {
32+
clientOptions.jsonMapper.readValue(payload, Event::class.java)
33+
} catch (e: JsonProcessingException) {
34+
throw LithicException("Invalid event payload", e)
35+
}
36+
}
37+
38+
override fun verifySignature(
39+
payload: String,
40+
headers: ListMultimap<String, String>,
41+
secret: String?
42+
) {
43+
val webhookSecret =
44+
secret
45+
?: clientOptions.webhookSecret
46+
?: throw LithicException(
47+
"The webhook secret must either be set using the env var, LITHIC_WEBHOOK_SECRET, on the client class, or passed to this method"
48+
)
49+
50+
val whsecret =
51+
try {
52+
Base64.getDecoder().decode(webhookSecret.removePrefix("whsec_"))
53+
} catch (e: RuntimeException) {
54+
throw LithicException("Invalid webhook secret")
55+
}
56+
57+
val msgId =
58+
headers.get("webhook-id").getOrNull(0)
59+
?: throw LithicException("Could not find webhook-id header")
60+
val msgSignture =
61+
headers.get("webhook-signature").getOrNull(0)
62+
?: throw LithicException("Could not find webhook-signature header")
63+
val msgTimestamp =
64+
headers.get("webhook-timestamp").getOrNull(0)
65+
?: throw LithicException("Could not find webhook-timestamp header")
66+
67+
val timestamp =
68+
try {
69+
Instant.ofEpochSecond(msgTimestamp.toLong())
70+
} catch (e: RuntimeException) {
71+
throw LithicException("Invalid signature headers", e)
72+
}
73+
val now = Instant.now(clientOptions.clock)
74+
75+
if (timestamp.isBefore(now.minus(Duration.ofMinutes(5)))) {
76+
throw LithicException("Webhook timestamp too old")
77+
}
78+
if (timestamp.isAfter(now.plus(Duration.ofMinutes(5)))) {
79+
throw LithicException("Webhook timestamp too new")
80+
}
81+
82+
val mac = Mac.getInstance("HmacSHA256")
83+
mac.init(SecretKeySpec(whsecret, "HmacSHA256"))
84+
val expectedSignature =
85+
mac.doFinal("$msgId.${timestamp.epochSecond}.$payload".toByteArray())
86+
87+
msgSignture.splitToSequence(" ").forEach {
88+
val parts = it.split(",")
89+
if (parts.size != 2) {
90+
return@forEach
91+
}
92+
93+
if (parts[0] != "v1") {
94+
return@forEach
95+
}
96+
97+
if (MessageDigest.isEqual(Base64.getDecoder().decode(parts[1]), expectedSignature)) {
98+
return
99+
}
100+
}
101+
102+
throw LithicException("None of the given webhook signatures match the expected signature")
103+
}
104+
}

0 commit comments

Comments
 (0)