Skip to content

Commit 6438d0a

Browse files
authored
Merge pull request #51 from itk-dev/test/mutation-cache-behavior
test: assert discovery document and JWKS caching behavior
2 parents d5ebdd0 + f43ab39 commit 6438d0a

2 files changed

Lines changed: 120 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515

1616
### Changed
1717

18+
- Strengthened cache assertions guided by mutation testing: the discovery
19+
document and JWKS key map are asserted to be written to the cache with
20+
the configured duration under the namespaced key, and a multi-key JWKS
21+
is asserted to reach `JWT::decode` in full
1822
- Strengthened exception assertions guided by mutation testing: thrown
1923
messages are asserted in full (including dynamic parts), wrap-boundary
2024
exceptions assert code `0` and the chained `$previous` cause, and

tests/Security/OpenIdConfigurationProviderTest.php

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1041,6 +1041,122 @@ public function testGetJwtVerificationKeysCacheHit(): void
10411041
$this->assertEquals(self::NONCE, $claims->nonce);
10421042
}
10431043

1044+
public function testGetConfigurationCachesFetchedDocument(): void
1045+
{
1046+
$openIDConnectMetadataUrl = 'https://some.url/openid-configuration';
1047+
$configuration = $this->loadMockFixture('mockOpenIDConfiguration.json');
1048+
1049+
$mockConfigResponse = $this->getMockHttpSuccessResponse('/../MockData/mockOpenIDConfiguration.json');
1050+
$mockHttpClient = $this->createStub(ClientInterface::class);
1051+
$mockHttpClient->method('request')->willReturn($mockConfigResponse);
1052+
1053+
// On a cache miss the fetched discovery document must be stored with
1054+
// the configured cache duration, under the namespaced cache key.
1055+
$configCacheItem = $this->createMock(CacheItemInterface::class);
1056+
$configCacheItem->method('isHit')->willReturn(false);
1057+
$configCacheItem->expects($this->once())->method('set')->with($configuration)->willReturnSelf();
1058+
$configCacheItem->expects($this->once())->method('expiresAfter')->with(3600)->willReturnSelf();
1059+
1060+
$expectedCacheKey = 'itk-openid-connect-configuration-||'.hash('sha1', $openIDConnectMetadataUrl).'||configuration';
1061+
1062+
$mockCacheItemPool = $this->createMock(CacheItemPoolInterface::class);
1063+
$mockCacheItemPool->expects($this->once())->method('getItem')->with($expectedCacheKey)->willReturn($configCacheItem);
1064+
$mockCacheItemPool->expects($this->once())->method('save')->with($configCacheItem)->willReturn(true);
1065+
1066+
$provider = new OpenIdConfigurationProvider([
1067+
'openIDConnectMetadataUrl' => $openIDConnectMetadataUrl,
1068+
'cacheItemPool' => $mockCacheItemPool,
1069+
'clientId' => self::CLIENT_ID,
1070+
'clientSecret' => self::CLIENT_SECRET,
1071+
'redirectUri' => self::REDIRECT_URI,
1072+
'cacheDuration' => 3600,
1073+
], [
1074+
'httpClient' => $mockHttpClient,
1075+
]);
1076+
1077+
$authUrl = $provider->getBaseAuthorizationUrl();
1078+
$this->assertSame('https://azure_b2c_test.b2clogin.com/azure_b2c_test.onmicrosoft.com/oauth2/v2.0/authorize?p=test-policy', $authUrl);
1079+
}
1080+
1081+
public function testGetJwtVerificationKeysCachesFetchedKeys(): void
1082+
{
1083+
$openIDConnectMetadataUrl = 'https://some.url/openid-configuration';
1084+
$configuration = $this->loadMockFixture('mockOpenIDConfiguration.json');
1085+
1086+
$mockKeysResponse = $this->getMockHttpSuccessResponse('/../MockData/mockOpenIDValidationKeys.json');
1087+
$mockHttpClient = $this->createStub(ClientInterface::class);
1088+
$mockHttpClient->method('request')->willReturn($mockKeysResponse);
1089+
1090+
$configCacheItem = $this->createStub(CacheItemInterface::class);
1091+
$configCacheItem->method('isHit')->willReturn(true);
1092+
$configCacheItem->method('get')->willReturn($configuration);
1093+
1094+
// On a JWKS cache miss the built Key map must be stored with the
1095+
// configured cache duration and saved to the pool.
1096+
$jwksCacheItem = $this->createMock(CacheItemInterface::class);
1097+
$jwksCacheItem->method('isHit')->willReturn(false);
1098+
$jwksCacheItem->expects($this->once())->method('set')->with($this->callback(
1099+
static fn (array $keys): bool => 1 === count($keys)
1100+
&& $keys['111111111111111111111111111111111111111111'] instanceof Key
1101+
))->willReturnSelf();
1102+
$jwksCacheItem->expects($this->once())->method('expiresAfter')->with(3600)->willReturnSelf();
1103+
1104+
$mockCacheItemPool = $this->createMock(CacheItemPoolInterface::class);
1105+
$mockCacheItemPool->method('getItem')->willReturnCallback(
1106+
static fn (string $key) => str_contains($key, 'jwks') ? $jwksCacheItem : $configCacheItem
1107+
);
1108+
$mockCacheItemPool->expects($this->once())->method('save')->with($jwksCacheItem)->willReturn(true);
1109+
1110+
$provider = new OpenIdConfigurationProvider([
1111+
'openIDConnectMetadataUrl' => $openIDConnectMetadataUrl,
1112+
'cacheItemPool' => $mockCacheItemPool,
1113+
'clientId' => self::CLIENT_ID,
1114+
'clientSecret' => self::CLIENT_SECRET,
1115+
'redirectUri' => self::REDIRECT_URI,
1116+
'cacheDuration' => 3600,
1117+
], [
1118+
'httpClient' => $mockHttpClient,
1119+
]);
1120+
1121+
/** @var \Mockery\MockInterface $mockJWT */
1122+
$mockJWT = \Mockery::mock('overload:Firebase\JWT\JWT', MockJWT::class);
1123+
$mockJWT->shouldReceive('decode')->andReturn($this->getMockClaims());
1124+
1125+
/** @var object{nonce: string} $claims */
1126+
$claims = $provider->validateIdToken('token', self::NONCE);
1127+
$this->assertEquals(self::NONCE, $claims->nonce);
1128+
}
1129+
1130+
public function testGetJwtVerificationKeysBuildsAllJwksKeys(): void
1131+
{
1132+
// Two RSA keys in the JWKS: the full key map (not just the first
1133+
// entry) must reach JWT::decode, since the token's "kid" may match
1134+
// any key published by the IdP.
1135+
$fixtureKeys = $this->loadMockFixture('mockOpenIDValidationKeys.json');
1136+
$this->assertIsArray($fixtureKeys['keys']);
1137+
$this->assertIsArray($fixtureKeys['keys'][0]);
1138+
$template = $fixtureKeys['keys'][0];
1139+
1140+
$jwks = ['keys' => [
1141+
['kid' => 'key-a'] + $template,
1142+
['kid' => 'key-b'] + $template,
1143+
]];
1144+
$provider = $this->createProviderWithCustomJwks((string) json_encode($jwks));
1145+
1146+
/** @var \Mockery\MockInterface $mockJWT */
1147+
$mockJWT = \Mockery::mock('overload:Firebase\JWT\JWT', MockJWT::class);
1148+
$mockJWT->shouldReceive('decode')->with(
1149+
\Mockery::type('string'),
1150+
\Mockery::on(static fn (array $keys): bool => 2 === count($keys)
1151+
&& $keys['key-a'] instanceof Key
1152+
&& $keys['key-b'] instanceof Key)
1153+
)->andReturn($this->getMockClaims());
1154+
1155+
/** @var object{nonce: string} $claims */
1156+
$claims = $provider->validateIdToken('token', self::NONCE);
1157+
$this->assertEquals(self::NONCE, $claims->nonce);
1158+
}
1159+
10441160
public function testGetConfigurationCacheInvalidArgument(): void
10451161
{
10461162
$openIDConnectMetadataUrl = 'https://some.url/openid-configuration';

0 commit comments

Comments
 (0)