From 4f8f2687d3b6c9e4ec02bf128f367447fd9be296 Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Thu, 29 Oct 2026 09:08:48 -0400 Subject: [PATCH 1/4] fix(FOUR-34195): fail fast and cache invalid API Bearer tokens before Passport --- .../Middleware/ProcessMakerAuthenticate.php | 28 ++- .../Services/InvalidBearerTokenCache.php | 159 ++++++++++++++++++ config/auth.php | 6 + .../Services/InvalidBearerTokenCacheTest.php | 156 +++++++++++++++++ 4 files changed, 341 insertions(+), 8 deletions(-) create mode 100644 ProcessMaker/Services/InvalidBearerTokenCache.php create mode 100644 tests/unit/ProcessMaker/Services/InvalidBearerTokenCacheTest.php diff --git a/ProcessMaker/Http/Middleware/ProcessMakerAuthenticate.php b/ProcessMaker/Http/Middleware/ProcessMakerAuthenticate.php index b64c13812e..210b6d8273 100644 --- a/ProcessMaker/Http/Middleware/ProcessMakerAuthenticate.php +++ b/ProcessMaker/Http/Middleware/ProcessMakerAuthenticate.php @@ -3,8 +3,11 @@ namespace ProcessMaker\Http\Middleware; use Closure; +use Illuminate\Auth\AuthenticationException; use Illuminate\Auth\Middleware\Authenticate; use Illuminate\Support\Str; +use League\OAuth2\Server\Exception\OAuthServerException; +use ProcessMaker\Services\InvalidBearerTokenCache; class ProcessMakerAuthenticate extends Authenticate { @@ -27,21 +30,34 @@ public function handle($request, Closure $next, ...$guards) protected function authenticate($request, array $guards) { + InvalidBearerTokenCache::guardBeforePassport($request, $guards); + InvalidBearerTokenCache::saveJwtBearerBeforePassportClearsHeader($request); + $this->addAcceptJsonHeaderIfApiCall($request, $guards); - // Load permissions into the session if (auth()->check() && !$request->ajax()) { $permissions = $request->user()->loadPermissions(); session(['permissions' => $permissions]); } - return parent::authenticate($request, $guards); + try { + return parent::authenticate($request, $guards); + } catch (OAuthServerException $e) { + InvalidBearerTokenCache::rememberPassportJwtFailure($request, $guards); + throw $e; + } } /** - * @param array $guards - * @param \Illuminate\Http\Request $request + * {@inheritdoc} */ + protected function unauthenticated($request, array $guards) + { + InvalidBearerTokenCache::rememberPassportJwtFailure($request, $guards); + + throw new AuthenticationException('Unauthenticated.', $guards); + } + private function addAcceptJsonHeaderIfApiCall(\Illuminate\Http\Request $request, array $guards): void { if (in_array('api', $guards) && !$this->requestHasAcceptJsonHeader($request)) { @@ -49,10 +65,6 @@ private function addAcceptJsonHeaderIfApiCall(\Illuminate\Http\Request $request, } } - /** - * @param \Illuminate\Http\Request $request - * @return bool - */ private function requestHasAcceptJsonHeader(\Illuminate\Http\Request $request): bool { return Str::contains($request->header('accept'), ['/json', '+json']); diff --git a/ProcessMaker/Services/InvalidBearerTokenCache.php b/ProcessMaker/Services/InvalidBearerTokenCache.php new file mode 100644 index 0000000000..0043f2174b --- /dev/null +++ b/ProcessMaker/Services/InvalidBearerTokenCache.php @@ -0,0 +1,159 @@ +attributes->set(self::ATTR_STASHED_JWT, $rawBearer); + } + + public static function rememberPassportJwtFailure(Request $request, array $guards = []): void + { + if (!self::isApiBearerRequest($request, $guards)) { + return; + } + + $jwt = self::jwtBearerFromRequest($request); + if ($jwt === null || self::ttlSeconds() <= 0) { + return; + } + + if ($request->attributes->get(self::ATTR_ALREADY_CACHED)) { + return; + } + + self::cache()->put(self::cacheKey($jwt), true, self::ttlSeconds()); + $request->attributes->set(self::ATTR_ALREADY_CACHED, true); + } + + public static function isInNegativeCache(string $token): bool + { + if (self::ttlSeconds() <= 0) { + return false; + } + + return self::cache()->has(self::cacheKey($token)); + } + + public static function setRepositoryForTesting(?RepositoryContract $repository): void + { + self::$repositoryForTesting = $repository; + } + + public static function setTtlSecondsForTesting(?int $ttl): void + { + self::$ttlSecondsForTesting = $ttl; + } + + private static function isApiBearerRequest(Request $request, array $guards): bool + { + if ($guards !== [] && !in_array('api', $guards, true)) { + return false; + } + + if ($guards === [] && !str_starts_with($request->path(), 'api/')) { + return false; + } + + return self::bearerFromAuthorizationHeader($request) !== null + || $request->attributes->has(self::ATTR_STASHED_JWT); + } + + private static function jwtBearerFromRequest(Request $request): ?string + { + $fromHeader = self::bearerFromAuthorizationHeader($request); + if ($fromHeader !== null && self::looksLikeJwt($fromHeader)) { + return $fromHeader; + } + + $stashed = $request->attributes->get(self::ATTR_STASHED_JWT); + if (is_string($stashed) && self::looksLikeJwt($stashed)) { + return $stashed; + } + + return null; + } + + private static function bearerFromAuthorizationHeader(Request $request): ?string + { + $header = $request->header('Authorization', ''); + if (!str_starts_with($header, 'Bearer ')) { + return null; + } + + $token = trim(substr($header, 7)); + + return $token === '' ? null : $token; + } + + private static function looksLikeJwt(string $token): bool + { + return substr_count($token, '.') === 2; + } + + private static function cacheKey(string $token): string + { + return self::CACHE_PREFIX . hash('sha256', $token); + } + + private static function ttlSeconds(): int + { + if (self::$ttlSecondsForTesting !== null) { + return self::$ttlSecondsForTesting; + } + + return (int) config('auth.invalid_bearer_cache_ttl', 60); + } + + private static function cache(): RepositoryContract + { + if (self::$repositoryForTesting !== null) { + return self::$repositoryForTesting; + } + + /** @var Repository $store */ + $store = Cache::store(); + + return $store; + } +} diff --git a/config/auth.php b/config/auth.php index e7811a250d..7b34bcec1d 100644 --- a/config/auth.php +++ b/config/auth.php @@ -111,6 +111,12 @@ 'log_auth_events' => env('LOG_AUTH_EVENTS', true), + /* + | Seconds to cache a failed JWT-shaped Bearer (hash only) so repeat requests skip Passport. + | Set AUTH_INVALID_BEARER_CACHE_TTL=0 to disable. + */ + 'invalid_bearer_cache_ttl' => (int) env('AUTH_INVALID_BEARER_CACHE_TTL', 60), + /* |-------------------------------------------------------------------------- | Password Confirmation Timeout diff --git a/tests/unit/ProcessMaker/Services/InvalidBearerTokenCacheTest.php b/tests/unit/ProcessMaker/Services/InvalidBearerTokenCacheTest.php new file mode 100644 index 0000000000..ee27eb9f42 --- /dev/null +++ b/tests/unit/ProcessMaker/Services/InvalidBearerTokenCacheTest.php @@ -0,0 +1,156 @@ +cache = new Repository(new ArrayStore()); + InvalidBearerTokenCache::setRepositoryForTesting($this->cache); + InvalidBearerTokenCache::setTtlSecondsForTesting(60); + } + + protected function tearDown(): void + { + InvalidBearerTokenCache::setRepositoryForTesting(null); + InvalidBearerTokenCache::setTtlSecondsForTesting(null); + parent::tearDown(); + } + + public function testRememberPassportJwtFailureMarksTokenForSkipPassport(): void + { + $token = 'aaa.bbb.ccc'; + $request = Request::create('/api/1.0/tasks', 'GET'); + $request->headers->set('Authorization', 'Bearer ' . $token); + + $this->assertFalse(InvalidBearerTokenCache::isInNegativeCache($token)); + + InvalidBearerTokenCache::rememberPassportJwtFailure($request, ['api']); + + $this->assertTrue(InvalidBearerTokenCache::isInNegativeCache($token)); + } + + public function testGuardBeforePassportThrowsWhenTokenWasCached(): void + { + $request = Request::create('/api/1.0/tasks', 'GET'); + $request->headers->set('Authorization', 'Bearer aaa.bbb.ccc'); + + InvalidBearerTokenCache::rememberPassportJwtFailure($request, ['api']); + + $this->expectException(AuthenticationException::class); + + InvalidBearerTokenCache::guardBeforePassport($request, ['api']); + } + + public function testGuardBeforePassportRejectsBearerWithoutJwtShape(): void + { + $request = $this->requestWithAuthorization('Bearer FAKE_JWT'); + + $this->expectException(AuthenticationException::class); + + InvalidBearerTokenCache::guardBeforePassport($request, ['api']); + } + + public function testGuardBeforePassportAllowsJwtShapedBearerThrough(): void + { + $request = $this->requestWithAuthorization('Bearer aaa.bbb.ccc'); + + InvalidBearerTokenCache::guardBeforePassport($request, ['api']); + $this->addToAssertionCount(1); + } + + public function testGuardBeforePassportIgnoresEmptyBearer(): void + { + InvalidBearerTokenCache::guardBeforePassport( + $this->requestWithAuthorization('Bearer '), + ['api'] + ); + $this->addToAssertionCount(1); + } + + public function testGuardBeforePassportIgnoresWhitespaceOnlyBearer(): void + { + InvalidBearerTokenCache::guardBeforePassport( + $this->requestWithAuthorization('Bearer '), + ['api'] + ); + $this->addToAssertionCount(1); + } + + public function testGuardBeforePassportAppliesWhenApiGuardAmongMultiple(): void + { + InvalidBearerTokenCache::guardBeforePassport( + $this->requestWithAuthorization('Bearer aaa.bbb.ccc'), + ['web', 'api'] + ); + $this->addToAssertionCount(1); + } + + public function testGuardBeforePassportSkipsNonApiGuard(): void + { + InvalidBearerTokenCache::guardBeforePassport( + $this->requestWithAuthorization('Bearer FAKE_JWT'), + ['web'] + ); + $this->addToAssertionCount(1); + } + + public function testGuardBeforePassportSkipsWithoutBearerHeader(): void + { + InvalidBearerTokenCache::guardBeforePassport( + Request::create('/api/1.0/tasks', 'GET'), + ['api'] + ); + $this->addToAssertionCount(1); + } + + public function testGuardBeforePassportSkipsNonBearerScheme(): void + { + InvalidBearerTokenCache::guardBeforePassport( + $this->requestWithAuthorization('Basic dXNlcjpwYXNz'), + ['api'] + ); + $this->addToAssertionCount(1); + } + + public function testDoesNotCacheMalformedTokensFromRequest(): void + { + $request = Request::create('/api/1.0/tasks', 'GET'); + $request->headers->set('Authorization', 'Bearer FAKE_JWT'); + + InvalidBearerTokenCache::rememberPassportJwtFailure($request, ['api']); + + $this->assertFalse(InvalidBearerTokenCache::isInNegativeCache('FAKE_JWT')); + } + + public function testRememberPassportJwtFailureUsesStashedTokenAfterPassportClearsHeader(): void + { + $request = Request::create('/api/1.0/tasks', 'GET'); + $request->headers->set('Authorization', 'Bearer aaa.bbb.ccc'); + InvalidBearerTokenCache::saveJwtBearerBeforePassportClearsHeader($request); + $request->headers->remove('Authorization'); + + InvalidBearerTokenCache::rememberPassportJwtFailure($request, ['api']); + + $this->assertTrue(InvalidBearerTokenCache::isInNegativeCache('aaa.bbb.ccc')); + } + + private function requestWithAuthorization(string $authorization): Request + { + $request = Request::create('/api/1.0/tasks', 'GET'); + $request->headers->set('Authorization', $authorization); + + return $request; + } +} From a2f87837bcf53913227a98ea96b823bc365829ed Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Thu, 29 Oct 2026 09:22:19 -0400 Subject: [PATCH 2/4] fix: add improvements --- .../Middleware/ProcessMakerAuthenticate.php | 3 +- .../Services/InvalidBearerTokenCache.php | 12 ++---- .../Services/InvalidBearerTokenCacheTest.php | 38 +++++++++---------- 3 files changed, 23 insertions(+), 30 deletions(-) diff --git a/ProcessMaker/Http/Middleware/ProcessMakerAuthenticate.php b/ProcessMaker/Http/Middleware/ProcessMakerAuthenticate.php index 210b6d8273..d121c67f26 100644 --- a/ProcessMaker/Http/Middleware/ProcessMakerAuthenticate.php +++ b/ProcessMaker/Http/Middleware/ProcessMakerAuthenticate.php @@ -30,8 +30,7 @@ public function handle($request, Closure $next, ...$guards) protected function authenticate($request, array $guards) { - InvalidBearerTokenCache::guardBeforePassport($request, $guards); - InvalidBearerTokenCache::saveJwtBearerBeforePassportClearsHeader($request); + InvalidBearerTokenCache::prepareApiBearerBeforePassport($request, $guards); $this->addAcceptJsonHeaderIfApiCall($request, $guards); diff --git a/ProcessMaker/Services/InvalidBearerTokenCache.php b/ProcessMaker/Services/InvalidBearerTokenCache.php index 0043f2174b..b18bec808c 100644 --- a/ProcessMaker/Services/InvalidBearerTokenCache.php +++ b/ProcessMaker/Services/InvalidBearerTokenCache.php @@ -20,7 +20,7 @@ class InvalidBearerTokenCache private static ?int $ttlSecondsForTesting = null; - public static function guardBeforePassport(Request $request, array $guards): void + public static function prepareApiBearerBeforePassport(Request $request, array $guards): void { if (!self::isApiBearerRequest($request, $guards)) { return; @@ -35,16 +35,10 @@ public static function guardBeforePassport(Request $request, array $guards): voi if ($rawBearer !== null && !self::looksLikeJwt($rawBearer)) { throw new AuthenticationException('Unauthenticated.', $guards); } - } - public static function saveJwtBearerBeforePassportClearsHeader(Request $request): void - { - $rawBearer = self::bearerFromAuthorizationHeader($request); - if ($rawBearer === null || !self::looksLikeJwt($rawBearer)) { - return; + if ($rawBearer !== null && self::looksLikeJwt($rawBearer)) { + $request->attributes->set(self::ATTR_STASHED_JWT, $rawBearer); } - - $request->attributes->set(self::ATTR_STASHED_JWT, $rawBearer); } public static function rememberPassportJwtFailure(Request $request, array $guards = []): void diff --git a/tests/unit/ProcessMaker/Services/InvalidBearerTokenCacheTest.php b/tests/unit/ProcessMaker/Services/InvalidBearerTokenCacheTest.php index ee27eb9f42..4a3a41a861 100644 --- a/tests/unit/ProcessMaker/Services/InvalidBearerTokenCacheTest.php +++ b/tests/unit/ProcessMaker/Services/InvalidBearerTokenCacheTest.php @@ -41,7 +41,7 @@ public function testRememberPassportJwtFailureMarksTokenForSkipPassport(): void $this->assertTrue(InvalidBearerTokenCache::isInNegativeCache($token)); } - public function testGuardBeforePassportThrowsWhenTokenWasCached(): void + public function testPrepareApiBearerBeforePassportThrowsWhenTokenWasCached(): void { $request = Request::create('/api/1.0/tasks', 'GET'); $request->headers->set('Authorization', 'Bearer aaa.bbb.ccc'); @@ -50,74 +50,74 @@ public function testGuardBeforePassportThrowsWhenTokenWasCached(): void $this->expectException(AuthenticationException::class); - InvalidBearerTokenCache::guardBeforePassport($request, ['api']); + InvalidBearerTokenCache::prepareApiBearerBeforePassport($request, ['api']); } - public function testGuardBeforePassportRejectsBearerWithoutJwtShape(): void + public function testPrepareApiBearerBeforePassportRejectsBearerWithoutJwtShape(): void { $request = $this->requestWithAuthorization('Bearer FAKE_JWT'); $this->expectException(AuthenticationException::class); - InvalidBearerTokenCache::guardBeforePassport($request, ['api']); + InvalidBearerTokenCache::prepareApiBearerBeforePassport($request, ['api']); } - public function testGuardBeforePassportAllowsJwtShapedBearerThrough(): void + public function testPrepareApiBearerBeforePassportAllowsJwtShapedBearerThrough(): void { $request = $this->requestWithAuthorization('Bearer aaa.bbb.ccc'); - InvalidBearerTokenCache::guardBeforePassport($request, ['api']); + InvalidBearerTokenCache::prepareApiBearerBeforePassport($request, ['api']); $this->addToAssertionCount(1); } - public function testGuardBeforePassportIgnoresEmptyBearer(): void + public function testPrepareApiBearerBeforePassportIgnoresEmptyBearer(): void { - InvalidBearerTokenCache::guardBeforePassport( + InvalidBearerTokenCache::prepareApiBearerBeforePassport( $this->requestWithAuthorization('Bearer '), ['api'] ); $this->addToAssertionCount(1); } - public function testGuardBeforePassportIgnoresWhitespaceOnlyBearer(): void + public function testPrepareApiBearerBeforePassportIgnoresWhitespaceOnlyBearer(): void { - InvalidBearerTokenCache::guardBeforePassport( + InvalidBearerTokenCache::prepareApiBearerBeforePassport( $this->requestWithAuthorization('Bearer '), ['api'] ); $this->addToAssertionCount(1); } - public function testGuardBeforePassportAppliesWhenApiGuardAmongMultiple(): void + public function testPrepareApiBearerBeforePassportAppliesWhenApiGuardAmongMultiple(): void { - InvalidBearerTokenCache::guardBeforePassport( + InvalidBearerTokenCache::prepareApiBearerBeforePassport( $this->requestWithAuthorization('Bearer aaa.bbb.ccc'), ['web', 'api'] ); $this->addToAssertionCount(1); } - public function testGuardBeforePassportSkipsNonApiGuard(): void + public function testPrepareApiBearerBeforePassportSkipsNonApiGuard(): void { - InvalidBearerTokenCache::guardBeforePassport( + InvalidBearerTokenCache::prepareApiBearerBeforePassport( $this->requestWithAuthorization('Bearer FAKE_JWT'), ['web'] ); $this->addToAssertionCount(1); } - public function testGuardBeforePassportSkipsWithoutBearerHeader(): void + public function testPrepareApiBearerBeforePassportSkipsWithoutBearerHeader(): void { - InvalidBearerTokenCache::guardBeforePassport( + InvalidBearerTokenCache::prepareApiBearerBeforePassport( Request::create('/api/1.0/tasks', 'GET'), ['api'] ); $this->addToAssertionCount(1); } - public function testGuardBeforePassportSkipsNonBearerScheme(): void + public function testPrepareApiBearerBeforePassportSkipsNonBearerScheme(): void { - InvalidBearerTokenCache::guardBeforePassport( + InvalidBearerTokenCache::prepareApiBearerBeforePassport( $this->requestWithAuthorization('Basic dXNlcjpwYXNz'), ['api'] ); @@ -138,7 +138,7 @@ public function testRememberPassportJwtFailureUsesStashedTokenAfterPassportClear { $request = Request::create('/api/1.0/tasks', 'GET'); $request->headers->set('Authorization', 'Bearer aaa.bbb.ccc'); - InvalidBearerTokenCache::saveJwtBearerBeforePassportClearsHeader($request); + InvalidBearerTokenCache::prepareApiBearerBeforePassport($request, ['api']); $request->headers->remove('Authorization'); InvalidBearerTokenCache::rememberPassportJwtFailure($request, ['api']); From 6cc23e84266095fa0cdceeee85aad048c9a53955 Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Thu, 29 Oct 2026 09:25:38 -0400 Subject: [PATCH 3/4] fix: restore unautenticate procedure --- ProcessMaker/Http/Middleware/ProcessMakerAuthenticate.php | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/ProcessMaker/Http/Middleware/ProcessMakerAuthenticate.php b/ProcessMaker/Http/Middleware/ProcessMakerAuthenticate.php index d121c67f26..ce77c39613 100644 --- a/ProcessMaker/Http/Middleware/ProcessMakerAuthenticate.php +++ b/ProcessMaker/Http/Middleware/ProcessMakerAuthenticate.php @@ -39,12 +39,7 @@ protected function authenticate($request, array $guards) session(['permissions' => $permissions]); } - try { - return parent::authenticate($request, $guards); - } catch (OAuthServerException $e) { - InvalidBearerTokenCache::rememberPassportJwtFailure($request, $guards); - throw $e; - } + return parent::authenticate($request, $guards); } /** From 2ff8477fc3edec9d7a9b9c306a634bf6ca3f6402 Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Thu, 29 Oct 2026 09:55:46 -0400 Subject: [PATCH 4/4] fix: cursor notes --- .../Middleware/ProcessMakerAuthenticate.php | 4 +- ...cessMakerAuthenticateInvalidBearerTest.php | 55 +++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 tests/Feature/Http/Middleware/ProcessMakerAuthenticateInvalidBearerTest.php diff --git a/ProcessMaker/Http/Middleware/ProcessMakerAuthenticate.php b/ProcessMaker/Http/Middleware/ProcessMakerAuthenticate.php index ce77c39613..283f9b4c16 100644 --- a/ProcessMaker/Http/Middleware/ProcessMakerAuthenticate.php +++ b/ProcessMaker/Http/Middleware/ProcessMakerAuthenticate.php @@ -30,10 +30,10 @@ public function handle($request, Closure $next, ...$guards) protected function authenticate($request, array $guards) { - InvalidBearerTokenCache::prepareApiBearerBeforePassport($request, $guards); - $this->addAcceptJsonHeaderIfApiCall($request, $guards); + InvalidBearerTokenCache::prepareApiBearerBeforePassport($request, $guards); + if (auth()->check() && !$request->ajax()) { $permissions = $request->user()->loadPermissions(); session(['permissions' => $permissions]); diff --git a/tests/Feature/Http/Middleware/ProcessMakerAuthenticateInvalidBearerTest.php b/tests/Feature/Http/Middleware/ProcessMakerAuthenticateInvalidBearerTest.php new file mode 100644 index 0000000000..6066e40e5c --- /dev/null +++ b/tests/Feature/Http/Middleware/ProcessMakerAuthenticateInvalidBearerTest.php @@ -0,0 +1,55 @@ +withHeaders([ + 'Authorization' => 'Bearer FAKE_JWT', + 'Accept' => 'text/html', + ])->get('/api/1.0/tasks'); + + $response->assertStatus(401); + $response->assertJson(['error' => 'Unauthenticated.']); + } + + public function testCachedInvalidJwtWithoutAcceptJsonReturns401(): void + { + $token = 'aaa.bbb.ccc'; + $request = Request::create('/api/1.0/tasks', 'GET'); + $request->headers->set('Authorization', 'Bearer ' . $token); + InvalidBearerTokenCache::rememberPassportJwtFailure($request, ['api']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $token, + 'Accept' => 'text/html', + ])->get('/api/1.0/tasks'); + + $response->assertStatus(401); + $response->assertJson(['error' => 'Unauthenticated.']); + } +}