From 137c79c48f2b7f624237ab3cc37cceae2f000b6d Mon Sep 17 00:00:00 2001 From: Antonio Ramirez Date: Thu, 28 May 2026 07:39:59 +0200 Subject: [PATCH 1/2] re #96 migrate JWT adapter to lcobucci/jwt 5.x and fix content negotiation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Altair\Http\Jwt adapters targeted the lcobucci/jwt 3.x API (removed Jose\Parsing namespace, Constraint\ValidAt, Validation\InvalidTokenException, new Key() on an interface, Lcobucci\Clock\SystemClock) while the framework requires ^5.3 — so the code could not run against the installed library. JWT (lcobucci 5.x): - Generator and parser now derive a fresh Lcobucci\JWT\Configuration from the framework TokenConfigurationInterface instead of injecting shared Builder/Parser primitives (the builder is immutable in v5; the old shared-builder loop also silently discarded withClaim() results). - Parser performs full validation: signature (SignedWith, algorithm fixed to the configured signer so alg=none / alg-confusion is rejected), issuer (IssuedBy), and time window (LooseValidAt). The old validateToken() was dead code, so expiry was never enforced — that gap is now closed. - Add a PSR-20 Altair\Http\Jwt\SystemClock (injectable, frozen in tests) and declare psr/clock as a direct dependency. - Security hardening: reject the unconfigured TOKEN_PUBLIC_KEY placeholder at runtime (fail fast) and stop interpolating the raw token into parse-failure exception messages (log-injection / oracle risk). Content negotiation: - FormatNegotiator: narrow Negotiator::getBest() (typed as the empty AcceptHeader marker interface) to BaseAccept before calling getValue(), and flatten the mime priorities with array_merge(...) — the previous call_user_func('array_merge', ...) passed an array-of-arrays as one argument, so header negotiation never worked. Tests: round-trip, tampered-signature / foreign-key / expired / wrong-issuer / malformed rejection for JWT; Accept-header negotiation for FormatNegotiator. Regenerate phpstan-baseline.neon: 14 Lcobucci/Negotiation findings drop out; no entries added. Baseline 65 -> 51 errors. --- composer.json | 1 + phpstan-baseline.neon | 65 -------- .../LcobucciTokenConfiguration.php | 50 +++--- .../Http/Jwt/LcobucciTokenGenerator.php | 59 ++++--- src/Altair/Http/Jwt/LcobucciTokenParser.php | 100 ++++++------ src/Altair/Http/Jwt/SystemClock.php | 31 ++++ src/Altair/Http/Support/FormatNegotiator.php | 7 +- tests/Http/Jwt/FrozenClock.php | 33 ++++ tests/Http/Jwt/JwtTestKeys.php | 44 ++++++ tests/Http/Jwt/LcobucciTokenGeneratorTest.php | 82 ++++++++++ tests/Http/Jwt/LcobucciTokenParserTest.php | 146 ++++++++++++++++++ tests/Http/Jwt/SystemClockTest.php | 35 +++++ tests/Http/Support/FormatNegotiatorTest.php | 16 ++ 13 files changed, 521 insertions(+), 148 deletions(-) create mode 100644 src/Altair/Http/Jwt/SystemClock.php create mode 100644 tests/Http/Jwt/FrozenClock.php create mode 100644 tests/Http/Jwt/JwtTestKeys.php create mode 100644 tests/Http/Jwt/LcobucciTokenGeneratorTest.php create mode 100644 tests/Http/Jwt/LcobucciTokenParserTest.php create mode 100644 tests/Http/Jwt/SystemClockTest.php diff --git a/composer.json b/composer.json index 60223cde..5228aec7 100644 --- a/composer.json +++ b/composer.json @@ -23,6 +23,7 @@ "nikic/fast-route": "^1.3", "nikic/php-parser": "^5.0", "psr/cache": "^3.0", + "psr/clock": "^1.0", "psr/container": "^2.0", "psr/event-dispatcher": "^1.0", "psr/http-factory": "^1.1", diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index d8775916..fdd01333 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -140,66 +140,6 @@ parameters: count: 1 path: src/Altair/Http/Collection/HttpStatusCollection.php - - - message: "#^Class Lcobucci\\\\Jose\\\\Parsing\\\\Decoder not found\\.$#" - count: 1 - path: src/Altair/Http/Configuration/LcobucciTokenConfiguration.php - - - - message: "#^Class Lcobucci\\\\Jose\\\\Parsing\\\\Encoder not found\\.$#" - count: 1 - path: src/Altair/Http/Configuration/LcobucciTokenConfiguration.php - - - - message: "#^Class Lcobucci\\\\Jose\\\\Parsing\\\\Parser not found\\.$#" - count: 2 - path: src/Altair/Http/Configuration/LcobucciTokenConfiguration.php - - - - message: "#^Cannot cast Lcobucci\\\\JWT\\\\UnencryptedToken to string\\.$#" - count: 1 - path: src/Altair/Http/Jwt/LcobucciTokenGenerator.php - - - - message: "#^Cannot instantiate interface Lcobucci\\\\JWT\\\\Signer\\\\Key\\.$#" - count: 1 - path: src/Altair/Http/Jwt/LcobucciTokenGenerator.php - - - - message: "#^Call to method getMessage\\(\\) on an unknown class Lcobucci\\\\JWT\\\\Validation\\\\InvalidTokenException\\.$#" - count: 1 - path: src/Altair/Http/Jwt/LcobucciTokenParser.php - - - - message: "#^Cannot instantiate interface Lcobucci\\\\JWT\\\\Signer\\\\Key\\.$#" - count: 1 - path: src/Altair/Http/Jwt/LcobucciTokenParser.php - - - - message: "#^Caught class Lcobucci\\\\JWT\\\\Validation\\\\InvalidTokenException not found\\.$#" - count: 1 - path: src/Altair/Http/Jwt/LcobucciTokenParser.php - - - - message: "#^Instantiated class Lcobucci\\\\Clock\\\\SystemClock not found\\.$#" - count: 1 - path: src/Altair/Http/Jwt/LcobucciTokenParser.php - - - - message: "#^Instantiated class Lcobucci\\\\JWT\\\\Validation\\\\Constraint\\\\ValidAt not found\\.$#" - count: 1 - path: src/Altair/Http/Jwt/LcobucciTokenParser.php - - - - message: "#^Parameter \\#1 \\.\\.\\.\\$issuers of class Lcobucci\\\\JWT\\\\Validation\\\\Constraint\\\\IssuedBy constructor expects non\\-empty\\-string, Psr\\\\Http\\\\Message\\\\UriInterface given\\.$#" - count: 1 - path: src/Altair/Http/Jwt/LcobucciTokenParser.php - - - - message: "#^Parameter \\#3 \\.\\.\\.\\$constraints of method Lcobucci\\\\JWT\\\\Validator\\:\\:assert\\(\\) expects Lcobucci\\\\JWT\\\\Validation\\\\Constraint, Lcobucci\\\\JWT\\\\Validation\\\\Constraint\\\\ValidAt given\\.$#" - count: 1 - path: src/Altair/Http/Jwt/LcobucciTokenParser.php - - message: "#^Offset 'domain' on array\\{lifetime\\: int\\<0, max\\>, path\\: non\\-falsy\\-string, domain\\: string, secure\\: bool, httponly\\: bool, samesite\\: string\\} on left side of \\?\\? always exists and is not nullable\\.$#" count: 1 @@ -230,11 +170,6 @@ parameters: count: 1 path: src/Altair/Http/Support/DefaultErrorHandler.php - - - message: "#^Call to an undefined method Negotiation\\\\AcceptHeader\\:\\:getValue\\(\\)\\.$#" - count: 1 - path: src/Altair/Http/Support/FormatNegotiator.php - - message: "#^Unable to resolve the template type TEntity in call to method Cycle\\\\ORM\\\\ORMInterface\\:\\:getRepository\\(\\)$#" count: 1 diff --git a/src/Altair/Http/Configuration/LcobucciTokenConfiguration.php b/src/Altair/Http/Configuration/LcobucciTokenConfiguration.php index 9033d570..2f4b2cea 100644 --- a/src/Altair/Http/Configuration/LcobucciTokenConfiguration.php +++ b/src/Altair/Http/Configuration/LcobucciTokenConfiguration.php @@ -15,40 +15,54 @@ use Altair\Configuration\Traits\EnvAwareTrait; use Altair\Container\Container; use Altair\Http\Contracts\TokenConfigurationInterface; +use Altair\Http\Exception\RuntimeException; use Altair\Http\Support\TokenConfiguration; -use Lcobucci\Jose\Parsing\Decoder; -use Lcobucci\Jose\Parsing\Encoder; -use Lcobucci\JWT\Builder; -use Lcobucci\JWT\Parser; use Lcobucci\JWT\Signer\Rsa\Sha256; -use Lcobucci\JWT\Validator; use Override; +/** + * Wires the framework {@see TokenConfigurationInterface} from environment variables. + * + * The lcobucci/jwt v5 builder, parser and validator are derived on demand from this + * configuration inside {@see \Altair\Http\Jwt\LcobucciTokenGenerator} and + * {@see \Altair\Http\Jwt\LcobucciTokenParser}, so no library primitives are bound here. + */ class LcobucciTokenConfiguration implements ConfigurationInterface { use EnvAwareTrait; + /** + * Legacy placeholder default; rejected at runtime so a misconfigured deployment fails fast + * instead of silently issuing tokens that can never be verified. + */ + private const string UNCONFIGURED_KEY = 'YOU_SHOULD_CHANGE_THIS'; + /** * @inheritDoc */ #[Override] public function apply(Container $container): void { - $tokenGeneratorConfigurationFactory = fn(): TokenConfiguration => new TokenConfiguration( - $this->env->get('TOKEN_PUBLIC_KEY', 'YOU_SHOULD_CHANGE_THIS'), - (int) $this->env->get('TOKEN_TTL', \ini_get('session.gc_maxlifetime')), - new Sha256(), - null, - $this->env->get('TOKEN_PRIVATE_KEY') - ); + $tokenConfigurationFactory = function (): TokenConfiguration { + $publicKey = (string) $this->env->get('TOKEN_PUBLIC_KEY', ''); + + if ($publicKey === '' || $publicKey === self::UNCONFIGURED_KEY) { + throw new RuntimeException( + 'TOKEN_PUBLIC_KEY must be set to a valid token verification key.' + ); + } + + return new TokenConfiguration( + $publicKey, + (int) $this->env->get('TOKEN_TTL', \ini_get('session.gc_maxlifetime')), + new Sha256(), + null, + $this->env->get('TOKEN_PRIVATE_KEY') + ); + }; $container ->alias(TokenConfigurationInterface::class, TokenConfiguration::class) - ->alias(Validator::class, \Lcobucci\JWT\Validation\Validator::class) - ->alias(Encoder::class, \Lcobucci\Jose\Parsing\Parser::class) - ->alias(Decoder::class, \Lcobucci\Jose\Parsing\Parser::class) - ->alias(Builder::class, \Lcobucci\JWT\Token\Builder::class) - ->alias(Parser::class, \Lcobucci\JWT\Token\Parser::class) - ->delegate(TokenConfiguration::class, $tokenGeneratorConfigurationFactory); + ->delegate(TokenConfiguration::class, $tokenConfigurationFactory); } } diff --git a/src/Altair/Http/Jwt/LcobucciTokenGenerator.php b/src/Altair/Http/Jwt/LcobucciTokenGenerator.php index b020b2f0..f3651c56 100644 --- a/src/Altair/Http/Jwt/LcobucciTokenGenerator.php +++ b/src/Altair/Http/Jwt/LcobucciTokenGenerator.php @@ -13,42 +13,61 @@ use Altair\Http\Contracts\TokenConfigurationInterface; use Altair\Http\Contracts\TokenGeneratorInterface; +use Altair\Http\Exception\InvalidTokenException; use DateTimeImmutable; -use Lcobucci\JWT\Builder; -use Lcobucci\JWT\Signer\Key; +use Lcobucci\JWT\Configuration; +use Lcobucci\JWT\Signer\Key\InMemory; use Override; use Psr\Http\Message\ServerRequestInterface; +/** + * Generates signed JWTs using lcobucci/jwt v5. + * + * Each call mints a fresh, immutable builder from a {@see Configuration} derived from the + * framework {@see TokenConfigurationInterface}. Asymmetric signing is assumed (RSA/ECDSA), + * so a private key must be configured. + */ class LcobucciTokenGenerator implements TokenGeneratorInterface { - /** - * LcobucciTokenGenerator constructor. - */ - public function __construct(protected ServerRequestInterface $request, protected Builder $builder, protected TokenConfigurationInterface $config) {} + public function __construct( + protected ServerRequestInterface $request, + protected TokenConfigurationInterface $config + ) {} /** * @inheritDoc + * * @param array $claims + * + * @throws InvalidTokenException when no private key is configured for signing */ #[Override] public function generate(array $claims = []): string { - $issuer = (string) $this->request->getUri(); - $issued_at = (new DateTimeImmutable())->setTimestamp($this->config->getTimestamp()); - $expiration = (new DateTimeImmutable())->setTimestamp($this->config->getExpirationTimestamp()); - // Assumed RSA or ECDSA signatures (highly recommended) - // Signatures are based on public and private keys so you have to generate using the private key and verify - // using the public key - $key = new Key($this->config->getPrivateKey()); + $privateKey = $this->config->getPrivateKey(); + + if ($privateKey === null || $privateKey === '') { + throw new InvalidTokenException('A private key is required to generate a token.'); + } + + $configuration = Configuration::forAsymmetricSigner( + $this->config->getSigner(), + InMemory::plainText($privateKey), + InMemory::plainText($this->config->getPublicKey()), + ); + + $builder = $configuration->builder() + ->issuedBy((string) $this->request->getUri()) + ->issuedAt((new DateTimeImmutable())->setTimestamp($this->config->getTimestamp())) + ->expiresAt((new DateTimeImmutable())->setTimestamp($this->config->getExpirationTimestamp())); + foreach ($claims as $name => $value) { - $this->builder->withClaim($name, $value); + // Builder is immutable in v5: each withClaim() returns a new instance. + $builder = $builder->withClaim($name, $value); } - return (string) $this - ->builder - ->issuedBy($issuer) - ->issuedAt($issued_at) - ->expiresAt($expiration) - ->getToken($this->config->getSigner(), $key); + return $builder + ->getToken($configuration->signer(), $configuration->signingKey()) + ->toString(); } } diff --git a/src/Altair/Http/Jwt/LcobucciTokenParser.php b/src/Altair/Http/Jwt/LcobucciTokenParser.php index c3e4e9db..07bc5c0e 100644 --- a/src/Altair/Http/Jwt/LcobucciTokenParser.php +++ b/src/Altair/Http/Jwt/LcobucciTokenParser.php @@ -16,27 +16,41 @@ use Altair\Http\Contracts\TokenParserInterface; use Altair\Http\Exception\InvalidTokenException; use Altair\Http\Support\Token; -use InvalidArgumentException; -use Lcobucci\Clock\SystemClock; -use Lcobucci\JWT\Parser; -use Lcobucci\JWT\Signer\Key; -use Lcobucci\JWT\Token as LcobucciToken; -use Lcobucci\JWT\Token\Plain; +use Lcobucci\JWT\Configuration; +use Lcobucci\JWT\Encoding\CannotDecodeContent; +use Lcobucci\JWT\Signer\Key\InMemory; +use Lcobucci\JWT\Token\InvalidTokenStructure; +use Lcobucci\JWT\Token\UnsupportedHeaderFound; +use Lcobucci\JWT\UnencryptedToken; use Lcobucci\JWT\Validation\Constraint\IssuedBy; -use Lcobucci\JWT\Validation\Constraint\ValidAt; -use Lcobucci\JWT\Validation\InvalidTokenException as LcobucciInvalidTokenException; -use Lcobucci\JWT\Validator; +use Lcobucci\JWT\Validation\Constraint\LooseValidAt; +use Lcobucci\JWT\Validation\Constraint\SignedWith; +use Lcobucci\JWT\Validation\RequiredConstraintsViolated; use Override; +use Psr\Clock\ClockInterface; use Psr\Http\Message\ServerRequestInterface; +/** + * Parses and fully validates JWTs using lcobucci/jwt v5. + * + * Validation asserts the signature, the issuer (the requesting URI) and the token's + * time window (expiry / not-before / issued-at) against an injectable PSR-20 clock. + * + * The verification algorithm is fixed to the framework-configured signer; lcobucci's + * SignedWith rejects any token whose `alg` header does not match that signer, so + * algorithm-confusion and `alg=none` tokens are rejected before signature verification. + */ class LcobucciTokenParser implements TokenParserInterface { - /** - * @var Validator - */ - protected $validator; + private readonly ClockInterface $clock; - public function __construct(protected ServerRequestInterface $request, protected Parser $parser, protected TokenConfigurationInterface $config) {} + public function __construct( + protected ServerRequestInterface $request, + protected TokenConfigurationInterface $config, + ?ClockInterface $clock = null + ) { + $this->clock = $clock ?? new SystemClock(); + } /** * @inheritDoc @@ -44,49 +58,49 @@ public function __construct(protected ServerRequestInterface $request, protected #[Override] public function parse(string $token): TokenInterface { - /** @var Plain $parsed */ - $parsed = $this->parseToken($token); - $this->verifySignature($parsed, $token); + $configuration = $this->buildConfiguration(); + $parsed = $this->parseToken($configuration, $token); - return new Token($token, $parsed->claims()->all()); - } - - /** - * @throws InvalidTokenException - */ - protected function parseToken(string $token): LcobucciToken - { try { - return $this->parser->parse($token); - } catch (InvalidArgumentException) { - throw new InvalidTokenException(\sprintf('Count not parse authorization token "%s"', $token)); + $configuration->validator()->assert( + $parsed, + new SignedWith($this->config->getSigner(), $configuration->verificationKey()), + new IssuedBy((string) $this->request->getUri()), + new LooseValidAt($this->clock), + ); + } catch (RequiredConstraintsViolated $requiredConstraintsViolated) { + throw new InvalidTokenException($requiredConstraintsViolated->getMessage(), $requiredConstraintsViolated); } + + return new Token($token, $parsed->claims()->all()); } - /** - * - * @throws InvalidTokenException - */ - protected function verifySignature(Plain $token, string $jwt): void + private function buildConfiguration(): Configuration { - $key = new Key($this->config->getPublicKey()); + // The signing (private) key is never used when parsing; the public key satisfies + // the asymmetric factory and is the key actually used for signature verification. + $verificationKey = InMemory::plainText($this->config->getPublicKey()); - if (!$this->config->getSigner()->verify($token->signature()->hash(), $token->payload(), $key)) { - throw new InvalidTokenException( - \sprintf('Provided authorization token %s is invalid.', $jwt) - ); - } + return Configuration::forAsymmetricSigner($this->config->getSigner(), $verificationKey, $verificationKey); } /** * @throws InvalidTokenException */ - protected function validateToken(Plain $token): void + private function parseToken(Configuration $configuration, string $token): UnencryptedToken { + // The raw token is intentionally omitted from the message to avoid leaking it into + // logs/responses and to prevent log injection via crafted token bytes. try { - $this->validator->assert($token, new IssuedBy($this->request->getUri()), new ValidAt(new SystemClock())); - } catch (LcobucciInvalidTokenException $lcobucciInvalidTokenException) { - throw new InvalidTokenException($lcobucciInvalidTokenException->getMessage()); + $parsed = $configuration->parser()->parse($token); + } catch (CannotDecodeContent | InvalidTokenStructure | UnsupportedHeaderFound $exception) { + throw new InvalidTokenException('Could not parse the authorization token.', $exception); } + + if (!$parsed instanceof UnencryptedToken) { + throw new InvalidTokenException('Could not parse the authorization token.'); + } + + return $parsed; } } diff --git a/src/Altair/Http/Jwt/SystemClock.php b/src/Altair/Http/Jwt/SystemClock.php new file mode 100644 index 00000000..f5522a6a --- /dev/null +++ b/src/Altair/Http/Jwt/SystemClock.php @@ -0,0 +1,31 @@ +formats, 1)); + $headers = array_merge(...array_column($this->formats, 1)); $mimeType = $this->negotiateHeader($request->getHeaderLine('Accept'), $headers); if (null !== $mimeType) { @@ -156,7 +157,9 @@ protected function negotiateHeader(string $accept, array $priorities): ?string return null; } - if (null !== $best) { + // Negotiator::getBest() is typed against the empty AcceptHeader marker interface; + // the concrete result is a BaseAccept, which exposes the negotiated value. + if ($best instanceof BaseAccept) { return $best->getValue(); } diff --git a/tests/Http/Jwt/FrozenClock.php b/tests/Http/Jwt/FrozenClock.php new file mode 100644 index 00000000..39893ab0 --- /dev/null +++ b/tests/Http/Jwt/FrozenClock.php @@ -0,0 +1,33 @@ +setTimestamp($timestamp)); + } + + public function now(): DateTimeImmutable + { + return $this->now; + } +} diff --git a/tests/Http/Jwt/JwtTestKeys.php b/tests/Http/Jwt/JwtTestKeys.php new file mode 100644 index 00000000..59be2454 --- /dev/null +++ b/tests/Http/Jwt/JwtTestKeys.php @@ -0,0 +1,44 @@ + 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + + if ($resource === false) { + throw new RuntimeException('Unable to generate an RSA key pair for testing.'); + } + + openssl_pkey_export($resource, $privateKey); + $details = openssl_pkey_get_details($resource); + + if ($details === false || !isset($details['key'])) { + throw new RuntimeException('Unable to export the RSA public key for testing.'); + } + + return [$privateKey, $details['key']]; + } +} diff --git a/tests/Http/Jwt/LcobucciTokenGeneratorTest.php b/tests/Http/Jwt/LcobucciTokenGeneratorTest.php new file mode 100644 index 00000000..c9a127d6 --- /dev/null +++ b/tests/Http/Jwt/LcobucciTokenGeneratorTest.php @@ -0,0 +1,82 @@ +withUri(new Uri('https://api.example.test/login')); + $config = new TokenConfiguration(self::$publicKey, $ttl, new Sha256(), $issuedAt, self::$privateKey); + + $jwt = (new LcobucciTokenGenerator($request, $config))->generate(['uid' => 42, 'role' => 'admin']); + + self::assertCount(3, explode('.', $jwt), 'A JWT is a three-segment dot-delimited string.'); + + $parsed = $this->verificationConfiguration()->parser()->parse($jwt); + self::assertInstanceOf(UnencryptedToken::class, $parsed); + + $claims = $parsed->claims(); + self::assertSame('https://api.example.test/login', $claims->get('iss')); + self::assertSame(42, $claims->get('uid')); + self::assertSame('admin', $claims->get('role')); + self::assertSame($issuedAt, $claims->get('iat')->getTimestamp()); + self::assertSame($issuedAt + $ttl, $claims->get('exp')->getTimestamp()); + } + + public function testGenerateThrowsWhenPrivateKeyIsMissing(): void + { + $request = (new ServerRequest())->withUri(new Uri('https://api.example.test/login')); + $config = new TokenConfiguration(self::$publicKey, 3600, new Sha256(), 1_700_000_000, null); + + $this->expectException(InvalidTokenException::class); + + (new LcobucciTokenGenerator($request, $config))->generate(); + } + + private function verificationConfiguration(): Configuration + { + return Configuration::forAsymmetricSigner( + new Sha256(), + InMemory::plainText(self::$privateKey), + InMemory::plainText(self::$publicKey), + ); + } +} diff --git a/tests/Http/Jwt/LcobucciTokenParserTest.php b/tests/Http/Jwt/LcobucciTokenParserTest.php new file mode 100644 index 00000000..e0ec2976 --- /dev/null +++ b/tests/Http/Jwt/LcobucciTokenParserTest.php @@ -0,0 +1,146 @@ +mintToken(self::ISSUER, self::ISSUED_AT + self::TTL, ['uid' => 7]); + $parser = $this->parser(self::ISSUER, FrozenClock::at(self::ISSUED_AT + 10)); + + $token = $parser->parse($jwt); + + self::assertSame($jwt, $token->getToken()); + self::assertSame(7, $token->getMetadata('uid')); + } + + public function testParseRejectsMalformedToken(): void + { + $this->expectException(InvalidTokenException::class); + + $this->parser(self::ISSUER, FrozenClock::at(self::ISSUED_AT))->parse('this-is-not-a-jwt'); + } + + public function testParseRejectsTamperedSignature(): void + { + $jwt = $this->mintToken(self::ISSUER, self::ISSUED_AT + self::TTL, ['uid' => 7]); + $tampered = substr($jwt, 0, -2) . (str_ends_with($jwt, 'AA') ? 'BB' : 'AA'); + + $this->expectException(InvalidTokenException::class); + + $this->parser(self::ISSUER, FrozenClock::at(self::ISSUED_AT + 10))->parse($tampered); + } + + public function testParseRejectsExpiredToken(): void + { + $jwt = $this->mintToken(self::ISSUER, self::ISSUED_AT + self::TTL, ['uid' => 7]); + $parser = $this->parser(self::ISSUER, FrozenClock::at(self::ISSUED_AT + self::TTL + 1)); + + $this->expectException(InvalidTokenException::class); + + $parser->parse($jwt); + } + + public function testParseRejectsWrongIssuer(): void + { + $jwt = $this->mintToken('https://evil.example.test', self::ISSUED_AT + self::TTL, ['uid' => 7]); + $parser = $this->parser(self::ISSUER, FrozenClock::at(self::ISSUED_AT + 10)); + + $this->expectException(InvalidTokenException::class); + + $parser->parse($jwt); + } + + public function testParseRejectsTokenSignedWithForeignKey(): void + { + [$foreignPrivate, $foreignPublic] = JwtTestKeys::rsaKeyPair(); + $foreign = Configuration::forAsymmetricSigner( + new Sha256(), + InMemory::plainText($foreignPrivate), + InMemory::plainText($foreignPublic), + ); + $jwt = $foreign->builder() + ->issuedBy(self::ISSUER) + ->issuedAt((new DateTimeImmutable())->setTimestamp(self::ISSUED_AT)) + ->expiresAt((new DateTimeImmutable())->setTimestamp(self::ISSUED_AT + self::TTL)) + ->getToken($foreign->signer(), $foreign->signingKey()) + ->toString(); + + $this->expectException(InvalidTokenException::class); + + $this->parser(self::ISSUER, FrozenClock::at(self::ISSUED_AT + 10))->parse($jwt); + } + + /** + * @param array $claims + */ + private function mintToken(string $issuer, int $expiresAt, array $claims): string + { + $configuration = Configuration::forAsymmetricSigner( + new Sha256(), + InMemory::plainText(self::$privateKey), + InMemory::plainText(self::$publicKey), + ); + + $builder = $configuration->builder() + ->issuedBy($issuer) + ->issuedAt((new DateTimeImmutable())->setTimestamp(self::ISSUED_AT)) + ->expiresAt((new DateTimeImmutable())->setTimestamp($expiresAt)); + + foreach ($claims as $name => $value) { + $builder = $builder->withClaim($name, $value); + } + + return $builder->getToken($configuration->signer(), $configuration->signingKey())->toString(); + } + + private function parser(string $requestUri, FrozenClock $clock): LcobucciTokenParser + { + $request = (new ServerRequest())->withUri(new Uri($requestUri)); + $config = new TokenConfiguration(self::$publicKey, self::TTL, new Sha256(), self::ISSUED_AT, self::$privateKey); + + return new LcobucciTokenParser($request, $config, $clock); + } +} diff --git a/tests/Http/Jwt/SystemClockTest.php b/tests/Http/Jwt/SystemClockTest.php new file mode 100644 index 00000000..6cf5fa1a --- /dev/null +++ b/tests/Http/Jwt/SystemClockTest.php @@ -0,0 +1,35 @@ +now()->getTimestamp(); + $after = time(); + + self::assertGreaterThanOrEqual($before, $now); + self::assertLessThanOrEqual($after, $now); + } +} diff --git a/tests/Http/Support/FormatNegotiatorTest.php b/tests/Http/Support/FormatNegotiatorTest.php index 44858362..8c6e46bd 100644 --- a/tests/Http/Support/FormatNegotiatorTest.php +++ b/tests/Http/Support/FormatNegotiatorTest.php @@ -13,6 +13,7 @@ use Altair\Http\Exception\InvalidArgumentException; use Altair\Http\Support\FormatNegotiator; +use Laminas\Diactoros\ServerRequest; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -36,4 +37,19 @@ public function testThrowsWhenFormatIsNotRegistered(): void $negotiator->getContentTypeByFormat('definitely-not-a-format'); } + + public function testNegotiatesFormatFromAcceptHeader(): void + { + $negotiator = new FormatNegotiator(); + $request = (new ServerRequest())->withHeader('Accept', 'application/json, text/html;q=0.5'); + + self::assertSame('json', $negotiator->getFromServerRequestHeaderLine($request)); + } + + public function testReturnsNullWhenAcceptHeaderIsAbsent(): void + { + $negotiator = new FormatNegotiator(); + + self::assertNull($negotiator->getFromServerRequestHeaderLine(new ServerRequest())); + } } From 6987f4c4a5d1cdb09ed2337ac6939c58cd90d559 Mon Sep 17 00:00:00 2001 From: Antonio Ramirez Date: Thu, 28 May 2026 07:48:22 +0200 Subject: [PATCH 2/2] re #96 harden JWT issuer/audience and fix rector Override findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the JWT security-review follow-ups (Findings 4 & 5): - iss is now a stable, configurable issuer (TOKEN_ISSUER) on TokenConfigurationInterface instead of the per-request URI. The old request-URI issuer made a token valid only at the exact endpoint that minted it; the generator and parser no longer depend on ServerRequest. TOKEN_ISSUER is required (fail fast) like TOKEN_PUBLIC_KEY. - Optional audience (aud): when TOKEN_AUDIENCE is configured the generator adds permittedFor() and the parser asserts PermittedFor; absent audience keeps the single-service default (no aud). Also add #[\Override] to the JWT test helpers/fixtures (FrozenClock::now, setUpBeforeClass) so the full-codebase `rector process --dry-run` is clean — these were missed because the migration commit only ran rector against src/. Tests extended for the configured issuer and for audience accept/reject. --- .../LcobucciTokenConfiguration.php | 14 ++++- .../Contracts/TokenConfigurationInterface.php | 10 +++ .../Http/Jwt/LcobucciTokenGenerator.php | 17 +++--- src/Altair/Http/Jwt/LcobucciTokenParser.php | 36 ++++++++--- .../Http/Support/TokenConfiguration.php | 22 ++++++- tests/Http/Jwt/FrozenClock.php | 2 + tests/Http/Jwt/LcobucciTokenGeneratorTest.php | 47 ++++++++++---- tests/Http/Jwt/LcobucciTokenParserTest.php | 61 ++++++++++++++----- 8 files changed, 163 insertions(+), 46 deletions(-) diff --git a/src/Altair/Http/Configuration/LcobucciTokenConfiguration.php b/src/Altair/Http/Configuration/LcobucciTokenConfiguration.php index 2f4b2cea..d1ea6552 100644 --- a/src/Altair/Http/Configuration/LcobucciTokenConfiguration.php +++ b/src/Altair/Http/Configuration/LcobucciTokenConfiguration.php @@ -52,12 +52,24 @@ public function apply(Container $container): void ); } + $issuer = (string) $this->env->get('TOKEN_ISSUER', ''); + + if ($issuer === '') { + throw new RuntimeException( + 'TOKEN_ISSUER must be set to a stable issuer identifier for the tokens this service mints.' + ); + } + + $audience = $this->env->get('TOKEN_AUDIENCE'); + return new TokenConfiguration( $publicKey, (int) $this->env->get('TOKEN_TTL', \ini_get('session.gc_maxlifetime')), new Sha256(), + $issuer, null, - $this->env->get('TOKEN_PRIVATE_KEY') + $this->env->get('TOKEN_PRIVATE_KEY'), + $audience === null ? null : (string) $audience ); }; diff --git a/src/Altair/Http/Contracts/TokenConfigurationInterface.php b/src/Altair/Http/Contracts/TokenConfigurationInterface.php index b35c8639..5d595e99 100644 --- a/src/Altair/Http/Contracts/TokenConfigurationInterface.php +++ b/src/Altair/Http/Contracts/TokenConfigurationInterface.php @@ -27,6 +27,16 @@ public function getTtl(): int; public function getSigner(): Signer; + /** + * The stable issuer identifier (`iss` claim) for tokens minted and validated by this service. + */ + public function getIssuer(): string; + + /** + * The intended audience (`aud` claim), or null when no audience restriction applies. + */ + public function getAudience(): ?string; + /** * Returns the timestamp for token issuance and expiration in UNIX_TIMESTAMP format. */ diff --git a/src/Altair/Http/Jwt/LcobucciTokenGenerator.php b/src/Altair/Http/Jwt/LcobucciTokenGenerator.php index f3651c56..0f6c0a3d 100644 --- a/src/Altair/Http/Jwt/LcobucciTokenGenerator.php +++ b/src/Altair/Http/Jwt/LcobucciTokenGenerator.php @@ -18,21 +18,18 @@ use Lcobucci\JWT\Configuration; use Lcobucci\JWT\Signer\Key\InMemory; use Override; -use Psr\Http\Message\ServerRequestInterface; /** * Generates signed JWTs using lcobucci/jwt v5. * * Each call mints a fresh, immutable builder from a {@see Configuration} derived from the * framework {@see TokenConfigurationInterface}. Asymmetric signing is assumed (RSA/ECDSA), - * so a private key must be configured. + * so a private key must be configured. The `iss` claim is the configured stable issuer and + * the `aud` claim is added when an audience is configured. */ class LcobucciTokenGenerator implements TokenGeneratorInterface { - public function __construct( - protected ServerRequestInterface $request, - protected TokenConfigurationInterface $config - ) {} + public function __construct(protected TokenConfigurationInterface $config) {} /** * @inheritDoc @@ -57,10 +54,16 @@ public function generate(array $claims = []): string ); $builder = $configuration->builder() - ->issuedBy((string) $this->request->getUri()) + ->issuedBy($this->config->getIssuer()) ->issuedAt((new DateTimeImmutable())->setTimestamp($this->config->getTimestamp())) ->expiresAt((new DateTimeImmutable())->setTimestamp($this->config->getExpirationTimestamp())); + $audience = $this->config->getAudience(); + + if ($audience !== null && $audience !== '') { + $builder = $builder->permittedFor($audience); + } + foreach ($claims as $name => $value) { // Builder is immutable in v5: each withClaim() returns a new instance. $builder = $builder->withClaim($name, $value); diff --git a/src/Altair/Http/Jwt/LcobucciTokenParser.php b/src/Altair/Http/Jwt/LcobucciTokenParser.php index 07bc5c0e..ec220a62 100644 --- a/src/Altair/Http/Jwt/LcobucciTokenParser.php +++ b/src/Altair/Http/Jwt/LcobucciTokenParser.php @@ -22,19 +22,21 @@ use Lcobucci\JWT\Token\InvalidTokenStructure; use Lcobucci\JWT\Token\UnsupportedHeaderFound; use Lcobucci\JWT\UnencryptedToken; +use Lcobucci\JWT\Validation\Constraint; use Lcobucci\JWT\Validation\Constraint\IssuedBy; use Lcobucci\JWT\Validation\Constraint\LooseValidAt; +use Lcobucci\JWT\Validation\Constraint\PermittedFor; use Lcobucci\JWT\Validation\Constraint\SignedWith; use Lcobucci\JWT\Validation\RequiredConstraintsViolated; use Override; use Psr\Clock\ClockInterface; -use Psr\Http\Message\ServerRequestInterface; /** * Parses and fully validates JWTs using lcobucci/jwt v5. * - * Validation asserts the signature, the issuer (the requesting URI) and the token's - * time window (expiry / not-before / issued-at) against an injectable PSR-20 clock. + * Validation asserts the signature, the configured issuer, the token's time window + * (expiry / not-before / issued-at) against an injectable PSR-20 clock, and — when an + * audience is configured — that the token is permitted for that audience. * * The verification algorithm is fixed to the framework-configured signer; lcobucci's * SignedWith rejects any token whose `alg` header does not match that signer, so @@ -45,7 +47,6 @@ class LcobucciTokenParser implements TokenParserInterface private readonly ClockInterface $clock; public function __construct( - protected ServerRequestInterface $request, protected TokenConfigurationInterface $config, ?ClockInterface $clock = null ) { @@ -62,12 +63,7 @@ public function parse(string $token): TokenInterface $parsed = $this->parseToken($configuration, $token); try { - $configuration->validator()->assert( - $parsed, - new SignedWith($this->config->getSigner(), $configuration->verificationKey()), - new IssuedBy((string) $this->request->getUri()), - new LooseValidAt($this->clock), - ); + $configuration->validator()->assert($parsed, ...$this->constraints($configuration)); } catch (RequiredConstraintsViolated $requiredConstraintsViolated) { throw new InvalidTokenException($requiredConstraintsViolated->getMessage(), $requiredConstraintsViolated); } @@ -75,6 +71,26 @@ public function parse(string $token): TokenInterface return new Token($token, $parsed->claims()->all()); } + /** + * @return list + */ + private function constraints(Configuration $configuration): array + { + $constraints = [ + new SignedWith($this->config->getSigner(), $configuration->verificationKey()), + new IssuedBy($this->config->getIssuer()), + new LooseValidAt($this->clock), + ]; + + $audience = $this->config->getAudience(); + + if ($audience !== null && $audience !== '') { + $constraints[] = new PermittedFor($audience); + } + + return $constraints; + } + private function buildConfiguration(): Configuration { // The signing (private) key is never used when parsing; the public key satisfies diff --git a/src/Altair/Http/Support/TokenConfiguration.php b/src/Altair/Http/Support/TokenConfiguration.php index 2200d82c..7ed9c3db 100644 --- a/src/Altair/Http/Support/TokenConfiguration.php +++ b/src/Altair/Http/Support/TokenConfiguration.php @@ -26,8 +26,10 @@ public function __construct( private string $publicKey, private int $ttl, private Signer $signer, + private string $issuer, ?int $timestamp = null, - private ?string $privateKey = null + private ?string $privateKey = null, + private ?string $audience = null ) { $this->timestamp = $timestamp ?: time(); } @@ -59,6 +61,24 @@ public function getSigner(): Signer return $this->signer; } + /** + * @inheritDoc + */ + #[Override] + public function getIssuer(): string + { + return $this->issuer; + } + + /** + * @inheritDoc + */ + #[Override] + public function getAudience(): ?string + { + return $this->audience; + } + /** * @inheritDoc */ diff --git a/tests/Http/Jwt/FrozenClock.php b/tests/Http/Jwt/FrozenClock.php index 39893ab0..ed370b0b 100644 --- a/tests/Http/Jwt/FrozenClock.php +++ b/tests/Http/Jwt/FrozenClock.php @@ -12,6 +12,7 @@ namespace Altair\Tests\Http\Jwt; use DateTimeImmutable; +use Override; use Psr\Clock\ClockInterface; /** @@ -26,6 +27,7 @@ public static function at(int $timestamp): self return new self((new DateTimeImmutable())->setTimestamp($timestamp)); } + #[Override] public function now(): DateTimeImmutable { return $this->now; diff --git a/tests/Http/Jwt/LcobucciTokenGeneratorTest.php b/tests/Http/Jwt/LcobucciTokenGeneratorTest.php index c9a127d6..8d47dc6d 100644 --- a/tests/Http/Jwt/LcobucciTokenGeneratorTest.php +++ b/tests/Http/Jwt/LcobucciTokenGeneratorTest.php @@ -14,22 +14,28 @@ use Altair\Http\Exception\InvalidTokenException; use Altair\Http\Jwt\LcobucciTokenGenerator; use Altair\Http\Support\TokenConfiguration; -use Laminas\Diactoros\ServerRequest; -use Laminas\Diactoros\Uri; use Lcobucci\JWT\Configuration; use Lcobucci\JWT\Signer\Key\InMemory; use Lcobucci\JWT\Signer\Rsa\Sha256; use Lcobucci\JWT\UnencryptedToken; +use Override; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; #[CoversClass(LcobucciTokenGenerator::class)] final class LcobucciTokenGeneratorTest extends TestCase { + private const string ISSUER = 'https://api.example.test'; + + private const int ISSUED_AT = 1_700_000_000; + + private const int TTL = 3600; + private static string $privateKey; private static string $publicKey; + #[Override] public static function setUpBeforeClass(): void { if (!\extension_loaded('openssl')) { @@ -41,12 +47,9 @@ public static function setUpBeforeClass(): void public function testGenerateProducesParseableTokenWithRegisteredAndCustomClaims(): void { - $issuedAt = 1_700_000_000; - $ttl = 3600; - $request = (new ServerRequest())->withUri(new Uri('https://api.example.test/login')); - $config = new TokenConfiguration(self::$publicKey, $ttl, new Sha256(), $issuedAt, self::$privateKey); + $config = new TokenConfiguration(self::$publicKey, self::TTL, new Sha256(), self::ISSUER, self::ISSUED_AT, self::$privateKey); - $jwt = (new LcobucciTokenGenerator($request, $config))->generate(['uid' => 42, 'role' => 'admin']); + $jwt = (new LcobucciTokenGenerator($config))->generate(['uid' => 42, 'role' => 'admin']); self::assertCount(3, explode('.', $jwt), 'A JWT is a three-segment dot-delimited string.'); @@ -54,21 +57,39 @@ public function testGenerateProducesParseableTokenWithRegisteredAndCustomClaims( self::assertInstanceOf(UnencryptedToken::class, $parsed); $claims = $parsed->claims(); - self::assertSame('https://api.example.test/login', $claims->get('iss')); + self::assertSame(self::ISSUER, $claims->get('iss')); self::assertSame(42, $claims->get('uid')); self::assertSame('admin', $claims->get('role')); - self::assertSame($issuedAt, $claims->get('iat')->getTimestamp()); - self::assertSame($issuedAt + $ttl, $claims->get('exp')->getTimestamp()); + self::assertSame(self::ISSUED_AT, $claims->get('iat')->getTimestamp()); + self::assertSame(self::ISSUED_AT + self::TTL, $claims->get('exp')->getTimestamp()); + } + + public function testGenerateIncludesAudienceWhenConfigured(): void + { + $config = new TokenConfiguration( + self::$publicKey, + self::TTL, + new Sha256(), + self::ISSUER, + self::ISSUED_AT, + self::$privateKey, + 'https://client.example.test' + ); + + $jwt = (new LcobucciTokenGenerator($config))->generate(); + + $parsed = $this->verificationConfiguration()->parser()->parse($jwt); + self::assertInstanceOf(UnencryptedToken::class, $parsed); + self::assertContains('https://client.example.test', $parsed->claims()->get('aud')); } public function testGenerateThrowsWhenPrivateKeyIsMissing(): void { - $request = (new ServerRequest())->withUri(new Uri('https://api.example.test/login')); - $config = new TokenConfiguration(self::$publicKey, 3600, new Sha256(), 1_700_000_000, null); + $config = new TokenConfiguration(self::$publicKey, self::TTL, new Sha256(), self::ISSUER, self::ISSUED_AT, null); $this->expectException(InvalidTokenException::class); - (new LcobucciTokenGenerator($request, $config))->generate(); + (new LcobucciTokenGenerator($config))->generate(); } private function verificationConfiguration(): Configuration diff --git a/tests/Http/Jwt/LcobucciTokenParserTest.php b/tests/Http/Jwt/LcobucciTokenParserTest.php index e0ec2976..17349afe 100644 --- a/tests/Http/Jwt/LcobucciTokenParserTest.php +++ b/tests/Http/Jwt/LcobucciTokenParserTest.php @@ -15,18 +15,19 @@ use Altair\Http\Jwt\LcobucciTokenParser; use Altair\Http\Support\TokenConfiguration; use DateTimeImmutable; -use Laminas\Diactoros\ServerRequest; -use Laminas\Diactoros\Uri; use Lcobucci\JWT\Configuration; use Lcobucci\JWT\Signer\Key\InMemory; use Lcobucci\JWT\Signer\Rsa\Sha256; +use Override; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; #[CoversClass(LcobucciTokenParser::class)] final class LcobucciTokenParserTest extends TestCase { - private const string ISSUER = 'https://api.example.test/login'; + private const string ISSUER = 'https://api.example.test'; + + private const string AUDIENCE = 'https://client.example.test'; private const int ISSUED_AT = 1_700_000_000; @@ -36,6 +37,7 @@ final class LcobucciTokenParserTest extends TestCase private static string $publicKey; + #[Override] public static function setUpBeforeClass(): void { if (!\extension_loaded('openssl')) { @@ -48,7 +50,7 @@ public static function setUpBeforeClass(): void public function testParseReturnsTokenWithClaimsForValidToken(): void { $jwt = $this->mintToken(self::ISSUER, self::ISSUED_AT + self::TTL, ['uid' => 7]); - $parser = $this->parser(self::ISSUER, FrozenClock::at(self::ISSUED_AT + 10)); + $parser = $this->parser(FrozenClock::at(self::ISSUED_AT + 10)); $token = $parser->parse($jwt); @@ -60,7 +62,7 @@ public function testParseRejectsMalformedToken(): void { $this->expectException(InvalidTokenException::class); - $this->parser(self::ISSUER, FrozenClock::at(self::ISSUED_AT))->parse('this-is-not-a-jwt'); + $this->parser(FrozenClock::at(self::ISSUED_AT))->parse('this-is-not-a-jwt'); } public function testParseRejectsTamperedSignature(): void @@ -70,13 +72,13 @@ public function testParseRejectsTamperedSignature(): void $this->expectException(InvalidTokenException::class); - $this->parser(self::ISSUER, FrozenClock::at(self::ISSUED_AT + 10))->parse($tampered); + $this->parser(FrozenClock::at(self::ISSUED_AT + 10))->parse($tampered); } public function testParseRejectsExpiredToken(): void { $jwt = $this->mintToken(self::ISSUER, self::ISSUED_AT + self::TTL, ['uid' => 7]); - $parser = $this->parser(self::ISSUER, FrozenClock::at(self::ISSUED_AT + self::TTL + 1)); + $parser = $this->parser(FrozenClock::at(self::ISSUED_AT + self::TTL + 1)); $this->expectException(InvalidTokenException::class); @@ -86,7 +88,7 @@ public function testParseRejectsExpiredToken(): void public function testParseRejectsWrongIssuer(): void { $jwt = $this->mintToken('https://evil.example.test', self::ISSUED_AT + self::TTL, ['uid' => 7]); - $parser = $this->parser(self::ISSUER, FrozenClock::at(self::ISSUED_AT + 10)); + $parser = $this->parser(FrozenClock::at(self::ISSUED_AT + 10)); $this->expectException(InvalidTokenException::class); @@ -110,13 +112,33 @@ public function testParseRejectsTokenSignedWithForeignKey(): void $this->expectException(InvalidTokenException::class); - $this->parser(self::ISSUER, FrozenClock::at(self::ISSUED_AT + 10))->parse($jwt); + $this->parser(FrozenClock::at(self::ISSUED_AT + 10))->parse($jwt); + } + + public function testParseAcceptsTokenWithMatchingAudience(): void + { + $jwt = $this->mintToken(self::ISSUER, self::ISSUED_AT + self::TTL, ['uid' => 7], self::AUDIENCE); + $parser = $this->parser(FrozenClock::at(self::ISSUED_AT + 10), self::AUDIENCE); + + $token = $parser->parse($jwt); + + self::assertSame(7, $token->getMetadata('uid')); + } + + public function testParseRejectsTokenWithMissingAudienceWhenAudienceRequired(): void + { + $jwt = $this->mintToken(self::ISSUER, self::ISSUED_AT + self::TTL, ['uid' => 7]); + $parser = $this->parser(FrozenClock::at(self::ISSUED_AT + 10), self::AUDIENCE); + + $this->expectException(InvalidTokenException::class); + + $parser->parse($jwt); } /** * @param array $claims */ - private function mintToken(string $issuer, int $expiresAt, array $claims): string + private function mintToken(string $issuer, int $expiresAt, array $claims, ?string $audience = null): string { $configuration = Configuration::forAsymmetricSigner( new Sha256(), @@ -129,6 +151,10 @@ private function mintToken(string $issuer, int $expiresAt, array $claims): strin ->issuedAt((new DateTimeImmutable())->setTimestamp(self::ISSUED_AT)) ->expiresAt((new DateTimeImmutable())->setTimestamp($expiresAt)); + if ($audience !== null) { + $builder = $builder->permittedFor($audience); + } + foreach ($claims as $name => $value) { $builder = $builder->withClaim($name, $value); } @@ -136,11 +162,18 @@ private function mintToken(string $issuer, int $expiresAt, array $claims): strin return $builder->getToken($configuration->signer(), $configuration->signingKey())->toString(); } - private function parser(string $requestUri, FrozenClock $clock): LcobucciTokenParser + private function parser(FrozenClock $clock, ?string $audience = null): LcobucciTokenParser { - $request = (new ServerRequest())->withUri(new Uri($requestUri)); - $config = new TokenConfiguration(self::$publicKey, self::TTL, new Sha256(), self::ISSUED_AT, self::$privateKey); + $config = new TokenConfiguration( + self::$publicKey, + self::TTL, + new Sha256(), + self::ISSUER, + self::ISSUED_AT, + self::$privateKey, + $audience + ); - return new LcobucciTokenParser($request, $config, $clock); + return new LcobucciTokenParser($config, $clock); } }