Skip to content

Commit

Permalink
Merge pull request #31 from shopware/next-36521/in-app-purchases-jwt
Browse files Browse the repository at this point in the history
NEXT-36521 - in app purchases JWT
  • Loading branch information
Bird87ZA authored Dec 13, 2024
2 parents 21739f6 + 909ac35 commit ebace4c
Show file tree
Hide file tree
Showing 42 changed files with 1,639 additions and 137 deletions.
6 changes: 4 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"php": "^8.1",
"lcobucci/clock": "^3",
"lcobucci/jwt": "^4.0 || ^5.0",
"phpseclib/phpseclib": "3.0.42",
"php-http/discovery": "^1.17",
"psr/clock-implementation": "*",
"psr/event-dispatcher": "^1.0",
Expand All @@ -27,11 +28,12 @@
"psr/http-factory": "^1.0",
"psr/http-factory-implementation": "*",
"psr/http-message": "^1.0 || ^2.0",
"psr/simple-cache": "^3.0"
"psr/simple-cache": "^3.0",
"strobotti/php-jwk": "^1.4"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.16",
"infection/infection": "^0.26.21",
"infection/infection": "^0.29",
"nyholm/psr7": "^1.7.0",
"nyholm/psr7-server": "^1.0",
"php-http/curl-client": "^2.2",
Expand Down
11 changes: 9 additions & 2 deletions examples/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,16 @@
use Shopware\App\SDK\AppLifecycle;
use Shopware\App\SDK\Authentication\ResponseSigner;
use Shopware\App\SDK\Context\ContextResolver;
use Shopware\App\SDK\Context\InAppPurchase\InAppPurchaseProvider;
use Shopware\App\SDK\Context\InAppPurchase\SBPStoreKeyFetcher;
use Shopware\App\SDK\HttpClient\ClientFactory;
use Shopware\App\SDK\Response\ActionButtonResponse;
use Shopware\App\SDK\Response\PaymentResponse;
use Shopware\App\SDK\Registration\RegistrationService;
use Shopware\App\SDK\Shop\ShopResolver;
use Shopware\App\SDK\TaxProvider\CalculatedTax;
use Shopware\App\SDK\TaxProvider\TaxProviderResponseBuilder;
use Shopware\App\SDK\Test\MockShop;

require __DIR__ . '/../vendor/autoload.php';
require __DIR__ . '/helper.php';
Expand All @@ -38,7 +42,10 @@
$registrationService = new RegistrationService($app, $fileShopRepository);
$shopResolver = new ShopResolver($fileShopRepository);
$appLifecycle = new AppLifecycle($registrationService, $shopResolver, $fileShopRepository);
$contextResolver = new ContextResolver();
$inAppPurchaseProvider = new InAppPurchaseProvider(new SBPStoreKeyFetcher(
(new ClientFactory())->createClient(new MockShop('shopId', 'shopUrl', 'shopSecret'))
));
$contextResolver = new ContextResolver($inAppPurchaseProvider);
$signer = new ResponseSigner();

if (str_starts_with($serverRequest->getUri()->getPath(), '/register/authorize')) {
Expand Down Expand Up @@ -116,7 +123,7 @@
$signer = new ResponseSigner();

send($signer->signResponse(PaymentResponse::paid(), $shop));
} elseif(str_starts_with($serverRequest->getUri()->getPath(), '/module/test')) {
} elseif (str_starts_with($serverRequest->getUri()->getPath(), '/module/test')) {
$shop = $shopResolver->resolveShop($serverRequest);
$module = $contextResolver->assembleModule($serverRequest, $shop);

Expand Down
8 changes: 8 additions & 0 deletions infection.json5
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
},
"mutators": {
"@default": true,
"LogicalOrAllSubExprNegation": false, // these mutants are false friends
"LogicalOrSingleSubExprNegation": false, // these mutants are false friends
"CastFloat": {
ignore: [
'Shopware\\App\\SDK\\Context\\SalesChannelContext\\Currency::getTaxFreeFrom'
Expand All @@ -31,5 +33,11 @@
'Shopware\\App\\SDK\\Shop\\ShopResolver::resolveFromSource'
]
},
"TrueValue": {
ignore: [
// The retry mechanism is tested, the mutation is a false friend
'Shopware\\App\\SDK\\Context\\InAppPurchase\\InAppPurchaseProvider::decodePurchases',
]
}
}
}
11 changes: 9 additions & 2 deletions src/Context/ActionSource.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,20 @@

namespace Shopware\App\SDK\Context;

use Shopware\App\SDK\Context\InAppPurchase\InAppPurchase;
use Shopware\App\SDK\Framework\Collection;

class ActionSource
{
/**
* @param string $url The shop url
* @param string $appVersion The installed App version
* @param Collection<InAppPurchase> $inAppPurchases The active in-app-purchases
*/
public function __construct(public readonly string $url, public readonly string $appVersion)
{
public function __construct(
public readonly string $url,
public readonly string $appVersion,
public readonly Collection $inAppPurchases,
) {
}
}
81 changes: 62 additions & 19 deletions src/Context/ContextResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Shopware\App\SDK\Context\ActionButton\ActionButtonAction;
use Shopware\App\SDK\Context\Cart\Cart;
use Shopware\App\SDK\Context\Gateway\Checkout\CheckoutGatewayAction;
use Shopware\App\SDK\Context\InAppPurchase\InAppPurchaseProvider;
use Shopware\App\SDK\Context\Module\ModuleAction;
use Shopware\App\SDK\Context\Order\Order;
use Shopware\App\SDK\Context\Order\OrderTransaction;
Expand All @@ -29,8 +30,15 @@
use Shopware\App\SDK\Framework\Collection;
use Shopware\App\SDK\Shop\ShopInterface;

/**
* @psalm-import-type StorefrontClaimsArray from StorefrontClaims
*/
class ContextResolver
{
public function __construct(private readonly InAppPurchaseProvider $inAppPurchaseProvider)
{
}

public function assembleWebhook(RequestInterface $request, ShopInterface $shop): WebhookAction
{
$body = json_decode($request->getBody()->getContents(), true, flags: JSON_THROW_ON_ERROR);
Expand All @@ -42,7 +50,7 @@ public function assembleWebhook(RequestInterface $request, ShopInterface $shop):

return new WebhookAction(
$shop,
$this->parseSource($body['source']),
$this->parseSource($body['source'], $shop),
$body['data']['event'],
$body['data']['payload'],
new DateTimeImmutable('@' . $body['timestamp'])
Expand All @@ -60,7 +68,7 @@ public function assembleActionButton(RequestInterface $request, ShopInterface $s

return new ActionButtonAction(
$shop,
$this->parseSource($body['source']),
$this->parseSource($body['source'], $shop),
$body['data']['ids'],
$body['data']['entity'],
$body['data']['action']
Expand All @@ -69,17 +77,32 @@ public function assembleActionButton(RequestInterface $request, ShopInterface $s

public function assembleModule(RequestInterface $request, ShopInterface $shop): ModuleAction
{
parse_str($request->getUri()->getQuery(), $params);
\parse_str($request->getUri()->getQuery(), $params);

if (!isset($params['sw-version'], $params['sw-context-language']) || !is_string($params['sw-version']) || !is_string($params['sw-context-language']) || !isset($params['sw-user-language']) || !is_string($params['sw-user-language'])) {
if (!isset($params['sw-version'], $params['sw-context-language'], $params['sw-user-language'])
|| !is_string($params['sw-version'])
|| !is_string($params['sw-context-language'])
|| !is_string($params['sw-user-language'])
) {
throw new MalformedWebhookBodyException();
}

if (isset($params['in-app-purchases'])) {
if (empty($params['in-app-purchases'])) {
throw new MalformedWebhookBodyException();
}

/** @var non-empty-string $inAppPurchaseString */
$inAppPurchaseString = $params['in-app-purchases'];
$inAppPurchases = $this->inAppPurchaseProvider->decodePurchases($inAppPurchaseString, $shop);
}

return new ModuleAction(
$shop,
$params['sw-version'],
$params['sw-context-language'],
$params['sw-user-language']
$params['sw-user-language'],
$inAppPurchases ?? new Collection(),
);
}

Expand All @@ -94,7 +117,7 @@ public function assembleTaxProvider(RequestInterface $request, ShopInterface $sh

return new TaxProviderAction(
$shop,
$this->parseSource($body['source']),
$this->parseSource($body['source'], $shop),
new SalesChannelContext($body['context']),
new Cart($body['cart'])
);
Expand All @@ -111,7 +134,7 @@ public function assemblePaymentPay(RequestInterface $request, ShopInterface $sho

return new PaymentPayAction(
$shop,
$this->parseSource($body['source']),
$this->parseSource($body['source'], $shop),
new Order($body['order']),
new OrderTransaction($body['orderTransaction']),
$body['returnUrl'] ?? null,
Expand All @@ -131,7 +154,7 @@ public function assemblePaymentFinalize(RequestInterface $request, ShopInterface

return new PaymentFinalizeAction(
$shop,
$this->parseSource($body['source']),
$this->parseSource($body['source'], $shop),
new OrderTransaction($body['orderTransaction']),
isset($body['recurring']) ? new RecurringData($body['recurring']) : null,
$body['queryParameters'] ?? []
Expand All @@ -149,7 +172,7 @@ public function assemblePaymentCapture(RequestInterface $request, ShopInterface

return new PaymentCaptureAction(
$shop,
$this->parseSource($body['source']),
$this->parseSource($body['source'], $shop),
new Order($body['order']),
new OrderTransaction($body['orderTransaction']),
isset($body['recurring']) ? new RecurringData($body['recurring']) : null,
Expand All @@ -168,7 +191,7 @@ public function assemblePaymentRecurringCapture(RequestInterface $request, ShopI

return new PaymentRecurringAction(
$shop,
$this->parseSource($body['source']),
$this->parseSource($body['source'], $shop),
new Order($body['order']),
new OrderTransaction($body['orderTransaction']),
);
Expand All @@ -185,7 +208,7 @@ public function assemblePaymentValidate(RequestInterface $request, ShopInterface

return new PaymentValidateAction(
$shop,
$this->parseSource($body['source']),
$this->parseSource($body['source'], $shop),
new Cart($body['cart']),
new SalesChannelContext($body['salesChannelContext']),
$body['requestData'] ?? []
Expand All @@ -203,7 +226,7 @@ public function assemblePaymentRefund(RequestInterface $request, ShopInterface $

return new RefundAction(
$shop,
$this->parseSource($body['source']),
$this->parseSource($body['source'], $shop),
new Order($body['order']),
new Refund($body['refund']),
);
Expand All @@ -227,12 +250,22 @@ public function assembleStorefrontRequest(RequestInterface $request, ShopInterfa
throw new MalformedWebhookBodyException();
}

/** @var array<string, string> $claims */
$claims = json_decode(base64_decode($parts[1]), true, flags: JSON_THROW_ON_ERROR);
/** @var StorefrontClaimsArray $claims */
$claims = \json_decode(\base64_decode($parts[1]), true, flags: JSON_THROW_ON_ERROR);

if (isset($claims['inAppPurchases'])) {
/** @phpstan-ignore booleanNot.alwaysFalse(phpstan claims, that the InAppPurchases always are strings) */
if (!\is_string($claims['inAppPurchases']) || empty($claims['inAppPurchases'])) {
throw new MalformedWebhookBodyException();
}

$inAppPurchases = $this->inAppPurchaseProvider->decodePurchases($claims['inAppPurchases'], $shop);
}

return new StorefrontAction(
$shop,
new StorefrontClaims($claims)
new StorefrontClaims($claims),
$inAppPurchases ?? new Collection()
);
}

Expand All @@ -247,7 +280,7 @@ public function assembleCheckoutGatewayRequest(RequestInterface $request, ShopIn

return new CheckoutGatewayAction(
$shop,
$this->parseSource($body['source']),
$this->parseSource($body['source'], $shop),
new Cart($body['cart']),
new SalesChannelContext($body['salesChannelContext']),
new Collection(\array_flip($body['paymentMethods'])),
Expand All @@ -259,15 +292,25 @@ public function assembleCheckoutGatewayRequest(RequestInterface $request, ShopIn
* @param array<string, mixed> $source
* @return ActionSource
*/
private function parseSource(array $source): ActionSource
private function parseSource(array $source, ShopInterface $shop): ActionSource
{
if (!isset($source['url'], $source['appVersion']) || !is_string($source['url']) || !is_string($source['appVersion'])) {
if (!isset($source['url'], $source['appVersion']) || !\is_string($source['url']) || !\is_string($source['appVersion'])) {
throw new MalformedWebhookBodyException();
}

if (isset($source['inAppPurchases'])) {
if (!\is_string($source['inAppPurchases']) || empty($source['inAppPurchases'])) {
throw new MalformedWebhookBodyException();
}

$inAppPurchases = $this->inAppPurchaseProvider->decodePurchases($source['inAppPurchases'], $shop);
}


return new ActionSource(
$source['url'],
$source['appVersion']
$source['appVersion'],
$inAppPurchases ?? new Collection(),
);
}
}
41 changes: 41 additions & 0 deletions src/Context/InAppPurchase/HasMatchingDomain.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

namespace Shopware\App\SDK\Context\InAppPurchase;

use Lcobucci\JWT\Token;
use Lcobucci\JWT\UnencryptedToken;
use Lcobucci\JWT\Validation\Constraint;
use Lcobucci\JWT\Validation\ConstraintViolation;
use Shopware\App\SDK\Shop\ShopInterface;

/**
* @phpstan-import-type InAppPurchaseArray from InAppPurchaseProvider
*/
class HasMatchingDomain implements Constraint
{
public function __construct(private readonly ShopInterface $shop)
{
}

public function assert(Token $token): void
{
if (!$token instanceof UnencryptedToken) {
throw new \Exception('Incorrect token type');
}

/** @var InAppPurchaseArray $inAppPurchase */
foreach ($token->claims()->all() as $inAppPurchase) {
if (!\array_key_exists('sub', $inAppPurchase)) {
throw ConstraintViolation::error('Missing sub claim', $this);
}

$host = \parse_url($this->shop->getShopUrl(), \PHP_URL_HOST);

if ($inAppPurchase['sub'] !== $host) {
throw ConstraintViolation::error('Token domain invalid: ' . $inAppPurchase['sub'] . ', expected: ' . $host, $this);
}
}
}
}
Loading

0 comments on commit ebace4c

Please sign in to comment.