Skip to content

Commit

Permalink
Prevent type conversions issues when parsing time-fractions
Browse files Browse the repository at this point in the history
We received reports of users experiencing exceptions when parsing JWTs
with timestamps with precision beyond microseconds on time-fractions.

This guards against having any kind of edge-case when dealing with
floats as input to the creation of DateTimeImmutable objects.
  • Loading branch information
lcobucci committed Mar 23, 2021
1 parent cefaeb3 commit c0938eb
Show file tree
Hide file tree
Showing 3 changed files with 136 additions and 14 deletions.
31 changes: 17 additions & 14 deletions src/Token/Parser.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,13 @@
use function count;
use function explode;
use function is_array;
use function is_string;
use function json_encode;
use function strpos;

use const JSON_THROW_ON_ERROR;
use function is_numeric;
use function number_format;

final class Parser implements ParserInterface
{
private const MICROSECOND_PRECISION = 6;

private Decoder $decoder;

public function __construct(Decoder $decoder)
Expand Down Expand Up @@ -109,25 +108,29 @@ private function parseClaims(string $data): array
continue;
}

$date = $claims[$claim];

$claims[$claim] = $this->convertDate(is_string($date) ? $date : json_encode($date, JSON_THROW_ON_ERROR));
$claims[$claim] = $this->convertDate($claims[$claim]);
}

return $claims;
}

/** @throws InvalidTokenStructure */
private function convertDate(string $value): DateTimeImmutable
/**
* @param int|float|string $timestamp
*
* @throws InvalidTokenStructure
*/
private function convertDate($timestamp): DateTimeImmutable
{
if (strpos($value, '.') === false) {
return new DateTimeImmutable('@' . $value);
if (! is_numeric($timestamp)) {
throw InvalidTokenStructure::dateIsNotParseable($timestamp);
}

$date = DateTimeImmutable::createFromFormat('U.u', $value);
$normalizedTimestamp = number_format((float) $timestamp, self::MICROSECOND_PRECISION, '.', '');

$date = DateTimeImmutable::createFromFormat('U.u', $normalizedTimestamp);

if ($date === false) {
throw InvalidTokenStructure::dateIsNotParseable($value);
throw InvalidTokenStructure::dateIsNotParseable($normalizedTimestamp);
}

return $date;
Expand Down
39 changes: 39 additions & 0 deletions test/functional/TimeFractionPrecisionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

use DateTimeImmutable;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Encoding\JoseEncoder;
use Lcobucci\JWT\Token\Plain;
use PHPUnit\Framework\TestCase;

Expand Down Expand Up @@ -58,4 +59,42 @@ public function datesWithPotentialRoundingIssues(): iterable
yield ['1613938511.018045'];
yield ['1616074725.008455'];
}

/**
* @test
* @dataProvider timeFractionConversions
*
* @param float|int|string $issuedAt
*/
public function typeConversionDoesNotCauseParsingErrors($issuedAt, string $timeFraction): void
{
$encoder = new JoseEncoder();
$headers = $encoder->base64UrlEncode($encoder->jsonEncode(['typ' => 'JWT', 'alg' => 'none']));
$claims = $encoder->base64UrlEncode($encoder->jsonEncode(['iat' => $issuedAt]));

$config = Configuration::forUnsecuredSigner();
$parsedToken = $config->parser()->parse($headers . '.' . $claims . '.');

self::assertInstanceOf(Plain::class, $parsedToken);
self::assertSame($timeFraction, $parsedToken->claims()->get('iat')->format('U.u'));
}

/** @return iterable<array{0: float|int|string, 1: string}> */
public function timeFractionConversions(): iterable
{
yield [1616481863.528781890869140625, '1616481863.528782'];
yield [1616497608.0510409, '1616497608.051041'];
yield [1616536852.1000001, '1616536852.100000'];
yield [1616457346.3878131, '1616457346.387813'];
yield [1616457346.0, '1616457346.000000'];

yield [1616457346, '1616457346.000000'];

yield ['1616481863.528781890869140625', '1616481863.528782'];
yield ['1616497608.0510409', '1616497608.051041'];
yield ['1616536852.1000001', '1616536852.100000'];
yield ['1616457346.3878131', '1616457346.387813'];
yield ['1616457346.0', '1616457346.000000'];
yield ['1616457346', '1616457346.000000'];
}
}
80 changes: 80 additions & 0 deletions test/unit/Token/ParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,49 @@ public function parseMustConvertDateClaimsToObjects(): void
);
}

/**
* @test
*
* @covers ::__construct
* @covers ::parse
* @covers ::splitJwt
* @covers ::parseHeader
* @covers ::parseClaims
* @covers ::parseSignature
* @covers ::convertDate
*
* @uses \Lcobucci\JWT\Token\Plain
* @uses \Lcobucci\JWT\Token\Signature
* @uses \Lcobucci\JWT\Token\DataSet
*/
public function parseMustConvertStringDates(): void
{
$data = [RegisteredClaims::NOT_BEFORE => '1486930757.000000'];

$this->decoder->expects(self::exactly(2))
->method('base64UrlDecode')
->withConsecutive(['a'], ['b'])
->willReturnOnConsecutiveCalls('a_dec', 'b_dec');

$this->decoder->expects(self::exactly(2))
->method('jsonDecode')
->withConsecutive(['a_dec'], ['b_dec'])
->willReturnOnConsecutiveCalls(
['typ' => 'JWT', 'alg' => 'HS256'],
$data
);

$token = $this->createParser()->parse('a.b.');
self::assertInstanceOf(Plain::class, $token);

$claims = $token->claims();

self::assertEquals(
DateTimeImmutable::createFromFormat('U.u', '1486930757.000000'),
$claims->get(RegisteredClaims::NOT_BEFORE)
);
}

/**
* @test
*
Expand Down Expand Up @@ -522,4 +565,41 @@ public function parseShouldRaiseExceptionOnInvalidDate(): void
$this->expectExceptionMessage('Value is not in the allowed date format: 14/10/2018 10:50:10.10 UTC');
$this->createParser()->parse('a.b.');
}

/**
* @test
*
* @covers ::__construct
* @covers ::parse
* @covers ::splitJwt
* @covers ::parseHeader
* @covers ::parseClaims
* @covers ::parseSignature
* @covers ::convertDate
* @covers \Lcobucci\JWT\Token\InvalidTokenStructure
*
* @uses \Lcobucci\JWT\Token\Plain
* @uses \Lcobucci\JWT\Token\Signature
* @uses \Lcobucci\JWT\Token\DataSet
*/
public function parseShouldRaiseExceptionOnTimestampBeyondDateTimeImmutableRange(): void
{
$data = [RegisteredClaims::ISSUED_AT => -10000000000 ** 5];

$this->decoder->expects(self::exactly(2))
->method('base64UrlDecode')
->withConsecutive(['a'], ['b'])
->willReturnOnConsecutiveCalls('a_dec', 'b_dec');

$this->decoder->expects(self::exactly(2))
->method('jsonDecode')
->withConsecutive(['a_dec'], ['b_dec'])
->willReturnOnConsecutiveCalls(
['typ' => 'JWT', 'alg' => 'HS256'],
$data
);

$this->expectException(InvalidTokenStructure::class);
$this->createParser()->parse('a.b.');
}
}

0 comments on commit c0938eb

Please sign in to comment.