Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"ext-sodium": "*",
"brick/math": "^0.9|^0.10|^0.11",
"paragonie/constant_time_encoding": "^2.4",
"psr/clock": "^1.0",
"psr/event-dispatcher": "^1.0",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0",
Expand Down Expand Up @@ -145,7 +146,9 @@
"sort-packages": true,
"allow-plugins": {
"infection/extension-installer": true,
"composer/package-versions-deprecated": true
"composer/package-versions-deprecated": true,
"phpstan/extension-installer": false,
"php-http/discovery": false
}
}
}
2 changes: 1 addition & 1 deletion rector.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
DoctrineSetList::DOCTRINE_CODE_QUALITY,
DoctrineSetList::ANNOTATIONS_TO_ATTRIBUTES,
PHPUnitSetList::PHPUNIT_SPECIFIC_METHOD,
PHPUnitLevelSetList::UP_TO_PHPUNIT_100,
PHPUnitLevelSetList::UP_TO_PHPUNIT_90,
PHPUnitSetList::PHPUNIT_CODE_QUALITY,
PHPUnitSetList::PHPUNIT_EXCEPTION,
PHPUnitSetList::REMOVE_MOCKS,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ public function load(array $configs, ContainerBuilder $container): void
$loader = new PhpFileLoader($container, new FileLocator(__DIR__ . '/../../../Resources/config'));
$loader->load('checkers.php');

$container->setAlias('jose.clock', $configs['clock']);
if (array_key_exists('checkers', $configs)) {
foreach ($this->sources as $source) {
$source->load($configs['checkers'], $container);
Expand All @@ -57,6 +58,13 @@ public function getNodeDefinition(NodeDefinition $node): void
if (! $this->isEnabled()) {
return;
}
$node->children()
->scalarNode('clock')
->defaultValue('jose.internal_clock')
->cannotBeEmpty()
->info('PSR-20 clock')
->end()
->end();
$childNode = $node
->children()
->arrayNode($this->name())
Expand Down
16 changes: 15 additions & 1 deletion src/Bundle/JoseFramework/Resources/config/checkers.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@
use Jose\Bundle\JoseFramework\Services\ClaimCheckerManagerFactory;
use Jose\Bundle\JoseFramework\Services\HeaderCheckerManagerFactory;
use Jose\Component\Checker\ExpirationTimeChecker;
use Jose\Component\Checker\InternalClock;
use Jose\Component\Checker\IssuedAtChecker;
use Jose\Component\Checker\NotBeforeChecker;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use function Symfony\Component\DependencyInjection\Loader\Configurator\service;

/*
* The MIT License (MIT)
Expand All @@ -17,7 +20,6 @@
* of the MIT license. See the LICENSE file for details.
*/

use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;

return function (ContainerConfigurator $container): void {
$container = $container->services()
Expand All @@ -33,6 +35,7 @@
->public();

$container->set(ExpirationTimeChecker::class)
->arg('$clock', service('jose.internal_clock'))
->tag('jose.checker.claim', [
'alias' => 'exp',
])
Expand All @@ -41,6 +44,7 @@
]);

$container->set(IssuedAtChecker::class)
->arg('$clock', service('jose.internal_clock'))
->tag('jose.checker.claim', [
'alias' => 'iat',
])
Expand All @@ -49,10 +53,20 @@
]);

$container->set(NotBeforeChecker::class)
->arg('$clock', service('jose.internal_clock'))
->tag('jose.checker.claim', [
'alias' => 'nbf',
])
->tag('jose.checker.header', [
'alias' => 'nbf',
]);

$container->set('jose.internal_clock')
->class(InternalClock::class)
->deprecate(
'web-token/jwt-bundle',
'3.2.0',
'The service "%service_id%" is an internal service that will be removed in 4.0.0. Please use a PSR-20 compatible service as clock.'
)
->private();
};
25 changes: 22 additions & 3 deletions src/Component/Checker/ExpirationTimeChecker.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use function is_float;
use function is_int;
use Psr\Clock\ClockInterface;

/**
* This class is a claim checker. When the "exp" is present, it will compare the value with the current timestamp.
Expand All @@ -14,10 +15,22 @@ final class ExpirationTimeChecker implements ClaimChecker, HeaderChecker
{
private const NAME = 'exp';

private readonly ClockInterface $clock;

public function __construct(
private readonly int $allowedTimeDrift = 0,
private readonly bool $protectedHeaderOnly = false
private readonly bool $protectedHeaderOnly = false,
?ClockInterface $clock = null,
) {
if ($clock === null) {
trigger_deprecation(
'web-token/jwt-checker',
'3.2.0',
'The parameter "$clock" will become mandatory in 4.0.0. Please set a valid PSR Clock implementation instead of "null".'
);
$clock = new InternalClock();
}
$this->clock = $clock;
}

/**
Expand All @@ -28,7 +41,10 @@ public function checkClaim(mixed $value): void
if (! is_float($value) && ! is_int($value)) {
throw new InvalidClaimException('"exp" must be an integer.', self::NAME, $value);
}
if (time() > $value + $this->allowedTimeDrift) {

$now = $this->clock->now()
->getTimestamp();
if ($now > $value + $this->allowedTimeDrift) {
throw new InvalidClaimException('The token expired.', self::NAME, $value);
}
}
Expand All @@ -43,7 +59,10 @@ public function checkHeader(mixed $value): void
if (! is_float($value) && ! is_int($value)) {
throw new InvalidHeaderException('"exp" must be an integer.', self::NAME, $value);
}
if (time() > $value + $this->allowedTimeDrift) {

$now = $this->clock->now()
->getTimestamp();
if ($now > $value + $this->allowedTimeDrift) {
throw new InvalidHeaderException('The token expired.', self::NAME, $value);
}
}
Expand Down
19 changes: 19 additions & 0 deletions src/Component/Checker/InternalClock.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace Jose\Component\Checker;

use DateTimeImmutable;
use Psr\Clock\ClockInterface;

/**
* @internal
*/
final class InternalClock implements ClockInterface
{
public function now(): DateTimeImmutable
{
return new DateTimeImmutable();
}
}
25 changes: 22 additions & 3 deletions src/Component/Checker/IssuedAtChecker.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use function is_float;
use function is_int;
use Psr\Clock\ClockInterface;

/**
* This class is a claim checker. When the "iat" is present, it will compare the value with the current timestamp.
Expand All @@ -14,10 +15,22 @@ final class IssuedAtChecker implements ClaimChecker, HeaderChecker
{
private const NAME = 'iat';

private readonly ClockInterface $clock;

public function __construct(
private readonly int $allowedTimeDrift = 0,
private readonly bool $protectedHeaderOnly = false
private readonly bool $protectedHeaderOnly = false,
?ClockInterface $clock = null,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

having this argument as the last one means that you cannot actually remove the nullable type in 4.0 as you cannot make it mandatory without making all other arguments mandatory as well.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @stof,

You are right, this will cause issues in the futur.
I created #467 and by setting constructors as private this should resolve that.
By the way, I am not sure the @deprecated statement on the __construct method makes sense.

) {
if ($clock === null) {
trigger_deprecation(
'web-token/jwt-checker',
'3.2.0',
'The parameter "$clock" will become mandatory in 4.0.0. Please set a valid PSR Clock implementation instead of "null".'
);
$clock = new InternalClock();
}
$this->clock = $clock;
}

/**
Expand All @@ -28,7 +41,10 @@ public function checkClaim(mixed $value): void
if (! is_float($value) && ! is_int($value)) {
throw new InvalidClaimException('"iat" must be an integer.', self::NAME, $value);
}
if (time() < $value - $this->allowedTimeDrift) {

$now = $this->clock->now()
->getTimestamp();
if ($now < $value - $this->allowedTimeDrift) {
throw new InvalidClaimException('The JWT is issued in the future.', self::NAME, $value);
}
}
Expand All @@ -43,7 +59,10 @@ public function checkHeader(mixed $value): void
if (! is_float($value) && ! is_int($value)) {
throw new InvalidHeaderException('The header "iat" must be an integer.', self::NAME, $value);
}
if (time() < $value - $this->allowedTimeDrift) {

$now = $this->clock->now()
->getTimestamp();
if ($now < $value - $this->allowedTimeDrift) {
throw new InvalidHeaderException('The JWT is issued in the future.', self::NAME, $value);
}
}
Expand Down
25 changes: 22 additions & 3 deletions src/Component/Checker/NotBeforeChecker.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use function is_float;
use function is_int;
use Psr\Clock\ClockInterface;

/**
* This class is a claim checker. When the "nbf" is present, it will compare the value with the current timestamp.
Expand All @@ -14,10 +15,22 @@ final class NotBeforeChecker implements ClaimChecker, HeaderChecker
{
private const NAME = 'nbf';

private readonly ClockInterface $clock;

public function __construct(
private readonly int $allowedTimeDrift = 0,
private readonly bool $protectedHeaderOnly = false
private readonly bool $protectedHeaderOnly = false,
?ClockInterface $clock = null,
) {
if ($clock === null) {
trigger_deprecation(
'web-token/jwt-checker',
'3.2.0',
'The parameter "$clock" will become mandatory in 4.0.0. Please set a valid PSR Clock implementation instead of "null".'
);
$clock = new InternalClock();
}
$this->clock = $clock;
}

/**
Expand All @@ -28,7 +41,10 @@ public function checkClaim(mixed $value): void
if (! is_float($value) && ! is_int($value)) {
throw new InvalidClaimException('"nbf" must be an integer.', self::NAME, $value);
}
if (time() < $value - $this->allowedTimeDrift) {

$now = $this->clock->now()
->getTimestamp();
if ($now < $value - $this->allowedTimeDrift) {
throw new InvalidClaimException('The JWT can not be used yet.', self::NAME, $value);
}
}
Expand All @@ -43,7 +59,10 @@ public function checkHeader(mixed $value): void
if (! is_float($value) && ! is_int($value)) {
throw new InvalidHeaderException('"nbf" must be an integer.', self::NAME, $value);
}
if (time() < $value - $this->allowedTimeDrift) {

$now = $this->clock->now()
->getTimestamp();
if ($now < $value - $this->allowedTimeDrift) {
throw new InvalidHeaderException('The JWT can not be used yet.', self::NAME, $value);
}
}
Expand Down
1 change: 1 addition & 0 deletions src/Component/Checker/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
},
"require": {
"php": ">=8.1",
"psr/clock": "^1.0",
"web-token/jwt-core": "^3.0"
}
}
32 changes: 20 additions & 12 deletions tests/Component/Checker/ClaimCheckerManagerFactoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
use Jose\Component\Checker\IssuedAtChecker;
use Jose\Component\Checker\MissingMandatoryClaimException;
use Jose\Component\Checker\NotBeforeChecker;
use Jose\Tests\Component\Checker\Stub\MockClock;
use PHPUnit\Framework\TestCase;
use Psr\Clock\ClockInterface;

/**
* @internal
Expand Down Expand Up @@ -55,15 +57,18 @@ public function iCanCreateAClaimCheckerManager(): void
*/
public function iCanCheckValidPayloadClaims(): void
{
$clock = new MockClock();
$now = $clock->now()
->getTimestamp();
$payload = [
'exp' => time() + 3600,
'iat' => time() - 1000,
'nbf' => time() - 100,
'exp' => $now + 3600,
'iat' => $now - 1000,
'nbf' => $now - 100,
'foo' => 'bar',
];
$expected = $payload;
unset($expected['foo']);
$manager = $this->getClaimCheckerManagerFactory()
$manager = $this->getClaimCheckerManagerFactory($clock)
->create(['exp', 'iat', 'nbf', 'aud']);
$result = $manager->check($payload);
static::assertSame($expected, $result);
Expand All @@ -77,26 +82,29 @@ public function theMandatoryClaimsAreNotSet(): void
$this->expectException(MissingMandatoryClaimException::class);
$this->expectExceptionMessage('The following claims are mandatory: bar.');

$clock = new MockClock();
$now = $clock->now()
->getTimestamp();
$payload = [
'exp' => time() + 3600,
'iat' => time() - 1000,
'nbf' => time() - 100,
'exp' => $now + 3600,
'iat' => $now - 1000,
'nbf' => $now - 100,
'foo' => 'bar',
];
$expected = $payload;
unset($expected['foo']);
$manager = $this->getClaimCheckerManagerFactory()
$manager = $this->getClaimCheckerManagerFactory($clock)
->create(['exp', 'iat', 'nbf', 'aud']);
$manager->check($payload, ['exp', 'foo', 'bar']);
}

private function getClaimCheckerManagerFactory(): ClaimCheckerManagerFactory
private function getClaimCheckerManagerFactory(ClockInterface $clock = new MockClock()): ClaimCheckerManagerFactory
{
if ($this->claimCheckerManagerFactory === null) {
$this->claimCheckerManagerFactory = new ClaimCheckerManagerFactory();
$this->claimCheckerManagerFactory->add('exp', new ExpirationTimeChecker());
$this->claimCheckerManagerFactory->add('iat', new IssuedAtChecker());
$this->claimCheckerManagerFactory->add('nbf', new NotBeforeChecker());
$this->claimCheckerManagerFactory->add('exp', new ExpirationTimeChecker(clock: $clock));
$this->claimCheckerManagerFactory->add('iat', new IssuedAtChecker(clock: $clock));
$this->claimCheckerManagerFactory->add('nbf', new NotBeforeChecker(clock: $clock));
$this->claimCheckerManagerFactory->add('aud', new AudienceChecker('My Service'));
}

Expand Down
Loading