Skip to content

Commit 4e26f35

Browse files
feat(api): add webhook signature verification
1 parent d91bef9 commit 4e26f35

8 files changed

Lines changed: 257 additions & 1 deletion

File tree

lithic-java-core/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ dependencies {
2222
api("com.fasterxml.jackson.core:jackson-core:2.18.2")
2323
api("com.fasterxml.jackson.core:jackson-databind:2.18.2")
2424
api("com.google.errorprone:error_prone_annotations:2.33.0")
25+
api("com.standardwebhooks:standardwebhooks:1.1.0")
2526

2627
implementation("com.fasterxml.jackson.core:jackson-annotations:2.18.2")
2728
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.18.2")
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// File generated from our OpenAPI spec by Stainless.
2+
3+
package com.lithic.api.core
4+
5+
import com.lithic.api.core.http.Headers
6+
import java.util.Objects
7+
import java.util.Optional
8+
import kotlin.jvm.optionals.getOrNull
9+
10+
class UnwrapWebhookParams
11+
private constructor(
12+
private val body: String,
13+
private val headers: Headers?,
14+
private val secret: String?,
15+
) {
16+
17+
/** The raw JSON body of the webhook request. */
18+
fun body(): String = body
19+
20+
/** The headers from the webhook request. */
21+
fun headers(): Optional<Headers> = Optional.ofNullable(headers)
22+
23+
/** The secret used to verify the webhook signature. */
24+
fun secret(): Optional<String> = Optional.ofNullable(secret)
25+
26+
fun toBuilder() = Builder().from(this)
27+
28+
companion object {
29+
30+
/**
31+
* Returns a mutable builder for constructing an instance of [UnwrapWebhookParams].
32+
*
33+
* The following fields are required:
34+
* ```java
35+
* .body()
36+
* ```
37+
*/
38+
@JvmStatic fun builder() = Builder()
39+
}
40+
41+
/** A builder for [UnwrapWebhookParams]. */
42+
class Builder internal constructor() {
43+
44+
private var body: String? = null
45+
private var headers: Headers? = null
46+
private var secret: String? = null
47+
48+
@JvmSynthetic
49+
internal fun from(unwrapWebhookParams: UnwrapWebhookParams) = apply {
50+
body = unwrapWebhookParams.body
51+
headers = unwrapWebhookParams.headers
52+
secret = unwrapWebhookParams.secret
53+
}
54+
55+
/** The raw JSON body of the webhook request. */
56+
fun body(body: String) = apply { this.body = body }
57+
58+
/** The headers from the webhook request. */
59+
fun headers(headers: Headers?) = apply { this.headers = headers }
60+
61+
/** Alias for calling [Builder.headers] with `headers.orElse(null)`. */
62+
fun headers(headers: Optional<Headers>) = headers(headers.getOrNull())
63+
64+
/** The secret used to verify the webhook signature. */
65+
fun secret(secret: String?) = apply { this.secret = secret }
66+
67+
/** Alias for calling [Builder.secret] with `secret.orElse(null)`. */
68+
fun secret(secret: Optional<String>) = secret(secret.getOrNull())
69+
70+
/**
71+
* Returns an immutable instance of [UnwrapWebhookParams].
72+
*
73+
* Further updates to this [Builder] will not mutate the returned instance.
74+
*
75+
* The following fields are required:
76+
* ```java
77+
* .body()
78+
* ```
79+
*
80+
* @throws IllegalStateException if any required field is unset.
81+
*/
82+
fun build(): UnwrapWebhookParams =
83+
UnwrapWebhookParams(checkRequired("body", body), headers, secret)
84+
}
85+
86+
override fun equals(other: Any?): Boolean {
87+
if (this === other) {
88+
return true
89+
}
90+
91+
return other is UnwrapWebhookParams &&
92+
body == other.body &&
93+
headers == other.headers &&
94+
secret == other.secret
95+
}
96+
97+
private val hashCode: Int by lazy { Objects.hash(body, headers, secret) }
98+
99+
override fun hashCode(): Int = hashCode
100+
101+
override fun toString() = "UnwrapWebhookParams{body=$body, headers=$headers, secret=$secret}"
102+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package com.lithic.api.errors
2+
3+
class LithicWebhookException
4+
@JvmOverloads
5+
constructor(message: String? = null, cause: Throwable? = null) : LithicException(message, cause)

lithic-java-core/src/main/kotlin/com/lithic/api/services/async/WebhookServiceAsync.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ package com.lithic.api.services.async
44

55
import com.lithic.api.core.ClientOptions
66
import com.lithic.api.core.JsonValue
7+
import com.lithic.api.core.UnwrapWebhookParams
78
import com.lithic.api.core.http.Headers
89
import com.lithic.api.errors.LithicInvalidDataException
10+
import com.lithic.api.errors.LithicWebhookException
911
import com.lithic.api.models.ParsedWebhookEvent
1012
import java.util.function.Consumer
1113

@@ -41,6 +43,14 @@ interface WebhookServiceAsync {
4143
*/
4244
fun parseUnsafe(body: String): ParsedWebhookEvent
4345

46+
/**
47+
* Unwraps a webhook event from its JSON representation.
48+
*
49+
* @throws LithicInvalidDataException if the body could not be parsed.
50+
* @throws LithicWebhookException if the webhook signature could not be verified
51+
*/
52+
fun parsed(unwrapParams: UnwrapWebhookParams): ParsedWebhookEvent
53+
4454
/**
4555
* A view of [WebhookServiceAsync] that provides access to raw HTTP responses for each method.
4656
*/

lithic-java-core/src/main/kotlin/com/lithic/api/services/async/WebhookServiceAsyncImpl.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ package com.lithic.api.services.async
55
import com.fasterxml.jackson.core.JsonProcessingException
66
import com.lithic.api.core.ClientOptions
77
import com.lithic.api.core.JsonValue
8+
import com.lithic.api.core.UnwrapWebhookParams
89
import com.lithic.api.core.http.Headers
910
import com.lithic.api.errors.LithicException
10-
import com.lithic.api.errors.LithicInvalidDataException
1111
import com.lithic.api.models.ParsedWebhookEvent
1212
import com.lithic.api.services.blocking.WebhookServiceImpl
1313
import java.util.function.Consumer
@@ -53,6 +53,9 @@ class WebhookServiceAsyncImpl internal constructor(private val clientOptions: Cl
5353
override fun parseUnsafe(body: String): ParsedWebhookEvent =
5454
WebhookServiceImpl(clientOptions).parseUnsafe(body)
5555

56+
override fun parsed(unwrapParams: UnwrapWebhookParams): ParsedWebhookEvent =
57+
WebhookServiceImpl(clientOptions).parsed(unwrapParams)
58+
5659
class WithRawResponseImpl internal constructor(private val clientOptions: ClientOptions) :
5760
WebhookServiceAsync.WithRawResponse {
5861

lithic-java-core/src/main/kotlin/com/lithic/api/services/blocking/WebhookService.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ package com.lithic.api.services.blocking
44

55
import com.lithic.api.core.ClientOptions
66
import com.lithic.api.core.JsonValue
7+
import com.lithic.api.core.UnwrapWebhookParams
78
import com.lithic.api.core.http.Headers
89
import com.lithic.api.errors.LithicInvalidDataException
10+
import com.lithic.api.errors.LithicWebhookException
911
import com.lithic.api.models.ParsedWebhookEvent
1012
import java.util.function.Consumer
1113

@@ -41,6 +43,14 @@ interface WebhookService {
4143
*/
4244
fun parseUnsafe(body: String): ParsedWebhookEvent
4345

46+
/**
47+
* Unwraps a webhook event from its JSON representation.
48+
*
49+
* @throws LithicInvalidDataException if the body could not be parsed.
50+
* @throws LithicWebhookException if the webhook signature could not be verified
51+
*/
52+
fun parsed(unwrapParams: UnwrapWebhookParams): ParsedWebhookEvent
53+
4454
/** A view of [WebhookService] that provides access to raw HTTP responses for each method. */
4555
interface WithRawResponse {
4656

lithic-java-core/src/main/kotlin/com/lithic/api/services/blocking/WebhookServiceImpl.kt

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,16 @@ import com.fasterxml.jackson.core.JsonProcessingException
66
import com.fasterxml.jackson.module.kotlin.jacksonTypeRef
77
import com.lithic.api.core.ClientOptions
88
import com.lithic.api.core.JsonValue
9+
import com.lithic.api.core.UnwrapWebhookParams
10+
import com.lithic.api.core.checkRequired
911
import com.lithic.api.core.getRequiredHeader
1012
import com.lithic.api.core.http.Headers
1113
import com.lithic.api.errors.LithicException
1214
import com.lithic.api.errors.LithicInvalidDataException
15+
import com.lithic.api.errors.LithicWebhookException
1316
import com.lithic.api.models.ParsedWebhookEvent
17+
import com.standardwebhooks.Webhook
18+
import com.standardwebhooks.exceptions.WebhookVerificationException
1419
import java.security.MessageDigest
1520
import java.time.Duration
1621
import java.time.Instant
@@ -124,6 +129,28 @@ class WebhookServiceImpl internal constructor(private val clientOptions: ClientO
124129
throw LithicInvalidDataException("Error parsing body", e)
125130
}
126131

132+
override fun parsed(unwrapParams: UnwrapWebhookParams): ParsedWebhookEvent {
133+
val headers = unwrapParams.headers().getOrNull()
134+
if (headers != null) {
135+
try {
136+
val webhookSecret =
137+
checkRequired(
138+
"webhookSecret",
139+
unwrapParams.secret().getOrNull()
140+
?: clientOptions.webhookSecret().getOrNull(),
141+
)
142+
val headersMap =
143+
headers.names().associateWith { name -> headers.values(name) }.toMap()
144+
145+
val webhook = Webhook(webhookSecret)
146+
webhook.verify(unwrapParams.body(), headersMap)
147+
} catch (e: WebhookVerificationException) {
148+
throw LithicWebhookException("Could not verify webhook event signature", e)
149+
}
150+
}
151+
return parsed(unwrapParams.body())
152+
}
153+
127154
class WithRawResponseImpl internal constructor(private val clientOptions: ClientOptions) :
128155
WebhookService.WithRawResponse {
129156

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// File generated from our OpenAPI spec by Stainless.
2+
3+
package com.lithic.api.services.async
4+
5+
import com.lithic.api.TestServerExtension
6+
import com.lithic.api.client.okhttp.LithicOkHttpClientAsync
7+
import com.lithic.api.core.UnwrapWebhookParams
8+
import com.lithic.api.core.http.Headers
9+
import com.lithic.api.errors.LithicWebhookException
10+
import com.standardwebhooks.Webhook
11+
import java.time.Instant
12+
import org.junit.jupiter.api.Test
13+
import org.junit.jupiter.api.assertThrows
14+
import org.junit.jupiter.api.extension.ExtendWith
15+
16+
@ExtendWith(TestServerExtension::class)
17+
internal class WebhookServiceAsyncTest {
18+
19+
@Test
20+
fun parsed() {
21+
val client =
22+
LithicOkHttpClientAsync.builder()
23+
.baseUrl(TestServerExtension.BASE_URL)
24+
.apiKey("My Lithic API Key")
25+
.build()
26+
val webhookServiceAsync = client.webhooks()
27+
28+
val payload =
29+
"{\"event_type\":\"account_holder.created\",\"token\":\"00000000-0000-0000-0000-000000000001\",\"account_token\":\"00000000-0000-0000-0000-000000000001\",\"created\":\"2019-12-27T18:11:19.117Z\",\"required_documents\":[{\"entity_token\":\"182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e\",\"status_reasons\":[\"string\"],\"valid_documents\":[\"string\"]}],\"status\":\"ACCEPTED\",\"status_reason\":[\"string\"]}"
30+
val webhookSecret = "whsec_c2VjcmV0Cg=="
31+
val messageId = "1"
32+
val timestampSeconds = Instant.now().epochSecond
33+
val webhook = Webhook(webhookSecret)
34+
val signature = webhook.sign(messageId, timestampSeconds, payload)
35+
val headers =
36+
Headers.builder()
37+
.putAll(
38+
mapOf(
39+
"webhook-signature" to listOf(signature),
40+
"webhook-id" to listOf(messageId),
41+
"webhook-timestamp" to listOf(timestampSeconds.toString()),
42+
)
43+
)
44+
.build()
45+
46+
webhookServiceAsync.parsed(payload).validate()
47+
48+
// Wrong key should throw
49+
assertThrows<LithicWebhookException> {
50+
val wrongKey = "whsec_aaaaaaaaaa"
51+
webhookServiceAsync.parsed(
52+
UnwrapWebhookParams.builder()
53+
.body(payload)
54+
.headers(headers)
55+
.secret(wrongKey)
56+
.build()
57+
)
58+
}
59+
60+
// Bad signature should throw
61+
assertThrows<LithicWebhookException> {
62+
val badSig = webhook.sign(messageId, timestampSeconds, "some other payload")
63+
val badHeaders =
64+
headers.toBuilder().replace("webhook-signature", listOf(badSig)).build()
65+
webhookServiceAsync.parsed(
66+
UnwrapWebhookParams.builder()
67+
.body(payload)
68+
.headers(badHeaders)
69+
.secret(webhookSecret)
70+
.build()
71+
)
72+
}
73+
74+
// Old timestamp should throw
75+
assertThrows<LithicWebhookException> {
76+
val oldHeaders = headers.toBuilder().replace("webhook-timestamp", listOf("5")).build()
77+
webhookServiceAsync.parsed(
78+
UnwrapWebhookParams.builder()
79+
.body(payload)
80+
.headers(oldHeaders)
81+
.secret(webhookSecret)
82+
.build()
83+
)
84+
}
85+
86+
// Wrong message ID should throw
87+
assertThrows<LithicWebhookException> {
88+
val wrongIdHeaders = headers.toBuilder().replace("webhook-id", listOf("wrong")).build()
89+
webhookServiceAsync.parsed(
90+
UnwrapWebhookParams.builder()
91+
.body(payload)
92+
.headers(wrongIdHeaders)
93+
.secret(webhookSecret)
94+
.build()
95+
)
96+
}
97+
}
98+
}

0 commit comments

Comments
 (0)