Repository: thephpleague/oauth2-server (php)

Issue:
Support Leeway in JWT Validation
The JWT library we use now supports clock leeway. We should allow implementations to use this feature in an upcoming version of this library

Pull request description:
Allow configuring leeway for the validation of jwt token dates
Fixes #1021

Patch:
diff --git a/src/AuthorizationValidators/BearerTokenValidator.php b/src/AuthorizationValidators/BearerTokenValidator.php
index faef5358..7723eda8 100644
--- a/src/AuthorizationValidators/BearerTokenValidator.php
+++ b/src/AuthorizationValidators/BearerTokenValidator.php
@@ -14,8 +14,8 @@ use Lcobucci\Clock\SystemClock;
 use Lcobucci\JWT\Configuration;
 use Lcobucci\JWT\Signer\Key\InMemory;
 use Lcobucci\JWT\Signer\Rsa\Sha256;
-use Lcobucci\JWT\Validation\Constraint\SignedWith;
 use Lcobucci\JWT\Validation\Constraint\LooseValidAt;
+use Lcobucci\JWT\Validation\Constraint\SignedWith;
 use Lcobucci\JWT\Validation\Constraint\ValidAt;
 use Lcobucci\JWT\Validation\RequiredConstraintsViolated;
 use League\OAuth2\Server\CryptKey;
@@ -43,12 +43,19 @@ class BearerTokenValidator implements AuthorizationValidatorInterface
      */
     private $jwtConfiguration;
 
+    /**
+     * @var \DateInterval|null
+     */
+    private $jwtValidAtDateLeeway;
+
     /**
      * @param AccessTokenRepositoryInterface $accessTokenRepository
+     * @param \DateInterval|null             $jwtValidAtDateLeeway
      */
-    public function __construct(AccessTokenRepositoryInterface $accessTokenRepository)
+    public function __construct(AccessTokenRepositoryInterface $accessTokenRepository, \DateInterval $jwtValidAtDateLeeway = null)
     {
         $this->accessTokenRepository = $accessTokenRepository;
+        $this->jwtValidAtDateLeeway = $jwtValidAtDateLeeway;
     }
 
     /**
@@ -73,10 +80,11 @@ class BearerTokenValidator implements AuthorizationValidatorInterface
             InMemory::plainText('empty', 'empty')
         );
 
+        $clock = new SystemClock(new DateTimeZone(\date_default_timezone_get()));
         $this->jwtConfiguration->setValidationConstraints(
             \class_exists(LooseValidAt::class)
-                ? new LooseValidAt(new SystemClock(new DateTimeZone(\date_default_timezone_get())))
-                : new ValidAt(new SystemClock(new DateTimeZone(\date_default_timezone_get()))),
+                ? new LooseValidAt($clock, $this->jwtValidAtDateLeeway)
+                : new ValidAt($clock, $this->jwtValidAtDateLeeway),
             new SignedWith(
                 new Sha256(),
                 InMemory::plainText($this->publicKey->getKeyContents(), $this->publicKey->getPassPhrase() ?? '')


Test patch:
diff --git a/tests/AuthorizationValidators/BearerTokenValidatorTest.php b/tests/AuthorizationValidators/BearerTokenValidatorTest.php
index 838d2bba..536618b9 100644
--- a/tests/AuthorizationValidators/BearerTokenValidatorTest.php
+++ b/tests/AuthorizationValidators/BearerTokenValidatorTest.php
@@ -71,4 +71,67 @@ class BearerTokenValidatorTest extends TestCase
 
         $bearerTokenValidator->validateAuthorization($request);
     }
+
+    public function testBearerTokenValidatorAcceptsExpiredTokenWithinLeeway()
+    {
+        $accessTokenRepositoryMock = $this->getMockBuilder(AccessTokenRepositoryInterface::class)->getMock();
+
+        // We fake generating this token 10 seconds into the future, an extreme example of possible time drift between servers
+        $future = (new DateTimeImmutable())->add(new DateInterval('PT10S'));
+
+        $bearerTokenValidator = new BearerTokenValidator($accessTokenRepositoryMock, new \DateInterval('PT10S'));
+        $bearerTokenValidator->setPublicKey(new CryptKey('file://' . __DIR__ . '/../Stubs/public.key'));
+
+        $bearerTokenValidatorReflection = new ReflectionClass(BearerTokenValidator::class);
+        $jwtConfiguration = $bearerTokenValidatorReflection->getProperty('jwtConfiguration');
+        $jwtConfiguration->setAccessible(true);
+
+        $jwtTokenFromFutureWithinLeeway = $jwtConfiguration->getValue($bearerTokenValidator)->builder()
+            ->permittedFor('client-id')
+            ->identifiedBy('token-id')
+            ->issuedAt($future)
+            ->canOnlyBeUsedAfter($future)
+            ->expiresAt((new DateTimeImmutable())->add(new DateInterval('PT1H')))
+            ->relatedTo('user-id')
+            ->withClaim('scopes', 'scope1 scope2 scope3 scope4')
+            ->getToken(new Sha256(), InMemory::file(__DIR__ . '/../Stubs/private.key'));
+
+        $request = (new ServerRequest())->withHeader('authorization', \sprintf('Bearer %s', $jwtTokenFromFutureWithinLeeway->toString()));
+
+        $validRequest = $bearerTokenValidator->validateAuthorization($request);
+
+        $this->assertArrayHasKey('authorization', $validRequest->getHeaders());
+    }
+
+    public function testBearerTokenValidatorRejectsExpiredTokenBeyondLeeway()
+    {
+        $accessTokenRepositoryMock = $this->getMockBuilder(AccessTokenRepositoryInterface::class)->getMock();
+
+        // We fake generating this token 10 seconds into the future, an extreme example of possible time drift between servers
+        $future = (new DateTimeImmutable())->add(new DateInterval('PT20S'));
+
+        $bearerTokenValidator = new BearerTokenValidator($accessTokenRepositoryMock, new \DateInterval('PT10S'));
+        $bearerTokenValidator->setPublicKey(new CryptKey('file://' . __DIR__ . '/../Stubs/public.key'));
+
+        $bearerTokenValidatorReflection = new ReflectionClass(BearerTokenValidator::class);
+        $jwtConfiguration = $bearerTokenValidatorReflection->getProperty('jwtConfiguration');
+        $jwtConfiguration->setAccessible(true);
+
+        $jwtTokenFromFutureBeyondLeeway = $jwtConfiguration->getValue($bearerTokenValidator)->builder()
+            ->permittedFor('client-id')
+            ->identifiedBy('token-id')
+            ->issuedAt($future)
+            ->canOnlyBeUsedAfter($future)
+            ->expiresAt((new DateTimeImmutable())->add(new DateInterval('PT1H')))
+            ->relatedTo('user-id')
+            ->withClaim('scopes', 'scope1 scope2 scope3 scope4')
+            ->getToken(new Sha256(), InMemory::file(__DIR__ . '/../Stubs/private.key'));
+
+        $request = (new ServerRequest())->withHeader('authorization', \sprintf('Bearer %s', $jwtTokenFromFutureBeyondLeeway->toString()));
+
+        $this->expectException(\League\OAuth2\Server\Exception\OAuthServerException::class);
+        $this->expectExceptionCode(9);
+
+        $bearerTokenValidator->validateAuthorization($request);
+    }
 }