diff --git a/README.md b/README.md index 59a99d9fd..f7c299cf5 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,24 @@ JWT::$leeway = 60; // $leeway in seconds $decoded = JWT::decode($jwt, new Key($key, 'HS256')); ``` +### Millisecond timestamps + +RFC 7519 requires the `iat`, `nbf` and `exp` claims to be expressed in +**seconds** since the Unix epoch. Some non-compliant token issuers emit these +claims in **milliseconds** instead, which causes `decode` to reject the token +(for example, throwing `Cannot handle token with iat prior to ...` with a date +far in the future). + +If you must consume such tokens, opt in by enabling millisecond mode. When +enabled, the reference time defaults to milliseconds +(`microtime(true) * 1000`), so claims are compared in the same unit. If you also +set `JWT::$timestamp` or `JWT::$leeway`, express them in milliseconds too. + +```php +JWT::$useMillisecondTimestamps = true; +$decoded = JWT::decode($jwt, new Key($key, 'HS256')); +``` + ## Example encode/decode headers Decoding the JWT headers without verifying the JWT first is NOT recommended, and is not supported by diff --git a/src/ExpiredException.php b/src/ExpiredException.php index 25f445132..5371fd5d3 100644 --- a/src/ExpiredException.php +++ b/src/ExpiredException.php @@ -6,7 +6,7 @@ class ExpiredException extends \UnexpectedValueException implements JWTException { private object $payload; - private ?int $timestamp = null; + private int|float|null $timestamp = null; public function setPayload(object $payload): void { @@ -18,12 +18,16 @@ public function getPayload(): object return $this->payload; } - public function setTimestamp(int $timestamp): void + /** + * @param int|float $timestamp Seconds, or milliseconds when + * JWT::$useMillisecondTimestamps is enabled. + */ + public function setTimestamp(int|float $timestamp): void { $this->timestamp = $timestamp; } - public function getTimestamp(): ?int + public function getTimestamp(): int|float|null { return $this->timestamp; } diff --git a/src/JWT.php b/src/JWT.php index 1c71ab3d0..df3d1f1aa 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -38,19 +38,47 @@ class JWT * we want to provide some extra leeway time to * account for clock skew. * + * When `JWT::$useMillisecondTimestamps` is enabled, this must be + * expressed in milliseconds. + * + * @var int + /** + * When checking nbf, iat or expiration times, + * we want to provide some extra leeway time to + * account for clock skew. + * + * NOTE: When `JWT::$useMillisecondTimestamps` is enabled, + * this must be expressed in milliseconds. + * * @var int */ public static $leeway = 0; + public static $leeway = 0; /** * Allow the current timestamp to be specified. * Useful for fixing a value within unit testing. * Will default to PHP time() value if null. * - * @var ?int + * @var int|float|null */ public static $timestamp = null; + /** + * When true, the 'iat', 'nbf' and 'exp' claims are interpreted as + * millisecond timestamps rather than the seconds mandated by RFC 7519. + * + * Some non-compliant token issuers emit these claims in milliseconds. + * Enabling this makes the reference time default to milliseconds + * (microtime(true) * 1000) so claims are compared in the same unit. + * + * NOTE: When enabled, `JWT::$timestamp` and `JWT::$leeway` must also be + * expressed in milliseconds if you set them. + * + * @var bool + */ + public static $useMillisecondTimestamps = false; + /** * @var array */ @@ -102,7 +130,15 @@ public static function decode( ?stdClass &$headers = null ): stdClass { // Validate JWT - $timestamp = \is_null(static::$timestamp) ? \time() : static::$timestamp; + if (!\is_null(static::$timestamp)) { + $timestamp = static::$timestamp; + } elseif (static::$useMillisecondTimestamps) { + // Keep the value as a float to avoid integer overflow on 32-bit + // systems, where a millisecond timestamp exceeds PHP_INT_MAX. + $timestamp = \round(\microtime(true) * 1000); + } else { + $timestamp = \time(); + } if (empty($keyOrKeyArray)) { throw new InvalidArgumentException('Key may not be empty'); @@ -167,7 +203,7 @@ public static function decode( // token can actually be used. If it's not yet that time, abort. if (isset($payload->nbf) && floor($payload->nbf) > ($timestamp + static::$leeway)) { $ex = new BeforeValidException( - 'Cannot handle token with nbf prior to ' . \date(DateTime::ATOM, (int) floor($payload->nbf)) + 'Cannot handle token with nbf prior to ' . \date(DateTime::ATOM, self::claimToSeconds($payload->nbf)) ); $ex->setPayload($payload); throw $ex; @@ -178,7 +214,7 @@ public static function decode( // correctly used the nbf claim). if (!isset($payload->nbf) && isset($payload->iat) && floor($payload->iat) > ($timestamp + static::$leeway)) { $ex = new BeforeValidException( - 'Cannot handle token with iat prior to ' . \date(DateTime::ATOM, (int) floor($payload->iat)) + 'Cannot handle token with iat prior to ' . \date(DateTime::ATOM, self::claimToSeconds($payload->iat)) ); $ex->setPayload($payload); throw $ex; @@ -546,6 +582,24 @@ private static function handleJsonError(int $errno): void ); } + /** + * Convert a date claim to whole seconds for human-readable output, + * accounting for `JWT::$useMillisecondTimestamps`. + * + * @param int|float|string $claim The 'iat', 'nbf' or 'exp' claim value. + * + * @return int Unix timestamp in seconds. + */ + private static function claimToSeconds($claim): int + { + $seconds = (float) $claim; + if (static::$useMillisecondTimestamps) { + $seconds /= 1000; + } + + return (int) \floor($seconds); + } + /** * Get the number of bytes in cryptographic strings. * diff --git a/tests/JWTTest.php b/tests/JWTTest.php index 40f45149c..09a246913 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -310,6 +310,111 @@ public function testInvalidTokenWithIatMicrotime() JWT::decode($encoded, $this->hmacKey); } + /** + * @runInSeparateProcess + */ + public function testValidTokenWithMillisecondTimestamps() + { + JWT::$useMillisecondTimestamps = true; + $nowMs = round(microtime(true) * 1000); + $payload = [ + 'message' => 'abc', + 'iat' => $nowMs, + 'exp' => $nowMs + 20000, // 20s in the future, in ms + ]; + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + $decoded = JWT::decode($encoded, $this->hmacKey); + $this->assertSame('abc', $decoded->message); + } + + /** + * @runInSeparateProcess + */ + public function testValidTokenWithMillisecondTimestampsAndExplicitOverrides() + { + JWT::$useMillisecondTimestamps = true; + // Fix "now" to a value that exceeds 32-bit PHP_INT_MAX, expressed in ms. + JWT::$timestamp = 1710000000000; // 2024-03-09T16:00:00Z in ms + JWT::$leeway = 60000; // 60s of leeway, in ms + $payload = [ + 'message' => 'abc', + // exp is 30s in the past, but within the 60s (ms) leeway window. + 'exp' => JWT::$timestamp - 30000, + // nbf is 30s in the future, also within the leeway window. + 'nbf' => JWT::$timestamp + 30000, + ]; + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + $decoded = JWT::decode($encoded, $this->hmacKey); + $this->assertSame('abc', $decoded->message); + } + + /** + * @runInSeparateProcess + */ + public function testMillisecondIatIsRejectedWithoutFlag() + { + $this->expectException(BeforeValidException::class); + $this->expectExceptionMessage('Cannot handle token with iat prior to'); + $payload = [ + 'message' => 'abc', + 'iat' => round(microtime(true) * 1000), // ms timestamp, but flag disabled + ]; + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + JWT::decode($encoded, $this->hmacKey); + } + + /** + * @runInSeparateProcess + */ + public function testExpiredTokenWithMillisecondTimestamps() + { + $this->expectException(ExpiredException::class); + JWT::$useMillisecondTimestamps = true; + $payload = [ + 'message' => 'abc', + 'exp' => round(microtime(true) * 1000) - 20000, // 20s in the past, in ms + ]; + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + JWT::decode($encoded, $this->hmacKey); + } + + /** + * @runInSeparateProcess + */ + public function testBeforeValidTokenWithMillisecondNbf() + { + $this->expectException(BeforeValidException::class); + $this->expectExceptionMessage('Cannot handle token with nbf prior to'); + JWT::$useMillisecondTimestamps = true; + $payload = [ + 'message' => 'abc', + 'nbf' => round(microtime(true) * 1000) + 20000, // 20s in the future, in ms + ]; + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + JWT::decode($encoded, $this->hmacKey); + } + + /** + * @runInSeparateProcess + */ + public function testMillisecondExceptionMessageRendersCorrectDate() + { + JWT::$useMillisecondTimestamps = true; + // 2033-05-18T03:33:20+00:00 expressed in milliseconds + $iatMs = 2000000000 * 1000; + $payload = [ + 'message' => 'abc', + 'iat' => $iatMs, + ]; + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + try { + JWT::decode($encoded, $this->hmacKey); + $this->fail('Expected BeforeValidException was not thrown'); + } catch (BeforeValidException $e) { + $this->assertStringContainsString('2033-05-18T03:33:20+00:00', $e->getMessage()); + } + } + public function testInvalidToken() { $encodeKey = $this->generateHmac256();