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 4, 2023
1 parent 3dd472d commit 15d5b75
Show file tree
Hide file tree
Showing 4 changed files with 144 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
39 changes: 39 additions & 0 deletions src/Validation/Constraint/SignedWithOneInSet.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?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<SignedWithInterface> */
private readonly array $constraints;

public function __construct(SignedWithInterface ...$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()
. ' (verified by ' . $violation->constraint . ')';
}
}

throw ConstraintViolation::error($errorMessage, $this);
}
}
84 changes: 29 additions & 55 deletions tests/JwtFacadeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@
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\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 +41,7 @@
* @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\StrictValidAt
* @uses \Lcobucci\JWT\Validation\ConstraintViolation
* @uses \Lcobucci\JWT\Validation\RequiredConstraintsViolated
Expand Down Expand Up @@ -72,12 +74,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 +102,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 +136,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 +149,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 +163,7 @@ public function badSigner(): void
);
}

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

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

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

/**
* @test
*
* @covers ::parse
*/
#[PHPUnit\Test]
public function parserForNonUnencryptedTokens(): void
{
$this->expectException(AssertionError::class);
Expand All @@ -258,12 +221,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 +248,20 @@ public function now(): DateTimeImmutable
),
);
}

#[PHPUnit\Test]
public function multipleKeys(): void
{
$token = (new JwtFacade())->parse(
$this->createToken(),
new SignedWithOneInSet(
new SignedWith($this->signer, InMemory::base64Encoded('czyPTpN595zVNSuvoNNlXCRFgXS2fHscMR36dGojaUE=')),
new SignedWith($this->signer, $this->key),
),
new StrictValidAt($this->clock),
new IssuedBy($this->issuer),
);

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

namespace Lcobucci\JWT\Tests\Validation\Constraint;

use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Token;
use Lcobucci\JWT\Token\DataSet;
use Lcobucci\JWT\Token\Plain;
use Lcobucci\JWT\Token\Signature;
use Lcobucci\JWT\Validation\Constraint\SignedWithOneInSet;
use Lcobucci\JWT\Validation\ConstraintViolation;
use Lcobucci\JWT\Validation\SignedWith;
use PHPUnit\Framework\Attributes as PHPUnit;

use const PHP_EOL;

#[PHPUnit\CoversClass(SignedWithOneInSet::class)]
#[PHPUnit\CoversClass(ConstraintViolation::class)]
#[PHPUnit\UsesClass(DataSet::class)]
#[PHPUnit\UsesClass(InMemory::class)]
#[PHPUnit\UsesClass(Plain::class)]
#[PHPUnit\UsesClass(Signature::class)]
#[PHPUnit\UsesClass(SignedWith::class)]
final class SignedWithOneInSetTest extends ConstraintTestCase
{
#[PHPUnit\Test]
public function exceptionShouldBeRaisedWhenSignatureIsNotVerifiedByAllConstraints(): void
{
$token = $this->createMock(Token::class);

$verifySignature1 = $this->createMock(SignedWith::class);
$verifySignature1
->expects(self::once())
->method('assert')
->willThrowException(ConstraintViolation::error('Msg1', $verifySignature1));

$verifySignature2 = $this->createMock(SignedWith::class);
$verifySignature2
->expects(self::once())
->method('assert')
->willThrowException(ConstraintViolation::error('Msg2', $verifySignature2));

$constraint = new SignedWithOneInSet($verifySignature1, $verifySignature2);

$this->expectException(ConstraintViolation::class);
$this->expectExceptionMessage(
'It was not possible to verify the signature of the token, reasons:'
. PHP_EOL . '- Msg1 (verified by ' . $verifySignature1::class . ')'
. PHP_EOL . '- Msg2 (verified by ' . $verifySignature2::class . ')',
);

$constraint->assert($token);
}

#[PHPUnit\Test]
public function assertShouldNotRaiseExceptionsWhenSignatureIsVerifiedByAtLeastOneConstraint(): void
{
$token = $this->createMock(Token::class);

$verifySignature1 = $this->createMock(SignedWith::class);
$verifySignature1
->expects(self::once())
->method('assert')
->willThrowException(ConstraintViolation::error('Msg', $verifySignature1));

$verifySignature2 = $this->createMock(SignedWith::class);
$verifySignature2->expects(self::once())->method('assert');

$constraint = new SignedWithOneInSet($verifySignature1, $verifySignature2);
$constraint->assert($token);

$this->addToAssertionCount(1);
}
}

0 comments on commit 15d5b75

Please sign in to comment.