From ec840dc86b4c8507f1107cda414d141f099b8770 Mon Sep 17 00:00:00 2001 From: "g.prenaj" Date: Fri, 27 Mar 2026 13:10:40 +0100 Subject: [PATCH 1/2] re create token subscriber --- src/Resources/config/services.xml | 7 + .../PaymentTokenInvalidatedSubscriber.php | 203 ++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 src/Subscribers/PaymentTokenInvalidatedSubscriber.php diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml index eec0aef3..cd7e25c2 100644 --- a/src/Resources/config/services.xml +++ b/src/Resources/config/services.xml @@ -171,6 +171,13 @@ + + + + + + + diff --git a/src/Subscribers/PaymentTokenInvalidatedSubscriber.php b/src/Subscribers/PaymentTokenInvalidatedSubscriber.php new file mode 100644 index 00000000..dc1ec3ea --- /dev/null +++ b/src/Subscribers/PaymentTokenInvalidatedSubscriber.php @@ -0,0 +1,203 @@ + ['onKernelException', 10], + ]; + } + + public function onKernelException(ExceptionEvent $event): void + { + if (!$event->isMainRequest()) { + return; + } + + if (!$this->isTokenInvalidatedException($event->getThrowable())) { + return; + } + + $request = $event->getRequest(); + $paymentToken = $request->query->get('_sw_payment_token'); + + if (!is_string($paymentToken) || $paymentToken === '') { + return; + } + + try { + $redirectUrl = $this->resolveRedirectUrl($paymentToken, $request); + } catch (\Throwable $e) { + $this->logger->warning( + 'Buckaroo: Could not resolve redirect URL for invalidated payment token — leaving default error handling in place', + ['exception' => $e->getMessage()] + ); + return; + } + + $this->logger->info( + 'Buckaroo: Redirecting after invalidated payment token (back-button / double-redirect scenario)', + ['redirectUrl' => $redirectUrl] + ); + + $event->setResponse(new RedirectResponse($redirectUrl)); + $event->stopPropagation(); + } + + private function isTokenInvalidatedException(\Throwable $exception): bool + { + // Shopware 6.4 (TokenInvalidatedException) and 6.5+ (PaymentException) both + // expose getErrorCode() returning the same constant. + if (method_exists($exception, 'getErrorCode')) { + return $exception->getErrorCode() === self::TOKEN_INVALIDATED_CODE; + } + + return str_contains(get_class($exception), 'TokenInvalidated'); + } + + /** + * Decode the JWT payload directly — no parseToken() call, no DB access. + * JWTFactoryV2::parseToken() would throw the same tokenInvalidated exception + * because the record no longer exists in payment_token. The JWT signature is + * still valid; we only need the plaintext claims embedded in the token. + */ + private function resolveRedirectUrl(string $paymentToken, Request $request): string + { + $payload = $this->decodeJwtPayload($paymentToken); + + // 'ful' and 'eul' are stored as relative paths (e.g. /checkout/finish?orderId=...) + $finishUrl = $this->makeAbsolute($payload['ful'] ?? null, $request); + $errorUrl = $this->makeAbsolute($payload['eul'] ?? null, $request); + $transactionId = isset($payload['sub']) && is_string($payload['sub']) ? $payload['sub'] : null; + + if ($transactionId !== null) { + try { + if ($this->isPaymentSuccessful($transactionId)) { + return $finishUrl ?? $this->accountOrdersUrl(); + } + + return $errorUrl ?? $this->accountOrdersUrl(); + } catch (\Throwable $e) { + $this->logger->warning( + 'Buckaroo: Could not determine transaction state for invalidated token — falling back to finishUrl', + ['transactionId' => $transactionId, 'exception' => $e->getMessage()] + ); + } + } + + return $finishUrl ?? $errorUrl ?? $this->accountOrdersUrl(); + } + + /** + * Decode the payload (second) segment of a JWT without signature verification. + * We only need the plaintext claims — the signature was already verified by + * Shopware core before tokenInvalidated was thrown. + * + * @return array + * @throws \InvalidArgumentException if the token is structurally malformed + */ + private function decodeJwtPayload(string $token): array + { + $parts = explode('.', $token); + if (count($parts) !== 3) { + throw new \InvalidArgumentException('Invalid JWT: expected 3 dot-separated parts'); + } + + // JWT uses base64url encoding (RFC 4648 §5) — swap - and _ back to + and / + $decoded = base64_decode(str_replace(['-', '_'], ['+', '/'], $parts[1]), true); + if ($decoded === false) { + throw new \InvalidArgumentException('JWT payload could not be base64-decoded'); + } + + $data = json_decode($decoded, true); + if (!is_array($data)) { + throw new \InvalidArgumentException('JWT payload is not a valid JSON object'); + } + + return $data; + } + + private function makeAbsolute(?string $path, Request $request): ?string + { + if ($path === null || $path === '') { + return null; + } + + if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) { + return $path; + } + + return $request->getSchemeAndHttpHost() . $path; + } + + private function isPaymentSuccessful(string $transactionId): bool + { + $criteria = new Criteria([$transactionId]); + $criteria->addAssociation('stateMachineState'); + + $transaction = $this->orderTransactionRepository + ->search($criteria, Context::createDefaultContext()) + ->first(); + + if ($transaction === null) { + return false; + } + + $state = $transaction->getStateMachineState(); + if ($state === null) { + return false; + } + + return in_array($state->getTechnicalName(), self::SUCCESS_STATES, true); + } + + private function accountOrdersUrl(): string + { + return $this->urlGenerator->generate( + 'frontend.account.order.page', + [], + UrlGeneratorInterface::ABSOLUTE_URL + ); + } +} From 4db1b634f8c699a0c7fe192eb46219633887cb42 Mon Sep 17 00:00:00 2001 From: "g.prenaj" Date: Fri, 27 Mar 2026 14:23:48 +0100 Subject: [PATCH 2/2] add logs --- CHANGELOG.md | 6 +++++- composer.json | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46bb3a83..092e07ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -410,4 +410,8 @@ Compatible from Shopware 6.5.0 up to 6.5.6.1 - Fix: SalesChannelContextServiceDecorator now uses context token from URL on payment return routes (buckaroo/cancel, checkout/finish, /payment/) to restore session when cookies are not sent. - Fix: PaymentContextRestoreSubscriber runs earlier (priority 5) to restore context before Shopware resolves the sales channel. - Fix: PaymentReturnContextSubscriber now appends context token to all storefront redirects (checkout, account), not just checkout/finish. -- Added: PaymentContextCookieSubscriber to explicitly set sw-context-token cookie when restored from URL, enabling use of cookie_samesite: lax without requiring null. \ No newline at end of file +- Added: PaymentContextCookieSubscriber to explicitly set sw-context-token cookie when restored from URL, enabling use of cookie_samesite: lax without requiring null. + +# 3.2.4 + +- BTI-684 Fix: Payment finalize token replay causes error after successful async payment (Wero/mobile) #405 \ No newline at end of file diff --git a/composer.json b/composer.json index c5f4b1db..0a96e68c 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "buckaroo/shopware6", "description": "Buckaroo payment provider plugin for Shopware 6", "type": "shopware-platform-plugin", - "version": "3.2.3", + "version": "3.2.4", "license": "proprietary", "minimum-stability": "stable", "require": {