Skip to content

Commit ede3f4c

Browse files
feat(api): add webhook signature verification
1 parent 9331148 commit ede3f4c

File tree

9 files changed

+330
-11
lines changed

9 files changed

+330
-11
lines changed

increase-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.increase.api.core
4+
5+
import com.increase.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.increase.api.errors
2+
3+
class IncreaseWebhookException
4+
@JvmOverloads
5+
constructor(message: String? = null, cause: Throwable? = null) : IncreaseException(message, cause)

increase-java-core/src/main/kotlin/com/increase/api/services/async/EventServiceAsync.kt

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

55
import com.increase.api.core.ClientOptions
66
import com.increase.api.core.RequestOptions
7+
import com.increase.api.core.UnwrapWebhookParams
78
import com.increase.api.core.http.HttpResponseFor
89
import com.increase.api.errors.IncreaseInvalidDataException
10+
import com.increase.api.errors.IncreaseWebhookException
911
import com.increase.api.models.events.Event
1012
import com.increase.api.models.events.EventListPageAsync
1113
import com.increase.api.models.events.EventListParams
@@ -85,6 +87,14 @@ interface EventServiceAsync {
8587
*/
8688
fun unwrap(body: String): UnwrapWebhookEvent
8789

90+
/**
91+
* Unwraps a webhook event from its JSON representation.
92+
*
93+
* @throws IncreaseInvalidDataException if the body could not be parsed.
94+
* @throws IncreaseWebhookException if the webhook signature could not be verified
95+
*/
96+
fun unwrap(unwrapParams: UnwrapWebhookParams): UnwrapWebhookEvent
97+
8898
/** A view of [EventServiceAsync] that provides access to raw HTTP responses for each method. */
8999
interface WithRawResponse {
90100

increase-java-core/src/main/kotlin/com/increase/api/services/async/EventServiceAsyncImpl.kt

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package com.increase.api.services.async
44

55
import com.increase.api.core.ClientOptions
66
import com.increase.api.core.RequestOptions
7+
import com.increase.api.core.UnwrapWebhookParams
78
import com.increase.api.core.checkRequired
89
import com.increase.api.core.handlers.errorBodyHandler
910
import com.increase.api.core.handlers.errorHandler
@@ -15,7 +16,6 @@ import com.increase.api.core.http.HttpResponse.Handler
1516
import com.increase.api.core.http.HttpResponseFor
1617
import com.increase.api.core.http.parseable
1718
import com.increase.api.core.prepareAsync
18-
import com.increase.api.errors.IncreaseInvalidDataException
1919
import com.increase.api.models.events.Event
2020
import com.increase.api.models.events.EventListPageAsync
2121
import com.increase.api.models.events.EventListPageResponse
@@ -53,14 +53,12 @@ class EventServiceAsyncImpl internal constructor(private val clientOptions: Clie
5353
// get /events
5454
withRawResponse().list(params, requestOptions).thenApply { it.parse() }
5555

56-
/**
57-
* Unwraps a webhook event from its JSON representation.
58-
*
59-
* @throws IncreaseInvalidDataException if the body could not be parsed.
60-
*/
6156
override fun unwrap(body: String): UnwrapWebhookEvent =
6257
EventServiceImpl(clientOptions).unwrap(body)
6358

59+
override fun unwrap(unwrapParams: UnwrapWebhookParams): UnwrapWebhookEvent =
60+
EventServiceImpl(clientOptions).unwrap(unwrapParams)
61+
6462
class WithRawResponseImpl internal constructor(private val clientOptions: ClientOptions) :
6563
EventServiceAsync.WithRawResponse {
6664

increase-java-core/src/main/kotlin/com/increase/api/services/blocking/EventService.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ package com.increase.api.services.blocking
55
import com.google.errorprone.annotations.MustBeClosed
66
import com.increase.api.core.ClientOptions
77
import com.increase.api.core.RequestOptions
8+
import com.increase.api.core.UnwrapWebhookParams
89
import com.increase.api.core.http.HttpResponseFor
910
import com.increase.api.errors.IncreaseInvalidDataException
11+
import com.increase.api.errors.IncreaseWebhookException
1012
import com.increase.api.models.events.Event
1113
import com.increase.api.models.events.EventListPage
1214
import com.increase.api.models.events.EventListParams
@@ -79,6 +81,14 @@ interface EventService {
7981
*/
8082
fun unwrap(body: String): UnwrapWebhookEvent
8183

84+
/**
85+
* Unwraps a webhook event from its JSON representation.
86+
*
87+
* @throws IncreaseInvalidDataException if the body could not be parsed.
88+
* @throws IncreaseWebhookException if the webhook signature could not be verified
89+
*/
90+
fun unwrap(unwrapParams: UnwrapWebhookParams): UnwrapWebhookEvent
91+
8292
/** A view of [EventService] that provides access to raw HTTP responses for each method. */
8393
interface WithRawResponse {
8494

increase-java-core/src/main/kotlin/com/increase/api/services/blocking/EventServiceImpl.kt

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package com.increase.api.services.blocking
55
import com.fasterxml.jackson.module.kotlin.jacksonTypeRef
66
import com.increase.api.core.ClientOptions
77
import com.increase.api.core.RequestOptions
8+
import com.increase.api.core.UnwrapWebhookParams
89
import com.increase.api.core.checkRequired
910
import com.increase.api.core.handlers.errorBodyHandler
1011
import com.increase.api.core.handlers.errorHandler
@@ -17,12 +18,15 @@ import com.increase.api.core.http.HttpResponseFor
1718
import com.increase.api.core.http.parseable
1819
import com.increase.api.core.prepare
1920
import com.increase.api.errors.IncreaseInvalidDataException
21+
import com.increase.api.errors.IncreaseWebhookException
2022
import com.increase.api.models.events.Event
2123
import com.increase.api.models.events.EventListPage
2224
import com.increase.api.models.events.EventListPageResponse
2325
import com.increase.api.models.events.EventListParams
2426
import com.increase.api.models.events.EventRetrieveParams
2527
import com.increase.api.models.events.UnwrapWebhookEvent
28+
import com.standardwebhooks.Webhook
29+
import com.standardwebhooks.exceptions.WebhookVerificationException
2630
import java.util.function.Consumer
2731
import kotlin.jvm.optionals.getOrNull
2832

@@ -46,18 +50,35 @@ class EventServiceImpl internal constructor(private val clientOptions: ClientOpt
4650
// get /events
4751
withRawResponse().list(params, requestOptions).parse()
4852

49-
/**
50-
* Unwraps a webhook event from its JSON representation.
51-
*
52-
* @throws IncreaseInvalidDataException if the body could not be parsed.
53-
*/
5453
override fun unwrap(body: String): UnwrapWebhookEvent =
5554
try {
5655
clientOptions.jsonMapper.readValue(body, jacksonTypeRef<UnwrapWebhookEvent>())
5756
} catch (e: Exception) {
5857
throw IncreaseInvalidDataException("Error parsing body", e)
5958
}
6059

60+
override fun unwrap(unwrapParams: UnwrapWebhookParams): UnwrapWebhookEvent {
61+
val headers = unwrapParams.headers().getOrNull()
62+
if (headers != null) {
63+
try {
64+
val webhookSecret =
65+
checkRequired(
66+
"webhookSecret",
67+
unwrapParams.secret().getOrNull()
68+
?: clientOptions.webhookSecret().getOrNull(),
69+
)
70+
val headersMap =
71+
headers.names().associateWith { name -> headers.values(name) }.toMap()
72+
73+
val webhook = Webhook(webhookSecret)
74+
webhook.verify(unwrapParams.body(), headersMap)
75+
} catch (e: WebhookVerificationException) {
76+
throw IncreaseWebhookException("Could not verify webhook event signature", e)
77+
}
78+
}
79+
return unwrap(unwrapParams.body())
80+
}
81+
6182
class WithRawResponseImpl internal constructor(private val clientOptions: ClientOptions) :
6283
EventService.WithRawResponse {
6384

increase-java-core/src/test/kotlin/com/increase/api/services/async/EventServiceAsyncTest.kt

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@ package com.increase.api.services.async
44

55
import com.increase.api.TestServerExtension
66
import com.increase.api.client.okhttp.IncreaseOkHttpClientAsync
7+
import com.increase.api.core.UnwrapWebhookParams
8+
import com.increase.api.core.http.Headers
9+
import com.increase.api.errors.IncreaseWebhookException
10+
import com.standardwebhooks.Webhook
11+
import java.time.Instant
712
import org.junit.jupiter.api.Test
13+
import org.junit.jupiter.api.assertThrows
814
import org.junit.jupiter.api.extension.ExtendWith
915

1016
@ExtendWith(TestServerExtension::class)
@@ -39,4 +45,84 @@ internal class EventServiceAsyncTest {
3945
val page = pageFuture.get()
4046
page.response().validate()
4147
}
48+
49+
@Test
50+
fun unwrap() {
51+
val client =
52+
IncreaseOkHttpClientAsync.builder()
53+
.baseUrl(TestServerExtension.BASE_URL)
54+
.apiKey("My API Key")
55+
.build()
56+
val eventServiceAsync = client.events()
57+
58+
val payload =
59+
"{\"id\":\"event_001dzz0r20rzr4zrhrr1364hy80\",\"associated_object_id\":\"account_in71c4amph0vgo2qllky\",\"associated_object_type\":\"account\",\"category\":\"account.created\",\"created_at\":\"2020-01-31T23:59:59Z\",\"type\":\"event\"}"
60+
val webhookSecret = "whsec_c2VjcmV0Cg=="
61+
val messageId = "1"
62+
val timestampSeconds = Instant.now().epochSecond
63+
val webhook = Webhook(webhookSecret)
64+
val signature = webhook.sign(messageId, timestampSeconds, payload)
65+
val headers =
66+
Headers.builder()
67+
.putAll(
68+
mapOf(
69+
"webhook-signature" to listOf(signature),
70+
"webhook-id" to listOf(messageId),
71+
"webhook-timestamp" to listOf(timestampSeconds.toString()),
72+
)
73+
)
74+
.build()
75+
76+
eventServiceAsync.unwrap(payload).validate()
77+
78+
// Wrong key should throw
79+
assertThrows<IncreaseWebhookException> {
80+
val wrongKey = "whsec_aaaaaaaaaa"
81+
eventServiceAsync.unwrap(
82+
UnwrapWebhookParams.builder()
83+
.body(payload)
84+
.headers(headers)
85+
.secret(wrongKey)
86+
.build()
87+
)
88+
}
89+
90+
// Bad signature should throw
91+
assertThrows<IncreaseWebhookException> {
92+
val badSig = webhook.sign(messageId, timestampSeconds, "some other payload")
93+
val badHeaders =
94+
headers.toBuilder().replace("webhook-signature", listOf(badSig)).build()
95+
eventServiceAsync.unwrap(
96+
UnwrapWebhookParams.builder()
97+
.body(payload)
98+
.headers(badHeaders)
99+
.secret(webhookSecret)
100+
.build()
101+
)
102+
}
103+
104+
// Old timestamp should throw
105+
assertThrows<IncreaseWebhookException> {
106+
val oldHeaders = headers.toBuilder().replace("webhook-timestamp", listOf("5")).build()
107+
eventServiceAsync.unwrap(
108+
UnwrapWebhookParams.builder()
109+
.body(payload)
110+
.headers(oldHeaders)
111+
.secret(webhookSecret)
112+
.build()
113+
)
114+
}
115+
116+
// Wrong message ID should throw
117+
assertThrows<IncreaseWebhookException> {
118+
val wrongIdHeaders = headers.toBuilder().replace("webhook-id", listOf("wrong")).build()
119+
eventServiceAsync.unwrap(
120+
UnwrapWebhookParams.builder()
121+
.body(payload)
122+
.headers(wrongIdHeaders)
123+
.secret(webhookSecret)
124+
.build()
125+
)
126+
}
127+
}
42128
}

0 commit comments

Comments
 (0)