diff --git a/src/Token/Parser.php b/src/Token/Parser.php index 530a727c..c8fb3848 100644 --- a/src/Token/Parser.php +++ b/src/Token/Parser.php @@ -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) @@ -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; diff --git a/test/functional/TimeFractionPrecisionTest.php b/test/functional/TimeFractionPrecisionTest.php index 4cb0534d..4e197c5c 100644 --- a/test/functional/TimeFractionPrecisionTest.php +++ b/test/functional/TimeFractionPrecisionTest.php @@ -5,6 +5,7 @@ use DateTimeImmutable; use Lcobucci\JWT\Configuration; +use Lcobucci\JWT\Encoding\JoseEncoder; use Lcobucci\JWT\Token\Plain; use PHPUnit\Framework\TestCase; @@ -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 */ + 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']; + } } diff --git a/test/unit/Token/ParserTest.php b/test/unit/Token/ParserTest.php index 5c1bf90c..70d9893d 100644 --- a/test/unit/Token/ParserTest.php +++ b/test/unit/Token/ParserTest.php @@ -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 * @@ -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.'); + } }