Skip to content

Commit

Permalink
It works. On with state, nonce and token validation
Browse files Browse the repository at this point in the history
  • Loading branch information
loevgaard committed Sep 30, 2024
1 parent f9daa48 commit 48f9279
Show file tree
Hide file tree
Showing 12 changed files with 162 additions and 25 deletions.
3 changes: 3 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@
],
"require": {
"php": ">=8.1",
"cuyz/valinor": "^1.13",
"doctrine/orm": "^2.0 || ^3.0",
"firebase/php-jwt": "^6.10",
"setono/doctrine-orm-trait": "^1.1",
"sylius/admin-bundle": "^1.0",
"sylius/core": "^1.0",
"sylius/core-bundle": "^1.0",
Expand Down
6 changes: 6 additions & 0 deletions src/Checker/MinimumAgeChecker.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Setono\SyliusAgeVerificationPlugin\Checker;

use Setono\SyliusAgeVerificationPlugin\Model\AgeAwareCustomerInterface;
use Setono\SyliusAgeVerificationPlugin\Model\AgeAwareProductInterface;
use Setono\SyliusAgeVerificationPlugin\Model\MinimumAge;
use Sylius\Component\Core\Model\OrderInterface;
Expand Down Expand Up @@ -42,6 +43,11 @@ public function check(OrderInterface $order): ?MinimumAge
}
}

$customer = $order->getCustomer();
if (null !== $minimumAge && $customer instanceof AgeAwareCustomerInterface && $customer->isOlderThan($minimumAge->value)) {
return null;
}

return $minimumAge;
}
}
3 changes: 2 additions & 1 deletion src/Checker/MinimumAgeCheckerInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
interface MinimumAgeCheckerInterface
{
/**
* Returns null if the order is not age restricted else returns the minimum age required
* Returns null if the order is not age restricted or the customer is older than the required minimum age
* Else it returns the minimum age required
*/
public function check(OrderInterface $order): ?MinimumAge;
}
39 changes: 36 additions & 3 deletions src/Controller/CriiptoCallbackAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,47 @@

namespace Setono\SyliusAgeVerificationPlugin\Controller;

use Doctrine\Persistence\ManagerRegistry;
use Setono\Doctrine\ORMTrait;
use Setono\SyliusAgeVerificationPlugin\Model\AgeAwareCustomerInterface;
use Setono\SyliusAgeVerificationPlugin\OpenIdConfiguration\OpenIdConfiguration;
use Setono\SyliusAgeVerificationPlugin\Token\TokenDecoderInterface;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Order\Context\CartContextInterface;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Webmozart\Assert\Assert;

final class CriiptoCallbackAction
{
use ORMTrait;

public function __construct(
private readonly OpenIdConfiguration $openIdConfiguration,
private readonly UrlGeneratorInterface $urlGenerator,
private readonly TokenDecoderInterface $tokenDecoder,
private readonly CartContextInterface $cartContext,
ManagerRegistry $managerRegistry,
private readonly string $clientId,
private readonly string $clientSecret,
) {
$this->managerRegistry = $managerRegistry;
}

public function __invoke(Request $request): RedirectResponse
{
/** @var OrderInterface $order */
$order = $this->cartContext->getCart();
Assert::isInstanceOf($order, OrderInterface::class);

/** @var AgeAwareCustomerInterface $customer */
$customer = $order->getCustomer();
Assert::isInstanceOf($customer, AgeAwareCustomerInterface::class);

$client = HttpClient::create();
$response = $client->request('POST', $this->openIdConfiguration->tokenEndpoint, [
$data = $client->request('POST', $this->openIdConfiguration->tokenEndpoint, [
'headers' => [
'Authorization' => 'Basic ' . base64_encode(sprintf('%s:%s', urlencode($this->clientId), urlencode($this->clientSecret))),
],
Expand All @@ -36,8 +57,20 @@ public function __invoke(Request $request): RedirectResponse
referenceType: UrlGeneratorInterface::ABSOLUTE_URL,
),
],
]);
])->toArray();

Assert::keyExists($data, 'id_token');
Assert::stringNotEmpty($data['id_token']);

$decodedToken = $this->tokenDecoder->decode($data['id_token'], $this->openIdConfiguration);

// todo validate decoded token

$customer->setOlderThan($decodedToken->olderThan());
$customer->setAgeCheckedAt(new \DateTimeImmutable());

$this->getManager($customer)->flush();

dd($response->getContent(false));
return new RedirectResponse('https://127.0.0.1:8000/en_US/checkout/complete');
}
}
6 changes: 3 additions & 3 deletions src/Model/AgeAwareCustomerInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@

interface AgeAwareCustomerInterface
{
public function isOver(int $age): bool;
public function isOlderThan(int $age): bool;

public function setIsOverAgeCheckedAt(\DateTimeInterface $ageCheckedAt): void;
public function setAgeCheckedAt(\DateTimeInterface $ageCheckedAt): void;

public function setIsOver(int $age): void;
public function setOlderThan(?int $age): void;
}
22 changes: 11 additions & 11 deletions src/Model/AgeAwareCustomerTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,36 +10,36 @@ trait AgeAwareCustomerTrait
{
/** @ORM\Column(type="datetime", nullable=true) */
#[ORM\Column(type: 'datetime', nullable: true)]
protected ?\DateTimeInterface $isOverAgeCheckedAt = null;
protected ?\DateTimeInterface $ageCheckedAt = null;

/** @ORM\Column(type="integer", nullable=true, options={"unsigned"=true}) */
#[ORM\Column(type: 'integer', nullable: true, options: ['unsigned' => true])]
protected ?int $isOver = null;
protected ?int $olderThan = null;

// todo test this
public function isOver(int $age): bool
public function isOlderThan(int $age): bool
{
if ($this->isOverAgeCheckedAt === null || $this->isOver === null) {
if ($this->ageCheckedAt === null || $this->olderThan === null) {
return false;
}

if ($age <= $this->isOver) {
if ($age <= $this->olderThan) {
return true;
}

$now = new \DateTimeImmutable();
$diff = $now->diff($this->isOverAgeCheckedAt);
$diff = $now->diff($this->ageCheckedAt);

return $age <= ($this->isOver + $diff->y);
return $age <= ($this->olderThan + $diff->y);
}

public function setIsOverAgeCheckedAt(?\DateTimeInterface $isOverAgeCheckedAt): void
public function setAgeCheckedAt(?\DateTimeInterface $isOverAgeCheckedAt): void
{
$this->isOverAgeCheckedAt = $isOverAgeCheckedAt;
$this->ageCheckedAt = $isOverAgeCheckedAt;
}

public function setIsOver(?int $age): void
public function setOlderThan(?int $age): void
{
$this->isOver = $age;
$this->olderThan = $age;
}
}
7 changes: 5 additions & 2 deletions src/OpenIdConfiguration/OpenIdConfiguration.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@
*/
class OpenIdConfiguration
{
public function __construct(public readonly string $authorizationEndpoint, public readonly string $tokenEndpoint)
{
public function __construct(
public readonly string $authorizationEndpoint,
public readonly string $tokenEndpoint,
public readonly array $keys,
) {
}
}
16 changes: 13 additions & 3 deletions src/OpenIdConfiguration/OpenIdConfigurationFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,15 @@ public function __construct(private readonly string $verifyDomain)

public function create(): OpenIdConfiguration
{
$client = HttpClient::create();
$openIdConfiguration = $client
$httpClient = HttpClient::create();
$openIdConfiguration = $httpClient
->request('GET', sprintf('https://%s/.well-known/openid-configuration', $this->verifyDomain))
->toArray()
;

Assert::keyExists($openIdConfiguration, 'token_endpoint');
Assert::keyExists($openIdConfiguration, 'authorization_endpoint');
Assert::keyExists($openIdConfiguration, 'jwks_uri');

/** @var mixed $tokenEndpoint */
$tokenEndpoint = $openIdConfiguration['token_endpoint'];
Expand All @@ -32,6 +33,15 @@ public function create(): OpenIdConfiguration
$authorizationEndpoint = $openIdConfiguration['authorization_endpoint'];
Assert::stringNotEmpty($authorizationEndpoint);

return new OpenIdConfiguration($authorizationEndpoint, $tokenEndpoint);
/** @var mixed $jwksUri */
$jwksUri = $openIdConfiguration['jwks_uri'];
Assert::stringNotEmpty($jwksUri);

$keys = $httpClient
->request('GET', $jwksUri)
->toArray()
;

return new OpenIdConfiguration($authorizationEndpoint, $tokenEndpoint, $keys);
}
}
15 changes: 13 additions & 2 deletions src/Resources/config/services.xml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
<service id="Setono\SyliusAgeVerificationPlugin\Controller\CriiptoCallbackAction" public="true">
<argument type="service" id="Setono\SyliusAgeVerificationPlugin\OpenIdConfiguration\OpenIdConfiguration"/>
<argument type="service" id="router"/>
<argument type="service" id="Setono\SyliusAgeVerificationPlugin\Token\TokenDecoderInterface"/>
<argument type="service" id="sylius.context.cart"/>
<argument type="service" id="doctrine"/>
<argument>%env(CRIIPTO_CLIENT_ID)%</argument>
<argument>%env(CRIIPTO_CLIENT_SECRET)%</argument>
</service>
Expand All @@ -27,7 +30,9 @@
</service>

<service id="Setono\SyliusAgeVerificationPlugin\OpenIdConfiguration\OpenIdConfiguration" lazy="true">
<factory service="Setono\SyliusAgeVerificationPlugin\OpenIdConfiguration\OpenIdConfigurationFactoryInterface" method="create"/>
<factory
service="Setono\SyliusAgeVerificationPlugin\OpenIdConfiguration\OpenIdConfigurationFactoryInterface"
method="create"/>
</service>

<service id="Setono\SyliusAgeVerificationPlugin\OpenIdConfiguration\OpenIdConfigurationFactoryInterface"
Expand All @@ -37,14 +42,20 @@
<argument>%setono_sylius_age_verification.criipto.verify_domain%</argument>
</service>

<service id="Setono\SyliusAgeVerificationPlugin\Token\TokenDecoderInterface"
alias="Setono\SyliusAgeVerificationPlugin\Token\TokenDecoder"/>

<service id="Setono\SyliusAgeVerificationPlugin\Token\TokenDecoder"/>

<service id="Setono\SyliusAgeVerificationPlugin\Twig\Extension">
<tag name="twig.extension"/>
</service>

<service id="Setono\SyliusAgeVerificationPlugin\Twig\Runtime">
<argument type="service" id="Setono\SyliusAgeVerificationPlugin\Checker\MinimumAgeCheckerInterface"/>
<argument type="service" id="sylius.context.cart"/>
<argument type="service" id="Setono\SyliusAgeVerificationPlugin\UrlGenerator\AuthorizationUrlGeneratorInterface"/>
<argument type="service"
id="Setono\SyliusAgeVerificationPlugin\UrlGenerator\AuthorizationUrlGeneratorInterface"/>

<tag name="twig.runtime"/>
</service>
Expand Down
31 changes: 31 additions & 0 deletions src/Token/DecodedToken.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusAgeVerificationPlugin\Token;

final class DecodedToken
{
public function __construct(
public string $iss,
public string $aud,
public string $nonce,
/** @var array{country: string, is_over_15?: bool, is_over_16?: bool, is_over_18?: bool, is_over_21?: bool} $ageVerification */
public array $ageVerification,
) {
}

/**
* Returns the age this user is over or null if the user is not over any age
*/
public function olderThan(): ?int
{
return match (true) {
$this->ageVerification['is_over_21'] ?? null === true => 21,
$this->ageVerification['is_over_18'] ?? null === true => 18,
$this->ageVerification['is_over_16'] ?? null === true => 16,
$this->ageVerification['is_over_15'] ?? null === true => 15,
default => null,
};
}
}
27 changes: 27 additions & 0 deletions src/Token/TokenDecoder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusAgeVerificationPlugin\Token;

use CuyZ\Valinor\MapperBuilder;
use Firebase\JWT\JWK;
use Firebase\JWT\JWT;
use Setono\SyliusAgeVerificationPlugin\OpenIdConfiguration\OpenIdConfiguration;
use Webmozart\Assert\Assert;

final class TokenDecoder implements TokenDecoderInterface
{
public function decode(string $token, OpenIdConfiguration $openIdConfiguration): DecodedToken
{
$data = (array) JWT::decode($token, JWK::parseKeySet($openIdConfiguration->keys, 'RS256'));
Assert::keyExists($data, 'http://ageverification.criipto.com');
$data['ageVerification'] = (array) $data['http://ageverification.criipto.com'];

return (new MapperBuilder())
->allowSuperfluousKeys()
->mapper()
->map(DecodedToken::class, $data)
;
}
}
12 changes: 12 additions & 0 deletions src/Token/TokenDecoderInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusAgeVerificationPlugin\Token;

use Setono\SyliusAgeVerificationPlugin\OpenIdConfiguration\OpenIdConfiguration;

interface TokenDecoderInterface
{
public function decode(string $token, OpenIdConfiguration $openIdConfiguration): DecodedToken;
}

0 comments on commit 48f9279

Please sign in to comment.