From 2b8e952eddd4945453bf0c6bb55f38f9345fd5cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Thu, 18 Jun 2026 23:57:31 +0200 Subject: [PATCH 1/3] feat(audit): refresh Stripe webhook endpoint to ensure updated signing secret --- .../payment/config/StripeApi.scala | 188 +++++++----------- 1 file changed, 75 insertions(+), 113 deletions(-) diff --git a/stripe/src/main/scala/app/softnetwork/payment/config/StripeApi.scala b/stripe/src/main/scala/app/softnetwork/payment/config/StripeApi.scala index b30ceb6..ce020ac 100644 --- a/stripe/src/main/scala/app/softnetwork/payment/config/StripeApi.scala +++ b/stripe/src/main/scala/app/softnetwork/payment/config/StripeApi.scala @@ -8,11 +8,7 @@ import com.stripe.Stripe import com.stripe.model.WebhookEndpoint import com.stripe.net.RequestOptions import com.stripe.net.RequestOptions.RequestOptionsBuilder -import com.stripe.param.{ - WebhookEndpointCreateParams, - WebhookEndpointListParams, - WebhookEndpointUpdateParams -} +import com.stripe.param.{WebhookEndpointCreateParams, WebhookEndpointListParams} import org.slf4j.{Logger, LoggerFactory} @@ -126,7 +122,7 @@ object StripeApi { val clientId = provider.providerId val apiKey = provider.providerApiKey - // create / update stripe webhook endpoint + // (re)create stripe webhook endpoint val hash = sha256(provider.clientId) @@ -137,121 +133,87 @@ object StripeApi { val url = s"${config.hooksBaseUrl}?hash=$hash" log.info( - s"Creating / updating Stripe webhook endpoint for provider ${provider.providerId} at ${config.hooksBaseUrl}?hash=*****" + s"Provisioning (delete + recreate) Stripe webhook endpoint for provider ${provider.providerId} at ${config.hooksBaseUrl}?hash=*****" ) import scala.jdk.CollectionConverters._ Try { - ((Option( - WebhookEndpoint - .list( - WebhookEndpointListParams.builder().setLimit(3L).build(), - requestOptions + // Stripe returns a webhook endpoint's signing secret ONLY at creation time — never on + // list / retrieve / update, and stripe-java 26.12.0 exposes no roll-secret API. The only + // way to guarantee that the locally stored secret matches the one Stripe actually uses is + // therefore to (re)create the endpoint and capture the secret it returns. So we always + // delete any endpoint already registered for this provider's URL, then create a fresh one. + // The URL carries the per-client hash, so we only ever match — and delete — THIS + // provider's endpoint(s), never another client's. We materialize the matches before + // deleting so paging is not disturbed by the deletions. + WebhookEndpoint + .list( + WebhookEndpointListParams.builder().setLimit(100L).build(), + requestOptions + ) + .autoPagingIterable() + .asScala + .filter(endpoint => Option(endpoint.getUrl).exists(_.contains(url))) + .toList + .foreach { endpoint => + log.info( + s"Deleting existing Stripe webhook endpoint ${endpoint.getId} to refresh its signing secret" ) - .getData - ) match { - case Some(data) => - data.asScala.headOption - case _ => - None - }) match { - case Some(webhookEndpoint) => - log.info(s"Webhook endpoint found: ${webhookEndpoint.getId}") - loadSecret(hash) match { - case None => - // Not deleting the webhook endpoint, as it may be used by other clients - // Try(webhookEndpoint.delete(requestOptions)) - None + Try(endpoint.delete(requestOptions)) match { + case Failure(f) => + log.warn( + s"Failed to delete Stripe webhook endpoint ${endpoint.getId}: ${f.getMessage}" + ) case _ => - Try( - webhookEndpoint - .update( - WebhookEndpointUpdateParams - .builder() - .addEnabledEvent( - WebhookEndpointUpdateParams.EnabledEvent.ACCOUNT__UPDATED - ) - .addEnabledEvent( - WebhookEndpointUpdateParams.EnabledEvent.PERSON__UPDATED - ) - .addEnabledEvent( - WebhookEndpointUpdateParams.EnabledEvent.INVOICE__PAYMENT_SUCCEEDED - ) - .addEnabledEvent( - WebhookEndpointUpdateParams.EnabledEvent.INVOICE__PAYMENT_FAILED - ) - .addEnabledEvent( - WebhookEndpointUpdateParams.EnabledEvent.CUSTOMER__SUBSCRIPTION__DELETED - ) - .addEnabledEvent( - WebhookEndpointUpdateParams.EnabledEvent.CUSTOMER__SUBSCRIPTION__UPDATED - ) - .addEnabledEvent( - WebhookEndpointUpdateParams.EnabledEvent.CUSTOMER__UPDATED - ) - .addEnabledEvent( - WebhookEndpointUpdateParams.EnabledEvent.PAYMENT_METHOD__ATTACHED - ) - .addEnabledEvent( - WebhookEndpointUpdateParams.EnabledEvent.PAYMENT_METHOD__DETACHED - ) - .setUrl(url) - .build(), - requestOptions - ) - .getSecret // update secret if changed - ).toOption } - case _ => - None - }).getOrElse { - WebhookEndpoint - .create( - WebhookEndpointCreateParams - .builder() - .addEnabledEvent( - WebhookEndpointCreateParams.EnabledEvent.ACCOUNT__UPDATED - ) - .addEnabledEvent( - WebhookEndpointCreateParams.EnabledEvent.PERSON__UPDATED - ) - .addEnabledEvent( - WebhookEndpointCreateParams.EnabledEvent.INVOICE__PAYMENT_SUCCEEDED - ) - .addEnabledEvent( - WebhookEndpointCreateParams.EnabledEvent.INVOICE__PAYMENT_FAILED - ) - .addEnabledEvent( - WebhookEndpointCreateParams.EnabledEvent.CUSTOMER__SUBSCRIPTION__DELETED - ) - .addEnabledEvent( - WebhookEndpointCreateParams.EnabledEvent.CUSTOMER__SUBSCRIPTION__UPDATED - ) - .addEnabledEvent( - WebhookEndpointCreateParams.EnabledEvent.CUSTOMER__UPDATED - ) - .addEnabledEvent( - WebhookEndpointCreateParams.EnabledEvent.PAYMENT_METHOD__ATTACHED - ) - .addEnabledEvent( - WebhookEndpointCreateParams.EnabledEvent.PAYMENT_METHOD__DETACHED - ) - .setUrl(url) - .setApiVersion(WebhookEndpointCreateParams.ApiVersion.VERSION_2024_06_20) - // connect=true -> events from connected accounts only; connect=false -> events from - // the platform account (customer.updated, invoice.*, subscription.*, payment_method.*). - // Driven by payment.stripe.connected (default false). NOTE: `connect` is immutable - // after endpoint creation, so flipping this requires deleting the existing endpoint - // so it is recreated. FUTURE: support several webhook endpoints per provider (one per - // scope) — not on the roadmap and not needed by the license-server (single endpoint, - // connected=false, suffices to receive customer.updated for org sync). - .setConnect(config.connected) - .build(), - requestOptions - ) - .getSecret - } + } + + WebhookEndpoint + .create( + WebhookEndpointCreateParams + .builder() + .addEnabledEvent( + WebhookEndpointCreateParams.EnabledEvent.ACCOUNT__UPDATED + ) + .addEnabledEvent( + WebhookEndpointCreateParams.EnabledEvent.PERSON__UPDATED + ) + .addEnabledEvent( + WebhookEndpointCreateParams.EnabledEvent.INVOICE__PAYMENT_SUCCEEDED + ) + .addEnabledEvent( + WebhookEndpointCreateParams.EnabledEvent.INVOICE__PAYMENT_FAILED + ) + .addEnabledEvent( + WebhookEndpointCreateParams.EnabledEvent.CUSTOMER__SUBSCRIPTION__DELETED + ) + .addEnabledEvent( + WebhookEndpointCreateParams.EnabledEvent.CUSTOMER__SUBSCRIPTION__UPDATED + ) + .addEnabledEvent( + WebhookEndpointCreateParams.EnabledEvent.CUSTOMER__UPDATED + ) + .addEnabledEvent( + WebhookEndpointCreateParams.EnabledEvent.PAYMENT_METHOD__ATTACHED + ) + .addEnabledEvent( + WebhookEndpointCreateParams.EnabledEvent.PAYMENT_METHOD__DETACHED + ) + .setUrl(url) + .setApiVersion(WebhookEndpointCreateParams.ApiVersion.VERSION_2024_06_20) + // connect=true -> events from connected accounts only; connect=false -> events from + // the platform account (customer.updated, invoice.*, subscription.*, payment_method.*). + // Driven by payment.stripe.connected (default false). NOTE: `connect` is immutable + // after endpoint creation, so flipping this requires deleting the existing endpoint + // so it is recreated. FUTURE: support several webhook endpoints per provider (one per + // scope) — not on the roadmap and not needed by the license-server (single endpoint, + // connected=false, suffices to receive customer.updated for org sync). + .setConnect(config.connected) + .build(), + requestOptions + ) + .getSecret } match { case Success(secret) => From 9613039272eae46478426460cd931205ab9ada08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Thu, 18 Jun 2026 23:57:43 +0200 Subject: [PATCH 2/3] feat(audit): eagerly initialize Stripe API at startup and log initialization errors --- .../softnetwork/payment/spi/StripeProvider.scala | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/stripe/src/main/scala/app/softnetwork/payment/spi/StripeProvider.scala b/stripe/src/main/scala/app/softnetwork/payment/spi/StripeProvider.scala index f8dba43..e509151 100644 --- a/stripe/src/main/scala/app/softnetwork/payment/spi/StripeProvider.scala +++ b/stripe/src/main/scala/app/softnetwork/payment/spi/StripeProvider.scala @@ -15,6 +15,8 @@ import com.typesafe.config.Config import org.json4s.Formats import org.slf4j.Logger +import scala.util.{Failure, Try} + trait StripeContext extends PaymentContext { override implicit def config: StripeApi.Config @@ -51,6 +53,8 @@ class StripeProviderFactory extends PaymentProviderSpi { @volatile private[this] var _config: Option[StripeApi.Config] = None + private[this] lazy val log: Logger = org.slf4j.LoggerFactory.getLogger(getClass) + override def providerType: Provider.ProviderType = Provider.ProviderType.STRIPE override def paymentProvider(p: Client.Provider): StripeProvider = { @@ -64,7 +68,17 @@ class StripeProviderFactory extends PaymentProviderSpi { override def softPaymentProvider(config: Config): Client.Provider = { val stripeConfig = StripeSettings(config).StripeApiConfig _config = Some(stripeConfig) - stripeConfig.softPayProvider + val provider = stripeConfig.softPayProvider + // Eagerly initialize the Stripe API at application startup so the platform webhook endpoint + // (and its signing secret) is provisioned up-front — before any payment operation or inbound + // webhook event — instead of lazily on first use. Guarded so a transient Stripe error does not + // prevent the application from starting; StripeApi() is retried on first lazy use. + Try(StripeApi()(provider, stripeConfig)) match { + case Failure(f) => + log.warn(s"Failed to initialize Stripe API at startup: ${f.getMessage}") + case _ => + } + provider } override def hooksDirectives(implicit From 161f1f4a89edb9ad016e88c93a2d5a669a89f149 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Thu, 18 Jun 2026 23:57:56 +0200 Subject: [PATCH 3/3] feat(audit): update payment configuration to support environment variable for base URL --- stripe/src/test/resources/reference.conf | 1 + 1 file changed, 1 insertion(+) diff --git a/stripe/src/test/resources/reference.conf b/stripe/src/test/resources/reference.conf index 2500aad..c04066e 100644 --- a/stripe/src/test/resources/reference.conf +++ b/stripe/src/test/resources/reference.conf @@ -1,4 +1,5 @@ payment{ baseUrl = "http://www.softnetwork.fr:"${softnetwork.api.server.port}"/"${softnetwork.api.server.root-path} + baseUrl = ${?PAYMENT_BASE_URL} base-url = ${payment.baseUrl} } \ No newline at end of file