diff --git a/.release-please-manifest.json b/.release-please-manifest.json index a7230d955..92be4f76e 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.424.0" + ".": "0.425.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 25d8b3fb0..fbcb1d502 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.425.0 (2026-02-06) + +Full Changelog: [v0.424.0...v0.425.0](https://github.com/Increase/increase-java/compare/v0.424.0...v0.425.0) + +### Features + +* **api:** add webhook signature verification ([ede3f4c](https://github.com/Increase/increase-java/commit/ede3f4cc63647edcebf4426df3198a6c2a27eda6)) + ## 0.424.0 (2026-02-06) Full Changelog: [v0.423.0...v0.424.0](https://github.com/Increase/increase-java/compare/v0.423.0...v0.424.0) diff --git a/README.md b/README.md index c816b1153..9c832c37c 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ -[![Maven Central](https://img.shields.io/maven-central/v/com.increase.api/increase-java)](https://central.sonatype.com/artifact/com.increase.api/increase-java/0.424.0) -[![javadoc](https://javadoc.io/badge2/com.increase.api/increase-java/0.424.0/javadoc.svg)](https://javadoc.io/doc/com.increase.api/increase-java/0.424.0) +[![Maven Central](https://img.shields.io/maven-central/v/com.increase.api/increase-java)](https://central.sonatype.com/artifact/com.increase.api/increase-java/0.425.0) +[![javadoc](https://javadoc.io/badge2/com.increase.api/increase-java/0.425.0/javadoc.svg)](https://javadoc.io/doc/com.increase.api/increase-java/0.425.0) @@ -13,7 +13,7 @@ The Increase Java SDK is similar to the Increase Kotlin SDK but with minor diffe -The REST API documentation can be found on [increase.com](https://increase.com/documentation). Javadocs are available on [javadoc.io](https://javadoc.io/doc/com.increase.api/increase-java/0.424.0). +The REST API documentation can be found on [increase.com](https://increase.com/documentation). Javadocs are available on [javadoc.io](https://javadoc.io/doc/com.increase.api/increase-java/0.425.0). @@ -24,7 +24,7 @@ The REST API documentation can be found on [increase.com](https://increase.com/d ### Gradle ```kotlin -implementation("com.increase.api:increase-java:0.424.0") +implementation("com.increase.api:increase-java:0.425.0") ``` ### Maven @@ -33,7 +33,7 @@ implementation("com.increase.api:increase-java:0.424.0") com.increase.api increase-java - 0.424.0 + 0.425.0 ``` diff --git a/build.gradle.kts b/build.gradle.kts index de8a27201..1ccde6b35 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,7 +8,7 @@ repositories { allprojects { group = "com.increase.api" - version = "0.424.0" // x-release-please-version + version = "0.425.0" // x-release-please-version } subprojects { diff --git a/increase-java-core/build.gradle.kts b/increase-java-core/build.gradle.kts index e5f90d187..8f3caebe6 100644 --- a/increase-java-core/build.gradle.kts +++ b/increase-java-core/build.gradle.kts @@ -22,6 +22,7 @@ dependencies { api("com.fasterxml.jackson.core:jackson-core:2.18.2") api("com.fasterxml.jackson.core:jackson-databind:2.18.2") api("com.google.errorprone:error_prone_annotations:2.33.0") + api("com.standardwebhooks:standardwebhooks:1.1.0") implementation("com.fasterxml.jackson.core:jackson-annotations:2.18.2") implementation("com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.18.2") diff --git a/increase-java-core/src/main/kotlin/com/increase/api/core/UnwrapWebhookParams.kt b/increase-java-core/src/main/kotlin/com/increase/api/core/UnwrapWebhookParams.kt new file mode 100644 index 000000000..57e03c068 --- /dev/null +++ b/increase-java-core/src/main/kotlin/com/increase/api/core/UnwrapWebhookParams.kt @@ -0,0 +1,102 @@ +// File generated from our OpenAPI spec by Stainless. + +package com.increase.api.core + +import com.increase.api.core.http.Headers +import java.util.Objects +import java.util.Optional +import kotlin.jvm.optionals.getOrNull + +class UnwrapWebhookParams +private constructor( + private val body: String, + private val headers: Headers?, + private val secret: String?, +) { + + /** The raw JSON body of the webhook request. */ + fun body(): String = body + + /** The headers from the webhook request. */ + fun headers(): Optional = Optional.ofNullable(headers) + + /** The secret used to verify the webhook signature. */ + fun secret(): Optional = Optional.ofNullable(secret) + + fun toBuilder() = Builder().from(this) + + companion object { + + /** + * Returns a mutable builder for constructing an instance of [UnwrapWebhookParams]. + * + * The following fields are required: + * ```java + * .body() + * ``` + */ + @JvmStatic fun builder() = Builder() + } + + /** A builder for [UnwrapWebhookParams]. */ + class Builder internal constructor() { + + private var body: String? = null + private var headers: Headers? = null + private var secret: String? = null + + @JvmSynthetic + internal fun from(unwrapWebhookParams: UnwrapWebhookParams) = apply { + body = unwrapWebhookParams.body + headers = unwrapWebhookParams.headers + secret = unwrapWebhookParams.secret + } + + /** The raw JSON body of the webhook request. */ + fun body(body: String) = apply { this.body = body } + + /** The headers from the webhook request. */ + fun headers(headers: Headers?) = apply { this.headers = headers } + + /** Alias for calling [Builder.headers] with `headers.orElse(null)`. */ + fun headers(headers: Optional) = headers(headers.getOrNull()) + + /** The secret used to verify the webhook signature. */ + fun secret(secret: String?) = apply { this.secret = secret } + + /** Alias for calling [Builder.secret] with `secret.orElse(null)`. */ + fun secret(secret: Optional) = secret(secret.getOrNull()) + + /** + * Returns an immutable instance of [UnwrapWebhookParams]. + * + * Further updates to this [Builder] will not mutate the returned instance. + * + * The following fields are required: + * ```java + * .body() + * ``` + * + * @throws IllegalStateException if any required field is unset. + */ + fun build(): UnwrapWebhookParams = + UnwrapWebhookParams(checkRequired("body", body), headers, secret) + } + + override fun equals(other: Any?): Boolean { + if (this === other) { + return true + } + + return other is UnwrapWebhookParams && + body == other.body && + headers == other.headers && + secret == other.secret + } + + private val hashCode: Int by lazy { Objects.hash(body, headers, secret) } + + override fun hashCode(): Int = hashCode + + override fun toString() = "UnwrapWebhookParams{body=$body, headers=$headers, secret=$secret}" +} diff --git a/increase-java-core/src/main/kotlin/com/increase/api/errors/IncreaseWebhookException.kt b/increase-java-core/src/main/kotlin/com/increase/api/errors/IncreaseWebhookException.kt new file mode 100644 index 000000000..71c91e06b --- /dev/null +++ b/increase-java-core/src/main/kotlin/com/increase/api/errors/IncreaseWebhookException.kt @@ -0,0 +1,5 @@ +package com.increase.api.errors + +class IncreaseWebhookException +@JvmOverloads +constructor(message: String? = null, cause: Throwable? = null) : IncreaseException(message, cause) diff --git a/increase-java-core/src/main/kotlin/com/increase/api/services/async/EventServiceAsync.kt b/increase-java-core/src/main/kotlin/com/increase/api/services/async/EventServiceAsync.kt index 6dbc606a4..73a7b74b7 100644 --- a/increase-java-core/src/main/kotlin/com/increase/api/services/async/EventServiceAsync.kt +++ b/increase-java-core/src/main/kotlin/com/increase/api/services/async/EventServiceAsync.kt @@ -4,8 +4,10 @@ package com.increase.api.services.async import com.increase.api.core.ClientOptions import com.increase.api.core.RequestOptions +import com.increase.api.core.UnwrapWebhookParams import com.increase.api.core.http.HttpResponseFor import com.increase.api.errors.IncreaseInvalidDataException +import com.increase.api.errors.IncreaseWebhookException import com.increase.api.models.events.Event import com.increase.api.models.events.EventListPageAsync import com.increase.api.models.events.EventListParams @@ -85,6 +87,14 @@ interface EventServiceAsync { */ fun unwrap(body: String): UnwrapWebhookEvent + /** + * Unwraps a webhook event from its JSON representation. + * + * @throws IncreaseInvalidDataException if the body could not be parsed. + * @throws IncreaseWebhookException if the webhook signature could not be verified + */ + fun unwrap(unwrapParams: UnwrapWebhookParams): UnwrapWebhookEvent + /** A view of [EventServiceAsync] that provides access to raw HTTP responses for each method. */ interface WithRawResponse { diff --git a/increase-java-core/src/main/kotlin/com/increase/api/services/async/EventServiceAsyncImpl.kt b/increase-java-core/src/main/kotlin/com/increase/api/services/async/EventServiceAsyncImpl.kt index 3c4229241..8bc419796 100644 --- a/increase-java-core/src/main/kotlin/com/increase/api/services/async/EventServiceAsyncImpl.kt +++ b/increase-java-core/src/main/kotlin/com/increase/api/services/async/EventServiceAsyncImpl.kt @@ -4,6 +4,7 @@ package com.increase.api.services.async import com.increase.api.core.ClientOptions import com.increase.api.core.RequestOptions +import com.increase.api.core.UnwrapWebhookParams import com.increase.api.core.checkRequired import com.increase.api.core.handlers.errorBodyHandler import com.increase.api.core.handlers.errorHandler @@ -15,7 +16,6 @@ import com.increase.api.core.http.HttpResponse.Handler import com.increase.api.core.http.HttpResponseFor import com.increase.api.core.http.parseable import com.increase.api.core.prepareAsync -import com.increase.api.errors.IncreaseInvalidDataException import com.increase.api.models.events.Event import com.increase.api.models.events.EventListPageAsync import com.increase.api.models.events.EventListPageResponse @@ -53,14 +53,12 @@ class EventServiceAsyncImpl internal constructor(private val clientOptions: Clie // get /events withRawResponse().list(params, requestOptions).thenApply { it.parse() } - /** - * Unwraps a webhook event from its JSON representation. - * - * @throws IncreaseInvalidDataException if the body could not be parsed. - */ override fun unwrap(body: String): UnwrapWebhookEvent = EventServiceImpl(clientOptions).unwrap(body) + override fun unwrap(unwrapParams: UnwrapWebhookParams): UnwrapWebhookEvent = + EventServiceImpl(clientOptions).unwrap(unwrapParams) + class WithRawResponseImpl internal constructor(private val clientOptions: ClientOptions) : EventServiceAsync.WithRawResponse { diff --git a/increase-java-core/src/main/kotlin/com/increase/api/services/blocking/EventService.kt b/increase-java-core/src/main/kotlin/com/increase/api/services/blocking/EventService.kt index 737406268..ed0f3edfa 100644 --- a/increase-java-core/src/main/kotlin/com/increase/api/services/blocking/EventService.kt +++ b/increase-java-core/src/main/kotlin/com/increase/api/services/blocking/EventService.kt @@ -5,8 +5,10 @@ package com.increase.api.services.blocking import com.google.errorprone.annotations.MustBeClosed import com.increase.api.core.ClientOptions import com.increase.api.core.RequestOptions +import com.increase.api.core.UnwrapWebhookParams import com.increase.api.core.http.HttpResponseFor import com.increase.api.errors.IncreaseInvalidDataException +import com.increase.api.errors.IncreaseWebhookException import com.increase.api.models.events.Event import com.increase.api.models.events.EventListPage import com.increase.api.models.events.EventListParams @@ -79,6 +81,14 @@ interface EventService { */ fun unwrap(body: String): UnwrapWebhookEvent + /** + * Unwraps a webhook event from its JSON representation. + * + * @throws IncreaseInvalidDataException if the body could not be parsed. + * @throws IncreaseWebhookException if the webhook signature could not be verified + */ + fun unwrap(unwrapParams: UnwrapWebhookParams): UnwrapWebhookEvent + /** A view of [EventService] that provides access to raw HTTP responses for each method. */ interface WithRawResponse { diff --git a/increase-java-core/src/main/kotlin/com/increase/api/services/blocking/EventServiceImpl.kt b/increase-java-core/src/main/kotlin/com/increase/api/services/blocking/EventServiceImpl.kt index 59c35d52b..67b8ae9a7 100644 --- a/increase-java-core/src/main/kotlin/com/increase/api/services/blocking/EventServiceImpl.kt +++ b/increase-java-core/src/main/kotlin/com/increase/api/services/blocking/EventServiceImpl.kt @@ -5,6 +5,7 @@ package com.increase.api.services.blocking import com.fasterxml.jackson.module.kotlin.jacksonTypeRef import com.increase.api.core.ClientOptions import com.increase.api.core.RequestOptions +import com.increase.api.core.UnwrapWebhookParams import com.increase.api.core.checkRequired import com.increase.api.core.handlers.errorBodyHandler import com.increase.api.core.handlers.errorHandler @@ -17,12 +18,15 @@ import com.increase.api.core.http.HttpResponseFor import com.increase.api.core.http.parseable import com.increase.api.core.prepare import com.increase.api.errors.IncreaseInvalidDataException +import com.increase.api.errors.IncreaseWebhookException import com.increase.api.models.events.Event import com.increase.api.models.events.EventListPage import com.increase.api.models.events.EventListPageResponse import com.increase.api.models.events.EventListParams import com.increase.api.models.events.EventRetrieveParams import com.increase.api.models.events.UnwrapWebhookEvent +import com.standardwebhooks.Webhook +import com.standardwebhooks.exceptions.WebhookVerificationException import java.util.function.Consumer import kotlin.jvm.optionals.getOrNull @@ -46,11 +50,6 @@ class EventServiceImpl internal constructor(private val clientOptions: ClientOpt // get /events withRawResponse().list(params, requestOptions).parse() - /** - * Unwraps a webhook event from its JSON representation. - * - * @throws IncreaseInvalidDataException if the body could not be parsed. - */ override fun unwrap(body: String): UnwrapWebhookEvent = try { clientOptions.jsonMapper.readValue(body, jacksonTypeRef()) @@ -58,6 +57,28 @@ class EventServiceImpl internal constructor(private val clientOptions: ClientOpt throw IncreaseInvalidDataException("Error parsing body", e) } + override fun unwrap(unwrapParams: UnwrapWebhookParams): UnwrapWebhookEvent { + val headers = unwrapParams.headers().getOrNull() + if (headers != null) { + try { + val webhookSecret = + checkRequired( + "webhookSecret", + unwrapParams.secret().getOrNull() + ?: clientOptions.webhookSecret().getOrNull(), + ) + val headersMap = + headers.names().associateWith { name -> headers.values(name) }.toMap() + + val webhook = Webhook(webhookSecret) + webhook.verify(unwrapParams.body(), headersMap) + } catch (e: WebhookVerificationException) { + throw IncreaseWebhookException("Could not verify webhook event signature", e) + } + } + return unwrap(unwrapParams.body()) + } + class WithRawResponseImpl internal constructor(private val clientOptions: ClientOptions) : EventService.WithRawResponse { diff --git a/increase-java-core/src/test/kotlin/com/increase/api/services/async/EventServiceAsyncTest.kt b/increase-java-core/src/test/kotlin/com/increase/api/services/async/EventServiceAsyncTest.kt index 8cb73f7e3..4c761a5b8 100644 --- a/increase-java-core/src/test/kotlin/com/increase/api/services/async/EventServiceAsyncTest.kt +++ b/increase-java-core/src/test/kotlin/com/increase/api/services/async/EventServiceAsyncTest.kt @@ -4,7 +4,13 @@ package com.increase.api.services.async import com.increase.api.TestServerExtension import com.increase.api.client.okhttp.IncreaseOkHttpClientAsync +import com.increase.api.core.UnwrapWebhookParams +import com.increase.api.core.http.Headers +import com.increase.api.errors.IncreaseWebhookException +import com.standardwebhooks.Webhook +import java.time.Instant import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.extension.ExtendWith @ExtendWith(TestServerExtension::class) @@ -39,4 +45,84 @@ internal class EventServiceAsyncTest { val page = pageFuture.get() page.response().validate() } + + @Test + fun unwrap() { + val client = + IncreaseOkHttpClientAsync.builder() + .baseUrl(TestServerExtension.BASE_URL) + .apiKey("My API Key") + .build() + val eventServiceAsync = client.events() + + val payload = + "{\"id\":\"event_001dzz0r20rzr4zrhrr1364hy80\",\"associated_object_id\":\"account_in71c4amph0vgo2qllky\",\"associated_object_type\":\"account\",\"category\":\"account.created\",\"created_at\":\"2020-01-31T23:59:59Z\",\"type\":\"event\"}" + val webhookSecret = "whsec_c2VjcmV0Cg==" + val messageId = "1" + val timestampSeconds = Instant.now().epochSecond + val webhook = Webhook(webhookSecret) + val signature = webhook.sign(messageId, timestampSeconds, payload) + val headers = + Headers.builder() + .putAll( + mapOf( + "webhook-signature" to listOf(signature), + "webhook-id" to listOf(messageId), + "webhook-timestamp" to listOf(timestampSeconds.toString()), + ) + ) + .build() + + eventServiceAsync.unwrap(payload).validate() + + // Wrong key should throw + assertThrows { + val wrongKey = "whsec_aaaaaaaaaa" + eventServiceAsync.unwrap( + UnwrapWebhookParams.builder() + .body(payload) + .headers(headers) + .secret(wrongKey) + .build() + ) + } + + // Bad signature should throw + assertThrows { + val badSig = webhook.sign(messageId, timestampSeconds, "some other payload") + val badHeaders = + headers.toBuilder().replace("webhook-signature", listOf(badSig)).build() + eventServiceAsync.unwrap( + UnwrapWebhookParams.builder() + .body(payload) + .headers(badHeaders) + .secret(webhookSecret) + .build() + ) + } + + // Old timestamp should throw + assertThrows { + val oldHeaders = headers.toBuilder().replace("webhook-timestamp", listOf("5")).build() + eventServiceAsync.unwrap( + UnwrapWebhookParams.builder() + .body(payload) + .headers(oldHeaders) + .secret(webhookSecret) + .build() + ) + } + + // Wrong message ID should throw + assertThrows { + val wrongIdHeaders = headers.toBuilder().replace("webhook-id", listOf("wrong")).build() + eventServiceAsync.unwrap( + UnwrapWebhookParams.builder() + .body(payload) + .headers(wrongIdHeaders) + .secret(webhookSecret) + .build() + ) + } + } } diff --git a/increase-java-core/src/test/kotlin/com/increase/api/services/blocking/EventServiceTest.kt b/increase-java-core/src/test/kotlin/com/increase/api/services/blocking/EventServiceTest.kt index b3cabcbbb..f263fd80c 100644 --- a/increase-java-core/src/test/kotlin/com/increase/api/services/blocking/EventServiceTest.kt +++ b/increase-java-core/src/test/kotlin/com/increase/api/services/blocking/EventServiceTest.kt @@ -4,7 +4,13 @@ package com.increase.api.services.blocking import com.increase.api.TestServerExtension import com.increase.api.client.okhttp.IncreaseOkHttpClient +import com.increase.api.core.UnwrapWebhookParams +import com.increase.api.core.http.Headers +import com.increase.api.errors.IncreaseWebhookException +import com.standardwebhooks.Webhook +import java.time.Instant import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.extension.ExtendWith @ExtendWith(TestServerExtension::class) @@ -37,4 +43,84 @@ internal class EventServiceTest { page.response().validate() } + + @Test + fun unwrap() { + val client = + IncreaseOkHttpClient.builder() + .baseUrl(TestServerExtension.BASE_URL) + .apiKey("My API Key") + .build() + val eventService = client.events() + + val payload = + "{\"id\":\"event_001dzz0r20rzr4zrhrr1364hy80\",\"associated_object_id\":\"account_in71c4amph0vgo2qllky\",\"associated_object_type\":\"account\",\"category\":\"account.created\",\"created_at\":\"2020-01-31T23:59:59Z\",\"type\":\"event\"}" + val webhookSecret = "whsec_c2VjcmV0Cg==" + val messageId = "1" + val timestampSeconds = Instant.now().epochSecond + val webhook = Webhook(webhookSecret) + val signature = webhook.sign(messageId, timestampSeconds, payload) + val headers = + Headers.builder() + .putAll( + mapOf( + "webhook-signature" to listOf(signature), + "webhook-id" to listOf(messageId), + "webhook-timestamp" to listOf(timestampSeconds.toString()), + ) + ) + .build() + + eventService.unwrap(payload).validate() + + // Wrong key should throw + assertThrows { + val wrongKey = "whsec_aaaaaaaaaa" + eventService.unwrap( + UnwrapWebhookParams.builder() + .body(payload) + .headers(headers) + .secret(wrongKey) + .build() + ) + } + + // Bad signature should throw + assertThrows { + val badSig = webhook.sign(messageId, timestampSeconds, "some other payload") + val badHeaders = + headers.toBuilder().replace("webhook-signature", listOf(badSig)).build() + eventService.unwrap( + UnwrapWebhookParams.builder() + .body(payload) + .headers(badHeaders) + .secret(webhookSecret) + .build() + ) + } + + // Old timestamp should throw + assertThrows { + val oldHeaders = headers.toBuilder().replace("webhook-timestamp", listOf("5")).build() + eventService.unwrap( + UnwrapWebhookParams.builder() + .body(payload) + .headers(oldHeaders) + .secret(webhookSecret) + .build() + ) + } + + // Wrong message ID should throw + assertThrows { + val wrongIdHeaders = headers.toBuilder().replace("webhook-id", listOf("wrong")).build() + eventService.unwrap( + UnwrapWebhookParams.builder() + .body(payload) + .headers(wrongIdHeaders) + .secret(webhookSecret) + .build() + ) + } + } }