Http::response([ 'issuer' => 'https://idp.example.com', 'authorization_endpoint' => 'https://idp.example.com/auth', 'token_endpoint' => 'https://idp.example.com/token', 'userinfo_endpoint' => 'https://idp.example.com/userinfo', 'jwks_uri' => 'https://idp.example.com/jwks', ]), 'https://idp.example.com/jwks' => Http::response(['keys' => [['kid' => 'one']]]), ]); $service = app(OidcDiscoveryService::class); $discovery = $service->discover('https://idp.example.com'); $jwks = $service->jwks($discovery->jwksUri); expect($discovery->issuer)->toBe('https://idp.example.com') ->and($jwks['keys'][0]['kid'])->toBe('one'); Http::assertSentCount(2); $service->discover('https://idp.example.com'); $service->jwks('https://idp.example.com/jwks'); Http::assertSentCount(2); }); it('does not cache discovery documents with mismatched issuers', function () { Cache::flush(); Http::fakeSequence('https://idp.example.com/.well-known/openid-configuration') ->push([ 'issuer' => 'https://evil.example.com', 'authorization_endpoint' => 'https://idp.example.com/auth', 'token_endpoint' => 'https://idp.example.com/token', 'userinfo_endpoint' => 'https://idp.example.com/userinfo', 'jwks_uri' => 'https://idp.example.com/jwks', ]) ->push([ 'issuer' => 'https://idp.example.com', 'authorization_endpoint' => 'https://idp.example.com/auth', 'token_endpoint' => 'https://idp.example.com/token', 'userinfo_endpoint' => 'https://idp.example.com/userinfo', 'jwks_uri' => 'https://idp.example.com/jwks', ]); $service = app(OidcDiscoveryService::class); $cacheKey = 'oidc:discovery:'.hash('sha256', 'https://idp.example.com'); expect(fn () => $service->discover('https://idp.example.com')) ->toThrow(OidcDiscoveryException::class, 'Discovery issuer does not match the configured issuer URL.') ->and(Cache::has($cacheKey))->toBeFalse() ->and($service->discover('https://idp.example.com')->issuer)->toBe('https://idp.example.com'); Http::assertSentCount(2); }); it('refetches jwks once on forced refresh to pick up rotated keys', function () { Cache::flush(); Http::fakeSequence('https://idp.example.com/jwks') ->push(['keys' => [['kid' => 'old']]]) ->push(['keys' => [['kid' => 'new']]]); $service = app(OidcDiscoveryService::class); expect($service->jwks('https://idp.example.com/jwks')['keys'][0]['kid'])->toBe('old'); // Forced refresh bypasses the cache and sees the rotated key. expect($service->jwks('https://idp.example.com/jwks', true)['keys'][0]['kid'])->toBe('new'); Http::assertSentCount(2); // Cooldown prevents a second immediate upstream fetch; cached value returned. expect($service->jwks('https://idp.example.com/jwks', true)['keys'][0]['kid'])->toBe('new'); Http::assertSentCount(2); }); it('rejects invalid discovery and jwks payloads', function () { Cache::flush(); Http::fake([ 'https://bad.example.com/.well-known/openid-configuration' => Http::response(['issuer' => 'https://bad.example.com']), ]); app(OidcDiscoveryService::class)->discover('https://bad.example.com'); })->throws(OidcDiscoveryException::class); it('rejects jwks responses without keys', function () { Cache::flush(); Http::fake([ 'https://idp.example.com/jwks' => Http::response(['empty' => true]), ]); app(OidcDiscoveryService::class)->jwks('https://idp.example.com/jwks'); })->throws(OidcJwksException::class); it('rejects non-https issuer urls', function () { Cache::flush(); Http::fake(); app(OidcDiscoveryService::class)->discover('http://idp.example.com'); })->throws(OidcDiscoveryException::class, 'Issuer URL must be an absolute HTTPS URL.'); it('rejects non-https jwks uris', function () { Cache::flush(); Http::fake(); app(OidcDiscoveryService::class)->jwks('http://idp.example.com/jwks'); })->throws(OidcJwksException::class, 'JWKS URI must be an absolute HTTPS URL.');