diff --git a/.readthedocs.yaml b/.readthedocs.yaml
new file mode 100644
index 00000000..aa49c943
--- /dev/null
+++ b/.readthedocs.yaml
@@ -0,0 +1,9 @@
+version: 2
+
+build:
+ os: ubuntu-22.04
+ tools:
+ python: "3"
+
+mkdocs:
+ configuration: mkdocs.yml
diff --git a/docs/rotating-keys.md b/docs/rotating-keys.md
new file mode 100644
index 00000000..25f17840
--- /dev/null
+++ b/docs/rotating-keys.md
@@ -0,0 +1,216 @@
+# Rotating Keys
+
+Key rotation consists in retiring and replacing cryptographic keys with new ones.
+Performing that operation on a regular basis is an industry standard.
+
+## Why should I rotate my keys?
+
+Rotating keys allows us to:
+
+1. Limit the number of tokens signed with the same key, helping the prevention of attacks enabled by cryptanalysis
+2. Adopt other algorithms or stronger keys
+3. Limit the impact of eventual compromised keys
+
+## The challenges
+
+After rotating keys, apps will likely receive requests with tokens issues with the previous key.
+If the key rotation of an app is done with a "hard cut", requests with non-expired tokens issued with the old key **will fail**!
+
+Imagine if you were the user who logged in just before a key rotation on that kind of app, you'd probably have to log in again!
+
+That's rather frustrating, right!?
+
+## Preventing issues
+
+It's possible to handle key rotation in a smoother way by leveraging the `SignedWithOneInSet` validation constraint!
+
+Say your application uses the symmetric algorithm `HS256` with a not so secure key to issue tokens:
+
+```php
+issue(
+ new Signer\Hmac\Sha256(),
+ InMemory::plainText(
+ 'a-very-long-and-secure-key-that-should-actually-be-something-else'
+ ),
+ static fn (Builder $builder): Builder => $builder
+ ->issuedBy('https://api.my-awesome-app.io')
+ ->permittedFor('https://client-app.io')
+);
+```
+
+!!! Sample
+ Here's a token issued with the code above, if you want to test the script locally:
+
+
+ Sample token
+
+ // line breaks added for readability
+ eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
+ .eyJpYXQiOjE2OTkxMzE5NjEsIm5iZiI6MTY5OTEzMTk2MSwiZXhwIjoxNjk5MTMyMjYxLCJpc3MiOiJ
+ odHRwczovL2FwaS5teS1hd2Vzb21lLWFwcC5pbyIsImF1ZCI6Imh0dHBzOi8vY2xpZW50LWFwcC5pbyJ9
+ .IA9S0n8Q2O97lyR8KczVE8g-hxbbH6_TfJS-JWTQR4c
+
+
+Your parsing logic (with validations) look like:
+
+```php
+parse($jwt, ...$validationConstraints);
+```
+
+### Performing a backwards compatible rotation
+
+Now Imagine that you want to adopt the new `BLAKE2B` symmetric algorithm.
+
+These are the changes to your issuing logic:
+
+```diff
+ issue(
+- new Signer\Hmac\Sha256(),
++ new Signer\Blake2b(),
+- InMemory::plainText(
+- 'a-very-long-and-secure-key-that-should-actually-be-something-else'
++ InMemory::base64Encoded(
++ 'GOu4rLyVCBxmxP+sbniU68ojAja5PkRdvv7vNvBCqDQ='
+ ),
+ static fn (Builder $builder): Builder => $builder
+ ->issuedBy('https://api.my-awesome-app.io')
+ ->permittedFor('https://client-app.io')
+ );
+```
+
+!!! Sample
+ Here's a token issued with the code above, if you want to test the script locally:
+
+
+ Sample token
+
+ // line breaks added for readability
+ eyJ0eXAiOiJKV1QiLCJhbGciOiJCTEFLRTJCIn0
+ .eyJpYXQiOjE2OTkxMzE5NjEsIm5iZiI6MTY5OTEzMTk2MSwiZXhwIjoxNjk5MTMyMjYxLCJpc3Mi
+ OiJodHRwczovL2FwaS5teS1hd2Vzb21lLWFwcC5pbyIsImF1ZCI6Imh0dHBzOi8vY2xpZW50LWFwc
+ C5pbyJ9.bD67s8IXpAJiBTIZn1et_M5WSS7kfmuNiacNRz5lArQ
+
+
+So far, nothing different that a normal rotation.
+
+Now check the changes on the parsing and validation logic:
+
+```diff
+ parse($jwt, ...$validationConstraints);
+```
+
+Now the application is able to accept non-expired tokens issued with either old and new keys!
+In this case, the old key would automatically only be accepted until `2023-12-31 23:59:59+00:00`, even if engineers forget to remove it from the list.
+
+!!! Important`
+ The order of `SignedWithUntilDate` constraints given to `SignedWithOneInSet` does matter, and it's recommended to leave older keys at the end of the list.
diff --git a/docs/validating-tokens.md b/docs/validating-tokens.md
index 7e130db9..fb5190a9 100644
--- a/docs/validating-tokens.md
+++ b/docs/validating-tokens.md
@@ -85,6 +85,8 @@ 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 `SignedWithUntilDate` constraints
+* `Lcobucci\JWT\Validation\Constraint\SignedWithUntilDate`: verifies if the token was signed with the expected signer and key (until a certain date)
* `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)
diff --git a/mkdocs.yml b/mkdocs.yml
index 8dbba334..1a3ef858 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -14,6 +14,7 @@ nav:
- 'configuration.md'
- Guides:
- 'extending-the-library.md'
+ - 'rotating-keys.md'
- 'upgrading.md'
markdown_extensions:
diff --git a/phpcs.xml.dist b/phpcs.xml.dist
index 91f86967..27ae9c74 100644
--- a/phpcs.xml.dist
+++ b/phpcs.xml.dist
@@ -16,7 +16,7 @@
- tests/UnsupportedParser.php
+ tests
diff --git a/src/Validation/Constraint/SignedWithOneInSet.php b/src/Validation/Constraint/SignedWithOneInSet.php
new file mode 100644
index 00000000..fb542fb3
--- /dev/null
+++ b/src/Validation/Constraint/SignedWithOneInSet.php
@@ -0,0 +1,38 @@
+ */
+ 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);
+ }
+}
diff --git a/src/Validation/Constraint/SignedWithUntilDate.php b/src/Validation/Constraint/SignedWithUntilDate.php
new file mode 100644
index 00000000..85429e89
--- /dev/null
+++ b/src/Validation/Constraint/SignedWithUntilDate.php
@@ -0,0 +1,47 @@
+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);
+ }
+}
diff --git a/tests/JwtFacadeTest.php b/tests/JwtFacadeTest.php
index ed3caea4..abf9a0f0 100644
--- a/tests/JwtFacadeTest.php
+++ b/tests/JwtFacadeTest.php
@@ -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
@@ -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
@@ -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(
@@ -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');
@@ -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(
@@ -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);
@@ -181,12 +165,7 @@ public function badSigner(): void
);
}
- /**
- * @test
- *
- * @covers ::issue
- * @covers ::parse
- */
+ #[PHPUnit\Test]
public function badKey(): void
{
$this->expectException(RequiredConstraintsViolated::class);
@@ -200,12 +179,7 @@ public function badKey(): void
);
}
- /**
- * @test
- *
- * @covers ::issue
- * @covers ::parse
- */
+ #[PHPUnit\Test]
public function badTime(): void
{
$token = $this->createToken();
@@ -222,12 +196,7 @@ public function badTime(): void
);
}
- /**
- * @test
- *
- * @covers ::issue
- * @covers ::parse
- */
+ #[PHPUnit\Test]
public function badIssuer(): void
{
$this->expectException(RequiredConstraintsViolated::class);
@@ -241,11 +210,7 @@ public function badIssuer(): void
);
}
- /**
- * @test
- *
- * @covers ::parse
- */
+ #[PHPUnit\Test]
public function parserForNonUnencryptedTokens(): void
{
$this->expectException(AssertionError::class);
@@ -258,12 +223,7 @@ public function parserForNonUnencryptedTokens(): void
);
}
- /**
- * @test
- *
- * @covers ::issue
- * @covers ::parse
- */
+ #[PHPUnit\Test]
public function customPsrClock(): void
{
$clock = new class () implements ClockInterface {
@@ -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);
+ }
}
diff --git a/tests/Signer/FakeSigner.php b/tests/Signer/FakeSigner.php
new file mode 100644
index 00000000..d6f934a4
--- /dev/null
+++ b/tests/Signer/FakeSigner.php
@@ -0,0 +1,30 @@
+signature;
+ }
+
+ public function sign(string $payload, Key $key): string
+ {
+ return $this->signature . '-' . $key->contents();
+ }
+
+ public function verify(string $expected, string $payload, Key $key): bool
+ {
+ return $this->signature . '-' . $key->contents() === $expected;
+ }
+}
diff --git a/tests/Validation/Constraint/ConstraintTestCase.php b/tests/Validation/Constraint/ConstraintTestCase.php
index 07860dda..9ef13b58 100644
--- a/tests/Validation/Constraint/ConstraintTestCase.php
+++ b/tests/Validation/Constraint/ConstraintTestCase.php
@@ -3,9 +3,14 @@
namespace Lcobucci\JWT\Tests\Validation\Constraint;
+use Closure;
+use Lcobucci\JWT\Builder;
+use Lcobucci\JWT\JwtFacade;
+use Lcobucci\JWT\Signer;
use Lcobucci\JWT\Token\DataSet;
use Lcobucci\JWT\Token\Plain;
use Lcobucci\JWT\Token\Signature;
+use Lcobucci\JWT\UnencryptedToken;
use PHPUnit\Framework\TestCase;
abstract class ConstraintTestCase extends TestCase
@@ -25,4 +30,13 @@ protected function buildToken(
$signature ?? new Signature('sig+hash', 'sig+encoded'),
);
}
+
+ protected function issueToken(Signer $signer, Signer\Key $key, ?Closure $customization = null): UnencryptedToken
+ {
+ return (new JwtFacade())->issue(
+ $signer,
+ $key,
+ $customization ?? static fn (Builder $builder) => $builder,
+ );
+ }
}
diff --git a/tests/Validation/Constraint/SignedWithOneInSetTest.php b/tests/Validation/Constraint/SignedWithOneInSetTest.php
new file mode 100644
index 00000000..622417a8
--- /dev/null
+++ b/tests/Validation/Constraint/SignedWithOneInSetTest.php
@@ -0,0 +1,86 @@
+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);
+ }
+}
diff --git a/tests/Validation/Constraint/SignedWithUntilDateTest.php b/tests/Validation/Constraint/SignedWithUntilDateTest.php
new file mode 100644
index 00000000..03980f23
--- /dev/null
+++ b/tests/Validation/Constraint/SignedWithUntilDateTest.php
@@ -0,0 +1,125 @@
+expectException(ConstraintViolation::class);
+ $this->expectExceptionMessage('This constraint was only usable until 2023-11-19T21:45:10+00:00');
+
+ $clock = new FrozenClock(new DateTimeImmutable('2023-11-19 22:45:10'));
+
+ $constraint = new SignedWithUntilDate(
+ new FakeSigner('1'),
+ InMemory::plainText('a'),
+ $clock->now()->modify('-1 hour'),
+ $clock,
+ );
+
+ $constraint->assert($this->issueToken(new FakeSigner('1'), InMemory::plainText('a')));
+ }
+
+ #[PHPUnit\Test]
+ public function assertShouldRaiseExceptionWhenTokenIsNotAPlainToken(): void
+ {
+ $this->expectException(ConstraintViolation::class);
+ $this->expectExceptionMessage('You should pass a plain token');
+
+ $clock = new FrozenClock(new DateTimeImmutable('2023-11-19 22:45:10'));
+
+ $constraint = new SignedWithUntilDate(new FakeSigner('1'), InMemory::plainText('a'), $clock->now(), $clock);
+ $constraint->assert($this->createMock(Token::class));
+ }
+
+ #[PHPUnit\Test]
+ public function assertShouldRaiseExceptionWhenSignerIsNotTheSame(): void
+ {
+ $this->expectException(ConstraintViolation::class);
+ $this->expectExceptionMessage('Token signer mismatch');
+
+ $clock = new FrozenClock(new DateTimeImmutable('2023-11-19 22:45:10'));
+ $key = InMemory::plainText('a');
+
+ $constraint = new SignedWithUntilDate(new FakeSigner('1'), $key, $clock->now(), $clock);
+ $constraint->assert($this->issueToken(new FakeSigner('2'), $key));
+ }
+
+ #[PHPUnit\Test]
+ public function assertShouldRaiseExceptionWhenSignatureIsInvalid(): void
+ {
+ $this->expectException(ConstraintViolation::class);
+ $this->expectExceptionMessage('Token signature mismatch');
+
+ $clock = new FrozenClock(new DateTimeImmutable('2023-11-19 22:45:10'));
+ $signer = new FakeSigner('1');
+
+ $constraint = new SignedWithUntilDate($signer, InMemory::plainText('a'), $clock->now(), $clock);
+ $constraint->assert($this->issueToken($signer, InMemory::plainText('b')));
+ }
+
+ #[PHPUnit\Test]
+ public function assertShouldNotRaiseExceptionWhenSignatureIsValid(): void
+ {
+ $clock = new FrozenClock(new DateTimeImmutable('2023-11-19 22:45:10'));
+
+ $signer = new FakeSigner('1');
+ $key = InMemory::plainText('a');
+
+ $constraint = new SignedWithUntilDate($signer, $key, $clock->now(), $clock);
+ $constraint->assert($this->issueToken($signer, $key));
+
+ $this->addToAssertionCount(1);
+ }
+
+ #[PHPUnit\Test]
+ public function clockShouldBeOptional(): void
+ {
+ $signer = new FakeSigner('1');
+ $key = InMemory::plainText('a');
+
+ $constraint = new SignedWithUntilDate($signer, $key, new DateTimeImmutable('+10 seconds'));
+ $constraint->assert($this->issueToken($signer, $key));
+
+ $this->addToAssertionCount(1);
+ }
+}