From 0f646c68b4e07ccfc68a313f3df5b5851beb97f9 Mon Sep 17 00:00:00 2001 From: romanetar Date: Tue, 20 Oct 2026 17:37:42 +0200 Subject: [PATCH 1/2] fix(oauth2): cache the api endpoint lookup done by the bearer token validator Cache the active flag and active scope names per (route, method) for cache_api_response.api_endpoint_lifetime seconds (0 disables it), with a negative entry for unknown endpoints. ApiEndpointsSeeder flushes the cached lookups. --- .env.example | 1 + ...Auth2BearerAccessTokenRequestValidator.php | 55 +++++++- config/cache_api_response.php | 2 + .../config/APIEndpointsMigrationHelper.php | 5 + database/seeders/ApiEndpointsSeeder.php | 3 + tests/OAuth2BearerEndpointCacheTest.php | 126 ++++++++++++++++++ 6 files changed, 189 insertions(+), 3 deletions(-) create mode 100644 tests/OAuth2BearerEndpointCacheTest.php diff --git a/.env.example b/.env.example index af492e5760..7d20152d79 100644 --- a/.env.example +++ b/.env.example @@ -54,6 +54,7 @@ SSL_ENABLED=true DB_LOG_ENABLED=true ACCESS_TOKEN_CACHE_LIFETIME=300 API_RESPONSE_CACHE_LIFETIME=600 +CACHE_API_RESPONSE_API_ENDPOINT_LIFETIME=3600 LOG_EMAIL_TO=smarcet@gmail.com LOG_EMAIL_FROM=smarcet@gmail.com diff --git a/app/Http/Middleware/OAuth2BearerAccessTokenRequestValidator.php b/app/Http/Middleware/OAuth2BearerAccessTokenRequestValidator.php index 502bb64ebf..5e6c891b37 100644 --- a/app/Http/Middleware/OAuth2BearerAccessTokenRequestValidator.php +++ b/app/Http/Middleware/OAuth2BearerAccessTokenRequestValidator.php @@ -14,6 +14,8 @@ use App\Models\ResourceServer\IAccessTokenService; use App\Models\ResourceServer\IApiEndpointRepository; use Closure; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Request; use Illuminate\Support\Facades\Response; @@ -33,6 +35,7 @@ */ class OAuth2BearerAccessTokenRequestValidator { + const EndpointCacheVersionKey = 'api_endpoint_cache_version'; /** * @var IResourceServerContext @@ -70,6 +73,52 @@ public function __construct( $this->token_service = $token_service; } + /** + * Invalidates every cached endpoint lookup (call it after endpoints or scopes change). + */ + public static function flushEndpointCache(): void + { + Cache::forever(self::EndpointCacheVersionKey, uniqid()); + } + + /** + * Only what the validator needs from the endpoint, cached per (route, method). + * Unknown endpoints are cached as missing. + * + * @param string $route + * @param string $method + * @return array{exists: bool, active: bool, scopes: string[]} + */ + private function getEndpointInfo(string $route, string $method): array + { + $loader = function () use ($route, $method): array { + $endpoint = $this->endpoint_repository->getApiEndpointByUrlAndMethod($route, $method); + if (is_null($endpoint)) { + return ['exists' => false, 'active' => false, 'scopes' => []]; + } + return [ + 'exists' => true, + 'active' => $endpoint->isActive(), + 'scopes' => $endpoint->getScopesNames(), + ]; + }; + + $lifetime = intval(Config::get('cache_api_response.api_endpoint_lifetime', 3600)); + if ($lifetime <= 0) { + return $loader(); + } + + $key = sprintf + ( + 'api_endpoint_%s_%s_%s', + Cache::get(self::EndpointCacheVersionKey, '0'), + $route, + $method + ); + + return Cache::remember($key, $lifetime, $loader); + } + /** * @param \Illuminate\Http\Request $request * @param Closure $next @@ -131,10 +180,10 @@ public function handle($request, Closure $next) ); } - $endpoint = $this->endpoint_repository->getApiEndpointByUrlAndMethod($route, $method); + $endpoint = $this->getEndpointInfo($route, $method); //api endpoint must be registered on db and active - if (is_null($endpoint) || !$endpoint->isActive()) { + if (!$endpoint['exists'] || !$endpoint['active']) { Log::warning ( sprintf('OAuth2BearerAccessTokenRequestValidator::handle API endpoint does not exits! (%s:%s)', $route, $method) @@ -184,7 +233,7 @@ public function handle($request, Closure $next) } //check scopes Log::debug('OAuth2BearerAccessTokenRequestValidator::handle checking token scopes ...'); - $endpoint_scopes = $endpoint->getScopesNames(); + $endpoint_scopes = $endpoint['scopes']; Log::debug(sprintf("OAuth2BearerAccessTokenRequestValidator::handle endpoint scopes %s", implode(' ',$endpoint_scopes))); Log::debug(sprintf("OAuth2BearerAccessTokenRequestValidator::handle token scopes %s", $token_info->getScope())); $token_scopes = explode(' ', $token_info->getScope()); diff --git a/config/cache_api_response.php b/config/cache_api_response.php index cb728400e5..72aee5449d 100644 --- a/config/cache_api_response.php +++ b/config/cache_api_response.php @@ -20,4 +20,6 @@ 'get_summits_response_lifetime' => env('CACHE_API_RESPONSE_GET_SUMMITS_LIFETIME', 600), 'file_url_lifetime' => env('CACHE_API_RESPONSE_FILE_URL_LIFETIME', 3600), 'event_has_access_lifetime' => env('CACHE_API_RESPONSE_EVENT_HAS_ACCESS_LIFETIME', 600), + // 0 disables the cache of the endpoint lookup done by the bearer token validator + 'api_endpoint_lifetime' => env('CACHE_API_RESPONSE_API_ENDPOINT_LIFETIME', 3600), ]; \ No newline at end of file diff --git a/database/migrations/config/APIEndpointsMigrationHelper.php b/database/migrations/config/APIEndpointsMigrationHelper.php index 32ad97dd69..7a099fe451 100644 --- a/database/migrations/config/APIEndpointsMigrationHelper.php +++ b/database/migrations/config/APIEndpointsMigrationHelper.php @@ -30,6 +30,11 @@ * $this->addSql($this->insertEndpointAuthzGroup('summits', $endpointName, $groupSlug)); * } * } + * + * Note: the bearer token validator caches the endpoint lookup (cache_api_response.api_endpoint_lifetime). + * A deploy running these migrations must flush it (php artisan cache:clear, or + * OAuth2BearerAccessTokenRequestValidator::flushEndpointCache()), otherwise new routes + * answer 400 until the lifetime expires. */ trait APIEndpointsMigrationHelper { diff --git a/database/seeders/ApiEndpointsSeeder.php b/database/seeders/ApiEndpointsSeeder.php index af083c4c3a..91de62e74e 100644 --- a/database/seeders/ApiEndpointsSeeder.php +++ b/database/seeders/ApiEndpointsSeeder.php @@ -22,6 +22,7 @@ use App\Security\OrganizationScopes; use App\Security\MemberScopes; use App\Models\Foundation\Main\IGroup; +use App\Http\Middleware\OAuth2BearerAccessTokenRequestValidator; use App\Security\CompanyScopes; use App\Security\SponsoredProjectScope; @@ -50,6 +51,8 @@ public function run() $this->seedSummitMediaFileTypeEndpoints(); $this->seedElectionsEndpoints(); $this->seedRSVPInvitationsEndpoints(); + // endpoints/scopes changed, drop the validator lookups cached by route and method + OAuth2BearerAccessTokenRequestValidator::flushEndpointCache(); } private function seedAttendeeBadgesEndpoints() diff --git a/tests/OAuth2BearerEndpointCacheTest.php b/tests/OAuth2BearerEndpointCacheTest.php new file mode 100644 index 0000000000..3e2f2494db --- /dev/null +++ b/tests/OAuth2BearerEndpointCacheTest.php @@ -0,0 +1,126 @@ +shouldReceive('isActive')->andReturn($active); + $endpoint->shouldReceive('getScopesNames')->andReturn($scopes); + return $endpoint; + } + + private function lookup($repository): array + { + $validator = new OAuth2BearerAccessTokenRequestValidator( + Mockery::mock(IResourceServerContext::class), + $repository, + Mockery::mock(IAccessTokenService::class) + ); + $m = new \ReflectionMethod($validator, 'getEndpointInfo'); + $m->setAccessible(true); + return $m->invoke($validator, self::Route, self::Method); + } + + public function testCacheMissPopulatesAndHitSkipsRepository(): void + { + $repo = Mockery::mock(IApiEndpointRepository::class); + $repo->shouldReceive('getApiEndpointByUrlAndMethod') + ->once() + ->with(self::Route, self::Method) + ->andReturn($this->endpoint(true, ['scope-a', 'scope-b'])); + + $expected = ['exists' => true, 'active' => true, 'scopes' => ['scope-a', 'scope-b']]; + $this->assertSame($expected, $this->lookup($repo)); + $this->assertSame($expected, $this->lookup($repo)); + } + + public function testInactiveEndpointIsReportedInactive(): void + { + $repo = Mockery::mock(IApiEndpointRepository::class); + $repo->shouldReceive('getApiEndpointByUrlAndMethod') + ->once() + ->andReturn($this->endpoint(false, ['scope-a'])); + + $this->assertFalse($this->lookup($repo)['active']); + $this->assertFalse($this->lookup($repo)['active']); + } + + public function testUnknownEndpointIsCachedAsMissing(): void + { + $repo = Mockery::mock(IApiEndpointRepository::class); + $repo->shouldReceive('getApiEndpointByUrlAndMethod')->once()->andReturn(null); + + $this->assertFalse($this->lookup($repo)['exists']); + $this->assertFalse($this->lookup($repo)['exists']); + } + + public function testLifetimeZeroDisablesCache(): void + { + Config::set('cache_api_response.api_endpoint_lifetime', 0); + $repo = Mockery::mock(IApiEndpointRepository::class); + $repo->shouldReceive('getApiEndpointByUrlAndMethod') + ->twice() + ->andReturn($this->endpoint(true, ['scope-a'])); + + $this->lookup($repo); + $this->lookup($repo); + $this->addToAssertionCount(Mockery::getContainer()->mockery_getExpectationCount()); + } + + public function testFlushForcesNewLookup(): void + { + $repo = Mockery::mock(IApiEndpointRepository::class); + $repo->shouldReceive('getApiEndpointByUrlAndMethod') + ->twice() + ->andReturn($this->endpoint(true, ['scope-a'])); + + $this->lookup($repo); + OAuth2BearerAccessTokenRequestValidator::flushEndpointCache(); + $this->lookup($repo); + $this->addToAssertionCount(Mockery::getContainer()->mockery_getExpectationCount()); + } +} From aedaf6e7e0533808c0d2d2a88ebc47b462718f5e Mon Sep 17 00:00:00 2001 From: romanetar Date: Tue, 20 Oct 2026 19:41:55 +0200 Subject: [PATCH 2/2] fix(oauth2): do not reuse a default version when the endpoint cache version is missing A cleared or evicted version key fell back to '0', so entries cached under that version could be served stale. Generate and persist a fresh version instead. --- ...Auth2BearerAccessTokenRequestValidator.php | 10 +++++++- tests/OAuth2BearerEndpointCacheTest.php | 23 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/app/Http/Middleware/OAuth2BearerAccessTokenRequestValidator.php b/app/Http/Middleware/OAuth2BearerAccessTokenRequestValidator.php index 5e6c891b37..255d266428 100644 --- a/app/Http/Middleware/OAuth2BearerAccessTokenRequestValidator.php +++ b/app/Http/Middleware/OAuth2BearerAccessTokenRequestValidator.php @@ -108,10 +108,18 @@ private function getEndpointInfo(string $route, string $method): array return $loader(); } + // a missing version (cache cleared or evicted) gets a fresh one, so entries cached under + // a previous version are never read again + $version = Cache::get(self::EndpointCacheVersionKey); + if (is_null($version)) { + Cache::add(self::EndpointCacheVersionKey, uniqid('', true)); + $version = Cache::get(self::EndpointCacheVersionKey); + } + $key = sprintf ( 'api_endpoint_%s_%s_%s', - Cache::get(self::EndpointCacheVersionKey, '0'), + $version, $route, $method ); diff --git a/tests/OAuth2BearerEndpointCacheTest.php b/tests/OAuth2BearerEndpointCacheTest.php index 3e2f2494db..5ba3212124 100644 --- a/tests/OAuth2BearerEndpointCacheTest.php +++ b/tests/OAuth2BearerEndpointCacheTest.php @@ -111,6 +111,29 @@ public function testLifetimeZeroDisablesCache(): void $this->addToAssertionCount(Mockery::getContainer()->mockery_getExpectationCount()); } + public function testMissingVersionIsPersistedAndNotReused(): void + { + $repo = Mockery::mock(IApiEndpointRepository::class); + $repo->shouldReceive('getApiEndpointByUrlAndMethod') + ->twice() + ->andReturn($this->endpoint(true, ['scope-a'])); + + $this->assertNull(Cache::get(OAuth2BearerAccessTokenRequestValidator::EndpointCacheVersionKey)); + $this->lookup($repo); + $version = Cache::get(OAuth2BearerAccessTokenRequestValidator::EndpointCacheVersionKey); + $this->assertNotNull($version); + $this->assertNotSame('0', $version); + + // the version is evicted: entries cached under it must not be served again + Cache::forget(OAuth2BearerAccessTokenRequestValidator::EndpointCacheVersionKey); + $this->lookup($repo); + $this->assertNotSame( + $version, + Cache::get(OAuth2BearerAccessTokenRequestValidator::EndpointCacheVersionKey) + ); + $this->addToAssertionCount(Mockery::getContainer()->mockery_getExpectationCount()); + } + public function testFlushForcesNewLookup(): void { $repo = Mockery::mock(IApiEndpointRepository::class);