Skip to content

Commit

Permalink
Add a way to verify a token against multiple algorithms/keys
Browse files Browse the repository at this point in the history
When dealing with key rotations, we will likely have to verify
previously issued tokens (signed with the old key) and new tokens
(signed with the new key).

This introduces a handy constraint that can take multiple `SignedWith`
constraints and only raise exceptions when the signature can't be
validated by any of them.

Signed-off-by: Luís Cobucci <[email protected]>
  • Loading branch information
lcobucci committed Nov 20, 2023
1 parent 800dbdd commit fbff2ed
Show file tree
Hide file tree
Showing 6 changed files with 340 additions and 55 deletions.
1 change: 1 addition & 0 deletions docs/validating-tokens.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ This library provides the following constraints:
* `Lcobucci\JWT\Validation\Constraint\PermittedFor`: verifies if the claim `aud` contains the expected value
* `Lcobucci\JWT\Validation\Constraint\RelatedTo`: verifies if the claim `sub` matches the expected value
* `Lcobucci\JWT\Validation\Constraint\SignedWith`: verifies if the token was signed with the expected signer and key
* `Lcobucci\JWT\Validation\Constraint\SignedWithOneInSet`: verifies the token signature against multiple `SignedWith` constraints
* `Lcobucci\JWT\Validation\Constraint\StrictValidAt`: verifies presence and validity of the claims `iat`, `nbf`, and `exp` (supports leeway configuration)
* `Lcobucci\JWT\Validation\Constraint\LooseValidAt`: verifies the claims `iat`, `nbf`, and `exp`, when present (supports leeway configuration)
* `Lcobucci\JWT\Validation\Constraint\HasClaimWithValue`: verifies that a **custom claim** has the expected value (not recommended when comparing cryptographic hashes)
Expand Down
38 changes: 38 additions & 0 deletions src/Validation/Constraint/SignedWithOneInSet.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);

namespace Lcobucci\JWT\Validation\Constraint;

use Lcobucci\JWT\Token;
use Lcobucci\JWT\Validation\ConstraintViolation;
use Lcobucci\JWT\Validation\SignedWith as SignedWithInterface;

use const PHP_EOL;

final class SignedWithOneInSet implements SignedWithInterface
{
/** @var array<SignedWithUntilDate> */
private readonly array $constraints;

public function __construct(SignedWithUntilDate ...$constraints)
{
$this->constraints = $constraints;
}

public function assert(Token $token): void
{
$errorMessage = 'It was not possible to verify the signature of the token, reasons:';

foreach ($this->constraints as $constraint) {
try {
$constraint->assert($token);

return;
} catch (ConstraintViolation $violation) {
$errorMessage .= PHP_EOL . '- ' . $violation->getMessage();
}
}

throw ConstraintViolation::error($errorMessage, $this);
}
}
47 changes: 47 additions & 0 deletions src/Validation/Constraint/SignedWithUntilDate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);

namespace Lcobucci\JWT\Validation\Constraint;

use DateTimeImmutable;
use DateTimeInterface;
use Lcobucci\JWT\Signer;
use Lcobucci\JWT\Token;
use Lcobucci\JWT\Validation\ConstraintViolation;
use Lcobucci\JWT\Validation\SignedWith as SignedWithInterface;
use Psr\Clock\ClockInterface;

final class SignedWithUntilDate implements SignedWithInterface
{
private readonly SignedWith $verifySignature;
private readonly ClockInterface $clock;

public function __construct(
Signer $signer,
Signer\Key $key,
private readonly DateTimeImmutable $validUntil,
?ClockInterface $clock = null,
) {
$this->verifySignature = new SignedWith($signer, $key);

$this->clock = $clock ?? new class () implements ClockInterface {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable();
}
};
}

public function assert(Token $token): void
{
if ($this->validUntil < $this->clock->now()) {
throw ConstraintViolation::error(
'This constraint was only usable until '
. $this->validUntil->format(DateTimeInterface::RFC3339),
$this,
);
}

$this->verifySignature->assert($token);
}
}
98 changes: 43 additions & 55 deletions tests/JwtFacadeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,16 @@
use Lcobucci\JWT\Token\Plain;
use Lcobucci\JWT\Validation\Constraint\IssuedBy;
use Lcobucci\JWT\Validation\Constraint\SignedWith;
use Lcobucci\JWT\Validation\Constraint\SignedWithOneInSet;
use Lcobucci\JWT\Validation\Constraint\SignedWithUntilDate;
use Lcobucci\JWT\Validation\Constraint\StrictValidAt;
use Lcobucci\JWT\Validation\RequiredConstraintsViolated;
use PHPUnit\Framework\Attributes as PHPUnit;
use PHPUnit\Framework\TestCase;
use Psr\Clock\ClockInterface;

/**
* @covers ::__construct
* @coversDefaultClass \Lcobucci\JWT\JwtFacade
* @covers \Lcobucci\JWT\JwtFacade
*
* @uses \Lcobucci\JWT\Token\Parser
* @uses \Lcobucci\JWT\Encoding\JoseEncoder
Expand All @@ -40,6 +42,8 @@
* @uses \Lcobucci\JWT\Validation\Validator
* @uses \Lcobucci\JWT\Validation\Constraint\IssuedBy
* @uses \Lcobucci\JWT\Validation\Constraint\SignedWith
* @uses \Lcobucci\JWT\Validation\Constraint\SignedWithOneInSet
* @uses \Lcobucci\JWT\Validation\Constraint\SignedWithUntilDate
* @uses \Lcobucci\JWT\Validation\Constraint\StrictValidAt
* @uses \Lcobucci\JWT\Validation\ConstraintViolation
* @uses \Lcobucci\JWT\Validation\RequiredConstraintsViolated
Expand Down Expand Up @@ -72,12 +76,7 @@ private function createToken(): string
)->toString();
}

/**
* @test
*
* @covers ::issue
* @covers ::parse
*/
#[PHPUnit\Test]
public function issueSetTimeValidity(): void
{
$token = (new JwtFacade(clock: $this->clock))->issue(
Expand Down Expand Up @@ -105,12 +104,7 @@ public function issueSetTimeValidity(): void
self::assertTrue($token->isExpired($inOneYear));
}

/**
* @test
*
* @covers ::issue
* @covers ::parse
*/
#[PHPUnit\Test]
public function issueAllowsTimeValidityOverwrite(): void
{
$then = new DateTimeImmutable('2001-02-03 04:05:06');
Expand Down Expand Up @@ -144,12 +138,7 @@ static function (Builder $builder) use ($then): Builder {
self::assertTrue($token->isExpired($inOneYear));
}

/**
* @test
*
* @covers ::issue
* @covers ::parse
*/
#[PHPUnit\Test]
public function goodJwt(): void
{
$token = (new JwtFacade())->parse(
Expand All @@ -162,12 +151,7 @@ public function goodJwt(): void
self::assertInstanceOf(Plain::class, $token);
}

/**
* @test
*
* @covers ::issue
* @covers ::parse
*/
#[PHPUnit\Test]
public function badSigner(): void
{
$this->expectException(RequiredConstraintsViolated::class);
Expand All @@ -181,12 +165,7 @@ public function badSigner(): void
);
}

/**
* @test
*
* @covers ::issue
* @covers ::parse
*/
#[PHPUnit\Test]
public function badKey(): void
{
$this->expectException(RequiredConstraintsViolated::class);
Expand All @@ -200,12 +179,7 @@ public function badKey(): void
);
}

/**
* @test
*
* @covers ::issue
* @covers ::parse
*/
#[PHPUnit\Test]
public function badTime(): void
{
$token = $this->createToken();
Expand All @@ -222,12 +196,7 @@ public function badTime(): void
);
}

/**
* @test
*
* @covers ::issue
* @covers ::parse
*/
#[PHPUnit\Test]
public function badIssuer(): void
{
$this->expectException(RequiredConstraintsViolated::class);
Expand All @@ -241,11 +210,7 @@ public function badIssuer(): void
);
}

/**
* @test
*
* @covers ::parse
*/
#[PHPUnit\Test]
public function parserForNonUnencryptedTokens(): void
{
$this->expectException(AssertionError::class);
Expand All @@ -258,12 +223,7 @@ public function parserForNonUnencryptedTokens(): void
);
}

/**
* @test
*
* @covers ::issue
* @covers ::parse
*/
#[PHPUnit\Test]
public function customPsrClock(): void
{
$clock = new class () implements ClockInterface {
Expand All @@ -290,4 +250,32 @@ public function now(): DateTimeImmutable
),
);
}

#[PHPUnit\Test]
public function multipleKeys(): void
{
$clock = new FrozenClock(new DateTimeImmutable('2023-11-19 22:10:00'));

$token = (new JwtFacade())->parse(
$this->createToken(),
new SignedWithOneInSet(
new SignedWithUntilDate(
$this->signer,
InMemory::base64Encoded('czyPTpN595zVNSuvoNNlXCRFgXS2fHscMR36dGojaUE='),
new DateTimeImmutable('2024-11-19 22:10:00'),
$clock,
),
new SignedWithUntilDate(
$this->signer,
$this->key,
new DateTimeImmutable('2025-11-19 22:10:00'),
$clock,
),
),
new StrictValidAt($this->clock),
new IssuedBy($this->issuer),
);

self::assertInstanceOf(Plain::class, $token);
}
}
86 changes: 86 additions & 0 deletions tests/Validation/Constraint/SignedWithOneInSetTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);

namespace Lcobucci\JWT\Tests\Validation\Constraint;

use DateTimeImmutable;
use Lcobucci\Clock\FrozenClock;
use Lcobucci\JWT\Encoding\ChainedFormatter;
use Lcobucci\JWT\Encoding\JoseEncoder;
use Lcobucci\JWT\Encoding\UnifyAudience;
use Lcobucci\JWT\Encoding\UnixTimestampDates;
use Lcobucci\JWT\JwtFacade;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\SodiumBase64Polyfill;
use Lcobucci\JWT\Tests\Signer\FakeSigner;
use Lcobucci\JWT\Token\Builder;
use Lcobucci\JWT\Token\DataSet;
use Lcobucci\JWT\Token\Parser;
use Lcobucci\JWT\Token\Plain;
use Lcobucci\JWT\Token\Signature;
use Lcobucci\JWT\Validation\Constraint\SignedWith;
use Lcobucci\JWT\Validation\Constraint\SignedWithOneInSet;
use Lcobucci\JWT\Validation\Constraint\SignedWithUntilDate;
use Lcobucci\JWT\Validation\ConstraintViolation;
use PHPUnit\Framework\Attributes as PHPUnit;

use const PHP_EOL;

#[PHPUnit\CoversClass(SignedWithOneInSet::class)]
#[PHPUnit\CoversClass(SignedWithUntilDate::class)]
#[PHPUnit\CoversClass(SignedWith::class)]
#[PHPUnit\CoversClass(ConstraintViolation::class)]
#[PHPUnit\UsesClass(InMemory::class)]
#[PHPUnit\UsesClass(JwtFacade::class)]
#[PHPUnit\UsesClass(ChainedFormatter::class)]
#[PHPUnit\UsesClass(JoseEncoder::class)]
#[PHPUnit\UsesClass(UnifyAudience::class)]
#[PHPUnit\UsesClass(UnixTimestampDates::class)]
#[PHPUnit\UsesClass(SodiumBase64Polyfill::class)]
#[PHPUnit\UsesClass(Builder::class)]
#[PHPUnit\UsesClass(DataSet::class)]
#[PHPUnit\UsesClass(Plain::class)]
#[PHPUnit\UsesClass(Signature::class)]
#[PHPUnit\UsesClass(Parser::class)]
final class SignedWithOneInSetTest extends ConstraintTestCase
{
#[PHPUnit\Test]
public function exceptionShouldBeRaisedWhenSignatureIsNotVerifiedByAllConstraints(): void
{
$clock = new FrozenClock(new DateTimeImmutable('2023-11-19 22:20:00'));
$signer = new FakeSigner('123');

$constraint = new SignedWithOneInSet(
new SignedWithUntilDate($signer, InMemory::plainText('b'), $clock->now(), $clock),
new SignedWithUntilDate($signer, InMemory::plainText('c'), $clock->now()->modify('-2 minutes'), $clock),
);

$this->expectException(ConstraintViolation::class);
$this->expectExceptionMessage(
'It was not possible to verify the signature of the token, reasons:'
. PHP_EOL . '- Token signature mismatch'
. PHP_EOL . '- This constraint was only usable until 2023-11-19T22:18:00+00:00',
);

$token = $this->issueToken($signer, InMemory::plainText('a'));
$constraint->assert($token);
}

#[PHPUnit\Test]
public function assertShouldNotRaiseExceptionsWhenSignatureIsVerifiedByAtLeastOneConstraint(): void
{
$clock = new FrozenClock(new DateTimeImmutable('2023-11-19 22:20:00'));
$signer = new FakeSigner('123');

$constraint = new SignedWithOneInSet(
new SignedWithUntilDate($signer, InMemory::plainText('b'), $clock->now(), $clock),
new SignedWithUntilDate($signer, InMemory::plainText('c'), $clock->now()->modify('-2 minutes'), $clock),
new SignedWithUntilDate($signer, InMemory::plainText('a'), $clock->now(), $clock),
);

$token = $this->issueToken($signer, InMemory::plainText('a'));
$constraint->assert($token);

$this->addToAssertionCount(1);
}
}
Loading

0 comments on commit fbff2ed

Please sign in to comment.