Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions src/ExpiredException.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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;
}
Expand Down
62 changes: 58 additions & 4 deletions src/JWT.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,19 +38,47 @@
* 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;

Check failure on line 55 in src/JWT.php

View workflow job for this annotation

GitHub Actions / PHPStan Static Analysis

Property Firebase\JWT\JWT::$leeway has no type specified.
public static $leeway = 0;

Check failure on line 56 in src/JWT.php

View workflow job for this annotation

GitHub Actions / PHPStan Static Analysis

Property Firebase\JWT\JWT::$leeway has no type specified.

Check failure on line 56 in src/JWT.php

View workflow job for this annotation

GitHub Actions / PHPStan Static Analysis

Cannot redeclare property Firebase\JWT\JWT::$leeway.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

accidental change


/**
* 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<string, string[]>
*/
Expand Down Expand Up @@ -102,7 +130,15 @@
?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 {
Comment thread
saifulferoz marked this conversation as resolved.
$timestamp = \time();
}

if (empty($keyOrKeyArray)) {
throw new InvalidArgumentException('Key may not be empty');
Expand Down Expand Up @@ -167,7 +203,7 @@
// 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;
Expand All @@ -178,7 +214,7 @@
// 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;
Expand Down Expand Up @@ -546,6 +582,24 @@
);
}

/**
* 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.
*
Expand Down
105 changes: 105 additions & 0 deletions tests/JWTTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,111 @@ public function testInvalidTokenWithIatMicrotime()
JWT::decode($encoded, $this->hmacKey);
}

Comment thread
saifulferoz marked this conversation as resolved.
/**
* @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();
Expand Down
Loading