Skip to content
Merged
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
25 changes: 17 additions & 8 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
after the document type rather than the individual key is more
accurate. Consumers catching the marker are unaffected; consumers
catching the concrete class need to swap the name.
- `OpenIdConfigurationProvider::getJwtVerificationKeys` declares its
return type as `array<string, Key>` (was just `array`), matching
the actual shape the method builds. Lets `validateIdToken` pass
the cached keys to `JWT::decode` without a `mixed` flow at
`level: max`.
- `OpenIdConfigurationProvider::validateIdToken` narrows its
`$claims` local via inline `@var \stdClass&object{aud, iss,
nonce}` so the spec-required claim accesses
(`$claims->aud` / `$claims->iss` / `$claims->nonce`) type-check at
`level: max`. No runtime change — these values are guaranteed
present and string-typed by the OIDC spec and `firebase/php-jwt`
already enforces JWT validity.

### Documentation

- Added a new "Exception handling" section to `README.md` describing the
marker interface, the SPL parents of each concrete, the PSR-18
co-implementation on `HttpException`, and the 4.x → 5.0 catch-block
migration. Also fixed the `validateIdToken` example to catch the
marker interface instead of the now-deprecated abstract.
- Added class-level PHPDoc to every concrete exception in
`src/Exception/` describing what it represents, when it's thrown,
the rationale for its SPL parent type, and the boundary against
Expand Down Expand Up @@ -123,14 +140,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
class (catch `OpenIdConnectExceptionInterface` instead). Kept through
5.x; removal scheduled for 6.0.

### Documentation

- Added a new "Exception handling" section to `README.md` describing the
marker interface, the SPL parents of each concrete, the PSR-18
co-implementation on `HttpException`, and the 4.x → 5.0 catch-block
migration. Also fixed the `validateIdToken` example to catch the
marker interface instead of the now-deprecated abstract.

### Tooling

- PHPStan now scans `tests/` in addition to `src/` at level 8, with
Expand Down
6 changes: 4 additions & 2 deletions src/Security/OpenIdConfigurationProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ public function validateIdToken(string $idToken, string $nonce): object
// NB: JWT::$leeway is a static property shared across all instances.
// Always set it immediately before decode to ensure the correct value.
JWT::$leeway = $this->leeway;
/** @var \stdClass&object{aud: string|array<string>, iss: string, nonce: string} $claims */
$claims = JWT::decode($idToken, $keys);
// "aud" may be an array of strings or a single string
// (cf. https://openid.net/specs/openid-connect-core-1_0.html#IDToken).
Expand Down Expand Up @@ -356,8 +357,8 @@ protected function createResourceOwner(array $response, AccessToken $token): Res
/**
* Get JWT verification keys from Azure Active Directory.
*
* @return array
* Array of keys
* @return array<string, Key>
* Array of keys indexed by JWK `kid`
*
* @throws OpenIdConnectExceptionInterface
*/
Expand All @@ -372,6 +373,7 @@ private function getJwtVerificationKeys(): array
$item = $this->cacheItemPool->getItem($cacheKey);

if ($item->isHit()) {
/** @var array<string, Key> $keys (we only ever store this shape) */
$keys = (array) $item->get();
} else {
$keysUri = $this->getConfiguration('jwks_uri');
Expand Down
14 changes: 13 additions & 1 deletion tests/Security/OpenIdConfigurationProviderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ public function testGetBaseAccessTokenUrl(): void

public function testValidateIdTokenSuccess(): void
{
/** @var \Mockery\MockInterface $mockJWT */
$mockJWT = \Mockery::mock('overload:Firebase\JWT\JWT', MockJWT::class);
$mockClaims = $this->getMockClaims();

Expand All @@ -249,6 +250,7 @@ public function testValidateIdTokenSuccess(): void

public function testValidateIdTokenFailure(): void
{
/** @var \Mockery\MockInterface $mockJWT */
$mockJWT = \Mockery::mock('overload:Firebase\JWT\JWT', MockJWT::class);
$mockJWT->shouldReceive('decode')->andThrow(SignatureInvalidException::class, 'Signature verification failed');

Expand All @@ -260,6 +262,7 @@ public function testValidateIdTokenFailure(): void

public function testValidateIdTokenAudience(): void
{
/** @var \Mockery\MockInterface $mockJWT */
$mockJWT = \Mockery::mock('overload:Firebase\JWT\JWT', MockJWT::class);
$mockClaims = $this->getMockClaims();
$mockClaims->aud = 'incorrect aud';
Expand All @@ -274,6 +277,7 @@ public function testValidateIdTokenAudience(): void

public function testValidateIdTokenIssuer(): void
{
/** @var \Mockery\MockInterface $mockJWT */
$mockJWT = \Mockery::mock('overload:Firebase\JWT\JWT', MockJWT::class);
$mockClaims = $this->getMockClaims();
$mockClaims->iss = 'incorrect iss';
Expand All @@ -288,6 +292,7 @@ public function testValidateIdTokenIssuer(): void

public function testValidateIdTokenNonce(): void
{
/** @var \Mockery\MockInterface $mockJWT */
$mockJWT = \Mockery::mock('overload:Firebase\JWT\JWT', MockJWT::class);
$mockClaims = $this->getMockClaims();
$mockClaims->nonce = 'incorrect nonce';
Expand Down Expand Up @@ -429,11 +434,13 @@ public function testCreateResourceOwner(): void
$method = new \ReflectionMethod(OpenIdConfigurationProvider::class, 'createResourceOwner');

$owner = $method->invoke($this->provider, ['id' => '123', 'name' => 'Test'], $token);
$this->assertInstanceOf(\League\OAuth2\Client\Provider\ResourceOwnerInterface::class, $owner);
$this->assertSame('123', $owner->getId());
}

public function testValidateIdTokenArrayAudience(): void
{
/** @var \Mockery\MockInterface $mockJWT */
$mockJWT = \Mockery::mock('overload:Firebase\JWT\JWT', MockJWT::class);
$mockClaims = $this->getMockClaims();
$mockClaims->aud = [self::CLIENT_ID, 'other_client'];
Expand All @@ -449,6 +456,7 @@ public function testValidateIdTokenArrayAudience(): void

public function testValidateIdTokenArrayAudienceInvalid(): void
{
/** @var \Mockery\MockInterface $mockJWT */
$mockJWT = \Mockery::mock('overload:Firebase\JWT\JWT', MockJWT::class);
$mockClaims = $this->getMockClaims();
$mockClaims->aud = ['wrong_client_1', 'wrong_client_2'];
Expand Down Expand Up @@ -847,6 +855,7 @@ public function testGetJwtVerificationKeysRejectsNonStringKid(): void
'httpClient' => $mockHttpClient,
]);

/** @var \Mockery\MockInterface $mockJWT */
$mockJWT = \Mockery::mock('overload:Firebase\JWT\JWT', MockJWT::class);

$this->expectException(JwksException::class);
Expand Down Expand Up @@ -892,6 +901,7 @@ public function testGetJwtVerificationKeysUnsupportedKeyType(): void
'httpClient' => $mockHttpClient,
]);

/** @var \Mockery\MockInterface $mockJWT */
$mockJWT = \Mockery::mock('overload:Firebase\JWT\JWT', MockJWT::class);

$this->expectException(JwksException::class);
Expand Down Expand Up @@ -937,6 +947,7 @@ public function testGetJwtVerificationKeysCacheHit(): void
'httpClient' => $mockHttpClient,
]);

/** @var \Mockery\MockInterface $mockJWT */
$mockJWT = \Mockery::mock('overload:Firebase\JWT\JWT', MockJWT::class);
$mockClaims = $this->getMockClaims();
$mockJWT->shouldReceive('decode')->andReturn($mockClaims);
Expand Down Expand Up @@ -1050,6 +1061,7 @@ public function testBase64urlDecodeFailure(): void
'httpClient' => $mockHttpClient,
]);

/** @var \Mockery\MockInterface $mockJWT */
$mockJWT = \Mockery::mock('overload:Firebase\JWT\JWT', MockJWT::class);

$this->expectException(\ItkDev\OpenIdConnect\Exception\DecodeException::class);
Expand All @@ -1070,7 +1082,7 @@ public function testBase64urlDecodeFailure(): void
* unreadable / not valid JSON, rather than letting `false` or `null` flow
* silently into the assertion under test.
*
* @return array<string, mixed>
* @return array<mixed> top-level decoded JSON; callers cast / narrow as needed
*/
private function loadMockFixture(string $filename): array
{
Expand Down
Loading