Skip to content
Open
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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 60 additions & 3 deletions app/Http/Middleware/OAuth2BearerAccessTokenRequestValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -33,6 +35,7 @@
*/
class OAuth2BearerAccessTokenRequestValidator
{
const EndpointCacheVersionKey = 'api_endpoint_cache_version';

/**
* @var IResourceServerContext
Expand Down Expand Up @@ -70,6 +73,60 @@ 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();
}

// 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',
$version,
$route,
$method
);

return Cache::remember($key, $lifetime, $loader);
}

/**
* @param \Illuminate\Http\Request $request
* @param Closure $next
Expand Down Expand Up @@ -131,10 +188,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)
Expand Down Expand Up @@ -184,7 +241,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());
Expand Down
2 changes: 2 additions & 0 deletions config/cache_api_response.php
Original file line number Diff line number Diff line change
Expand Up @@ -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),
];
5 changes: 5 additions & 0 deletions database/migrations/config/APIEndpointsMigrationHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
3 changes: 3 additions & 0 deletions database/seeders/ApiEndpointsSeeder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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()
Expand Down
149 changes: 149 additions & 0 deletions tests/OAuth2BearerEndpointCacheTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
<?php namespace Tests;
/**
* Copyright 2026 OpenStack Foundation
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/

use App\Http\Middleware\OAuth2BearerAccessTokenRequestValidator;
use App\Models\ResourceServer\ApiEndpoint;
use App\Models\ResourceServer\IAccessTokenService;
use App\Models\ResourceServer\IApiEndpointRepository;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Config;
use models\oauth2\IResourceServerContext;
use Mockery;

/**
* Class OAuth2BearerEndpointCacheTest
* Covers the cache of the endpoint lookup done by the bearer token validator.
*/
class OAuth2BearerEndpointCacheTest extends TestCase
{
private const Route = '/api/v1/summits/{id}';
private const Method = 'GET';

protected function setUp(): void
{
parent::setUp();
Config::set('cache.default', 'array');
Cache::flush();
Config::set('cache_api_response.api_endpoint_lifetime', 3600);
}

protected function tearDown(): void
{
Cache::flush();
Mockery::close();
parent::tearDown();
}

private function endpoint(bool $active, array $scopes): ApiEndpoint
{
$endpoint = Mockery::mock(ApiEndpoint::class);
$endpoint->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 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);
$repo->shouldReceive('getApiEndpointByUrlAndMethod')
->twice()
->andReturn($this->endpoint(true, ['scope-a']));

$this->lookup($repo);
OAuth2BearerAccessTokenRequestValidator::flushEndpointCache();
$this->lookup($repo);
$this->addToAssertionCount(Mockery::getContainer()->mockery_getExpectationCount());
}
}
Loading