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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
(`task test:mutation`), run in CI and reported to the Stryker dashboard
(mutation score badge in README)

### Changed

- Strengthened exception assertions guided by mutation testing: thrown
messages are asserted in full (including dynamic parts), wrap-boundary
exceptions assert code `0` and the chained `$previous` cause, and
invalid JSON from the token endpoint is covered

## [5.0.0] - 2026-06-02

Reworked exception hierarchy and tightened IdP-payload validations. The runtime
Expand Down
154 changes: 125 additions & 29 deletions tests/Security/OpenIdConfigurationProviderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -254,10 +254,16 @@ public function testValidateIdTokenFailure(): void
$mockJWT = \Mockery::mock('overload:Firebase\JWT\JWT', MockJWT::class);
$mockJWT->shouldReceive('decode')->andThrow(SignatureInvalidException::class, 'Signature verification failed');

$this->expectException(ValidationException::class);
$this->expectExceptionMessage('ID token validation failed');

$this->provider->validateIdToken('token', self::NONCE);
try {
$this->provider->validateIdToken('token', self::NONCE);
} catch (ValidationException $thrown) {
$this->assertSame('ID token validation failed: Signature verification failed', $thrown->getMessage());
$this->assertSame(0, $thrown->getCode());
$this->assertInstanceOf(SignatureInvalidException::class, $thrown->getPrevious(), 'Original cause must be chained');

return;
}
$this->fail('Expected ValidationException');
}

public function testValidateIdTokenAudience(): void
Expand All @@ -270,7 +276,7 @@ public function testValidateIdTokenAudience(): void
$mockJWT->shouldReceive('decode')->andReturn($mockClaims);

$this->expectException(ClaimsException::class);
$this->expectExceptionMessage('ID token has incorrect audience');
$this->expectExceptionMessage('ID token has incorrect audience(s): incorrect aud');

$this->provider->validateIdToken('token', self::NONCE);
}
Expand All @@ -285,7 +291,7 @@ public function testValidateIdTokenIssuer(): void
$mockJWT->shouldReceive('decode')->andReturn($mockClaims);

$this->expectException(ClaimsException::class);
$this->expectExceptionMessage('ID token has incorrect issuer');
$this->expectExceptionMessage('ID token has incorrect issuer: incorrect iss');

$this->provider->validateIdToken('token', self::NONCE);
}
Expand All @@ -300,7 +306,7 @@ public function testValidateIdTokenNonce(): void
$mockJWT->shouldReceive('decode')->andReturn($mockClaims);

$this->expectException(ClaimsException::class);
$this->expectExceptionMessage('ID token has incorrect nonce');
$this->expectExceptionMessage('ID token has incorrect nonce: incorrect nonce');

$this->provider->validateIdToken('token', self::NONCE);
}
Expand All @@ -310,7 +316,7 @@ public function testConstructBadUrl(): void
$mockCacheItemPool = $this->createStub(CacheItemPoolInterface::class);

$this->expectException(BadUrlException::class);
$this->expectExceptionMessage('OpenIDConnectMetadataUrl is invalid');
$this->expectExceptionMessage('OpenIDConnectMetadataUrl is invalid: not-a-valid-url');

new OpenIdConfigurationProvider([
'cacheItemPool' => $mockCacheItemPool,
Expand All @@ -323,7 +329,7 @@ public function testConstructHttpUrlNotAllowed(): void
$mockCacheItemPool = $this->createStub(CacheItemPoolInterface::class);

$this->expectException(IllegalSchemeException::class);
$this->expectExceptionMessage('OpenIDConnectMetadataUrl must use https');
$this->expectExceptionMessage('OpenIDConnectMetadataUrl must use https: http://some.url/openid-configuration');

new OpenIdConfigurationProvider([
'cacheItemPool' => $mockCacheItemPool,
Expand Down Expand Up @@ -421,10 +427,15 @@ public function testCheckResponseWithErrorStatusCode(): void

$method = new \ReflectionMethod(OpenIdConfigurationProvider::class, 'checkResponse');

$this->expectException(IdentityProviderException::class);
$this->expectExceptionMessage('400');
try {
$method->invoke($this->provider, $response, []);
} catch (IdentityProviderException $thrown) {
$this->assertSame('400', $thrown->getMessage());
$this->assertSame(0, $thrown->getCode());

$method->invoke($this->provider, $response, []);
return;
}
$this->fail('Expected IdentityProviderException');
}

public function testCreateResourceOwner(): void
Expand Down Expand Up @@ -464,7 +475,7 @@ public function testValidateIdTokenArrayAudienceInvalid(): void
$mockJWT->shouldReceive('decode')->andReturn($mockClaims);

$this->expectException(ClaimsException::class);
$this->expectExceptionMessage('ID token has incorrect audience');
$this->expectExceptionMessage('ID token has incorrect audience(s): wrong_client_1, wrong_client_2');

$this->provider->validateIdToken('token', self::NONCE);
}
Expand Down Expand Up @@ -547,10 +558,71 @@ public function testGetIdTokenFailure(): void
'httpClient' => $mockHttpClient,
]);

$this->expectException(CodeException::class);
$this->expectExceptionMessage('Get ID token failed');
try {
$provider->getIdToken('test-code');
} catch (CodeException $thrown) {
$this->assertSame('Get ID token failed: Connection failed', $thrown->getMessage());
$this->assertSame(0, $thrown->getCode());
$this->assertInstanceOf(ClientExceptionInterface::class, $thrown->getPrevious(), 'Original cause must be chained');

$provider->getIdToken('test-code');
return;
}
$this->fail('Expected CodeException');
}

public function testGetIdTokenRejectsInvalidJsonResponse(): void
{
$tokenEndpoint = 'https://azure_b2c_test.b2clogin.com/azure_b2c_test.onmicrosoft.com/oauth2/v2.0/token?p=test-policy';
$openIDConnectMetadataUrl = 'https://some.url/openid-configuration';

$mockConfigResponse = $this->getMockHttpSuccessResponse('/../MockData/mockOpenIDConfiguration.json');

$malformedTokenResponseBody = 'not valid json{{{';
$mockTokenStream = $this->createStub(StreamInterface::class);
$mockTokenStream->method('getContents')->willReturn($malformedTokenResponseBody);
$mockTokenStream->method('__toString')->willReturn($malformedTokenResponseBody);

$mockTokenResponse = $this->createStub(ResponseInterface::class);
$mockTokenResponse->method('getStatusCode')->willReturn(200);
$mockTokenResponse->method('getBody')->willReturn($mockTokenStream);

$mockHttpClient = $this->createStub(ClientInterface::class);
$mockHttpClient->method('request')->willReturnMap([
['GET', $openIDConnectMetadataUrl, [], $mockConfigResponse],
['POST', $tokenEndpoint, ['form_params' => [
'client_id' => self::CLIENT_ID,
'client_secret' => self::CLIENT_SECRET,
'redirect_uri' => self::REDIRECT_URI,
'grant_type' => 'authorization_code',
'code' => 'test-code',
]], $mockTokenResponse],
]);

$mockCacheItem = $this->createStub(CacheItemInterface::class);
$mockCacheItem->method('isHit')->willReturn(false);

$mockCacheItemPool = $this->createStub(CacheItemPoolInterface::class);
$mockCacheItemPool->method('getItem')->willReturn($mockCacheItem);

$provider = new OpenIdConfigurationProvider([
'openIDConnectMetadataUrl' => $openIDConnectMetadataUrl,
'cacheItemPool' => $mockCacheItemPool,
'clientId' => self::CLIENT_ID,
'clientSecret' => self::CLIENT_SECRET,
'redirectUri' => self::REDIRECT_URI,
], [
'httpClient' => $mockHttpClient,
]);

try {
$provider->getIdToken('test-code');
} catch (CodeException $thrown) {
$this->assertSame(0, $thrown->getCode());
$this->assertInstanceOf(\JsonException::class, $thrown->getPrevious(), 'Original cause must be chained');

return;
}
$this->fail('Expected CodeException');
}

public function testGetIdTokenRejectsResponseWithoutStringIdToken(): void
Expand Down Expand Up @@ -698,7 +770,7 @@ public function testFetchJsonResourceNon200(): void
]);

$this->expectException(HttpException::class);
$this->expectExceptionMessage('Cannot access json resource');
$this->expectExceptionMessage('Cannot access json resource: https://some.url/openid-configuration');

$provider->getBaseAuthorizationUrl();
}
Expand Down Expand Up @@ -728,10 +800,16 @@ public function testFetchJsonResourceClientException(): void
'httpClient' => $mockHttpClient,
]);

$this->expectException(HttpException::class);
$this->expectExceptionMessage('Connection refused');
try {
$provider->getBaseAuthorizationUrl();
} catch (HttpException $thrown) {
$this->assertSame('Connection refused', $thrown->getMessage());
$this->assertSame(0, $thrown->getCode());
$this->assertSame($exception, $thrown->getPrevious(), 'Original cause must be chained');

$provider->getBaseAuthorizationUrl();
return;
}
$this->fail('Expected HttpException');
}

public function testFetchJsonResourceInvalidJson(): void
Expand Down Expand Up @@ -764,9 +842,15 @@ public function testFetchJsonResourceInvalidJson(): void
'httpClient' => $mockHttpClient,
]);

$this->expectException(\ItkDev\OpenIdConnect\Exception\JsonException::class);
try {
$provider->getBaseAuthorizationUrl();
} catch (\ItkDev\OpenIdConnect\Exception\JsonException $thrown) {
$this->assertSame(0, $thrown->getCode());
$this->assertInstanceOf(\JsonException::class, $thrown->getPrevious(), 'Original cause must be chained');

$provider->getBaseAuthorizationUrl();
return;
}
$this->fail('Expected JsonException');
}

public function testGetJwtVerificationKeysRejectsJwksMissingKeysArray(): void
Expand Down Expand Up @@ -978,10 +1062,16 @@ public function testGetConfigurationCacheInvalidArgument(): void
'httpClient' => $mockHttpClient,
]);

$this->expectException(CacheException::class);
$this->expectExceptionMessage('Invalid cache key');
try {
$provider->getBaseAuthorizationUrl();
} catch (CacheException $thrown) {
$this->assertSame('Invalid cache key', $thrown->getMessage());
$this->assertSame(0, $thrown->getCode());
$this->assertSame($exception, $thrown->getPrevious(), 'Original cause must be chained');

$provider->getBaseAuthorizationUrl();
return;
}
$this->fail('Expected CacheException');
}

public function testGetJwtVerificationKeysCacheInvalidArgument(): void
Expand Down Expand Up @@ -1017,10 +1107,16 @@ public function testGetJwtVerificationKeysCacheInvalidArgument(): void
'httpClient' => $mockHttpClient,
]);

$this->expectException(CacheException::class);
$this->expectExceptionMessage('Invalid jwks cache key');
try {
$provider->validateIdToken('token', self::NONCE);
} catch (CacheException $thrown) {
$this->assertSame('Invalid jwks cache key', $thrown->getMessage());
$this->assertSame(0, $thrown->getCode());
$this->assertSame($exception, $thrown->getPrevious(), 'Original cause must be chained');

$provider->validateIdToken('token', self::NONCE);
return;
}
$this->fail('Expected CacheException');
}

public function testBase64urlDecodeFailure(): void
Expand Down Expand Up @@ -1065,7 +1161,7 @@ public function testBase64urlDecodeFailure(): void
$mockJWT = \Mockery::mock('overload:Firebase\JWT\JWT', MockJWT::class);

$this->expectException(\ItkDev\OpenIdConnect\Exception\DecodeException::class);
$this->expectExceptionMessage('Error url decoding input');
$this->expectExceptionMessage('Error url decoding input !!!');

$provider->validateIdToken('token', self::NONCE);
}
Expand Down
Loading