From ba5b5896d181e7ae69d740e99f5699f0540803a6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 7 Jan 2026 10:55:11 +0000 Subject: [PATCH 1/3] feat(api-bundle): add Bearer token authentication support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create MultiHeaderTokenAuthenticatorInterface extending TokenAuthenticatorInterface - Update StorageApiTokenAuthenticator to implement MultiHeaderTokenAuthenticatorInterface - Add getTokenHeaders() method returning ['Authorization', 'X-StorageApi-Token'] - Update AttributeAuthenticator to try multiple headers in priority order - Extract Bearer token value from 'Bearer ' format - Maintain backward compatibility with single-header authenticators This enables api-bundle to accept OAuth Bearer tokens in addition to traditional Storage API tokens, supporting the OAuth authentication flow. Refs: AI-2276 Co-Authored-By: Tomáš Fejfar --- .../src/Security/AttributeAuthenticator.php | 33 +++++++++++++++++-- ...MultiHeaderTokenAuthenticatorInterface.php | 24 ++++++++++++++ .../StorageApiTokenAuthenticator.php | 11 +++++-- 3 files changed, 62 insertions(+), 6 deletions(-) create mode 100644 libs/api-bundle/src/Security/MultiHeaderTokenAuthenticatorInterface.php diff --git a/libs/api-bundle/src/Security/AttributeAuthenticator.php b/libs/api-bundle/src/Security/AttributeAuthenticator.php index 87b5dd062..fe924f957 100644 --- a/libs/api-bundle/src/Security/AttributeAuthenticator.php +++ b/libs/api-bundle/src/Security/AttributeAuthenticator.php @@ -42,13 +42,15 @@ public function authenticate(Request $request): SelfValidatingPassport $authenticator = $this->authenticators->get($authAttribute->getName()); assert($authenticator instanceof TokenAuthenticatorInterface); - $tokenHeader = $authenticator->getTokenHeader(); - $token = $request->headers->get($tokenHeader); + $token = $this->getTokenFromRequest($request, $authenticator); if ($token === null) { + $tokenHeaders = $authenticator instanceof MultiHeaderTokenAuthenticatorInterface + ? $authenticator->getTokenHeaders() + : [$authenticator->getTokenHeader()]; $error = new CustomUserMessageAuthenticationException(sprintf( 'Authentication header "%s" is missing', - $tokenHeader, + implode('" or "', $tokenHeaders), )); continue; } @@ -125,4 +127,29 @@ private function getControllerAuthAttributes(Request $request): array ReflectionAttribute::IS_INSTANCEOF, ); } + + /** + * Gets the token from the request, trying multiple headers if the authenticator supports it. + * For Bearer tokens, extracts the token value from "Bearer " format. + * + * @param TokenAuthenticatorInterface $authenticator + */ + private function getTokenFromRequest(Request $request, TokenAuthenticatorInterface $authenticator): ?string + { + if ($authenticator instanceof MultiHeaderTokenAuthenticatorInterface) { + foreach ($authenticator->getTokenHeaders() as $header) { + $value = $request->headers->get($header); + if ($value !== null) { + // Handle Bearer token format + if ($header === 'Authorization' && str_starts_with($value, 'Bearer ')) { + return substr($value, 7); + } + return $value; + } + } + return null; + } + + return $request->headers->get($authenticator->getTokenHeader()); + } } diff --git a/libs/api-bundle/src/Security/MultiHeaderTokenAuthenticatorInterface.php b/libs/api-bundle/src/Security/MultiHeaderTokenAuthenticatorInterface.php new file mode 100644 index 000000000..eb961dc95 --- /dev/null +++ b/libs/api-bundle/src/Security/MultiHeaderTokenAuthenticatorInterface.php @@ -0,0 +1,24 @@ + + */ +interface MultiHeaderTokenAuthenticatorInterface extends TokenAuthenticatorInterface +{ + /** + * Returns a list of authentication headers to try, in priority order. + * The first header that has a non-null value will be used for authentication. + * + * @return list + */ + public function getTokenHeaders(): array; +} diff --git a/libs/api-bundle/src/Security/StorageApiToken/StorageApiTokenAuthenticator.php b/libs/api-bundle/src/Security/StorageApiToken/StorageApiTokenAuthenticator.php index f4ceca986..d039ae988 100644 --- a/libs/api-bundle/src/Security/StorageApiToken/StorageApiTokenAuthenticator.php +++ b/libs/api-bundle/src/Security/StorageApiToken/StorageApiTokenAuthenticator.php @@ -6,7 +6,7 @@ use Keboola\ApiBundle\Attribute\AuthAttributeInterface; use Keboola\ApiBundle\Attribute\StorageApiTokenAuth; -use Keboola\ApiBundle\Security\TokenAuthenticatorInterface; +use Keboola\ApiBundle\Security\MultiHeaderTokenAuthenticatorInterface; use Keboola\ApiBundle\Security\TokenInterface; use Keboola\StorageApi\ClientException; use Keboola\StorageApiBranch\Factory\StorageClientRequestFactory; @@ -15,9 +15,9 @@ use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException; /** - * @implements TokenAuthenticatorInterface + * @implements MultiHeaderTokenAuthenticatorInterface */ -class StorageApiTokenAuthenticator implements TokenAuthenticatorInterface +class StorageApiTokenAuthenticator implements MultiHeaderTokenAuthenticatorInterface { public function __construct( private readonly StorageClientRequestFactory $clientRequestFactory, @@ -30,6 +30,11 @@ public function getTokenHeader(): string return 'X-StorageApi-Token'; } + public function getTokenHeaders(): array + { + return ['Authorization', 'X-StorageApi-Token']; + } + public function authenticateToken(AuthAttributeInterface $authAttribute, string $token): StorageApiToken { assert($authAttribute instanceof StorageApiTokenAuth); From 38e139a7f210cab32503d72c4088775bbbc3964d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 7 Jan 2026 11:06:00 +0000 Subject: [PATCH 2/3] fix(api-bundle): refactor to call getTokenHeader() only once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compute tokenHeaders array once at the top of the loop and pass it to the helper function. This avoids calling getTokenHeader() twice when the token is null (once for extraction, once for error message). The helper function is renamed from getTokenFromRequest to getTokenFromHeaders and now takes an array of headers instead of an authenticator, making the code cleaner and more testable. Co-Authored-By: Tomáš Fejfar --- .../src/Security/AttributeAuthenticator.php | 35 ++++++++----------- 1 file changed, 14 insertions(+), 21 deletions(-) diff --git a/libs/api-bundle/src/Security/AttributeAuthenticator.php b/libs/api-bundle/src/Security/AttributeAuthenticator.php index fe924f957..56ed436ff 100644 --- a/libs/api-bundle/src/Security/AttributeAuthenticator.php +++ b/libs/api-bundle/src/Security/AttributeAuthenticator.php @@ -42,12 +42,13 @@ public function authenticate(Request $request): SelfValidatingPassport $authenticator = $this->authenticators->get($authAttribute->getName()); assert($authenticator instanceof TokenAuthenticatorInterface); - $token = $this->getTokenFromRequest($request, $authenticator); + $tokenHeaders = $authenticator instanceof MultiHeaderTokenAuthenticatorInterface + ? $authenticator->getTokenHeaders() + : [$authenticator->getTokenHeader()]; + + $token = $this->getTokenFromHeaders($request, $tokenHeaders); if ($token === null) { - $tokenHeaders = $authenticator instanceof MultiHeaderTokenAuthenticatorInterface - ? $authenticator->getTokenHeaders() - : [$authenticator->getTokenHeader()]; $error = new CustomUserMessageAuthenticationException(sprintf( 'Authentication header "%s" is missing', implode('" or "', $tokenHeaders), @@ -129,27 +130,19 @@ private function getControllerAuthAttributes(Request $request): array } /** - * Gets the token from the request, trying multiple headers if the authenticator supports it. - * For Bearer tokens, extracts the token value from "Bearer " format. - * - * @param TokenAuthenticatorInterface $authenticator + * @param list $tokenHeaders */ - private function getTokenFromRequest(Request $request, TokenAuthenticatorInterface $authenticator): ?string + private function getTokenFromHeaders(Request $request, array $tokenHeaders): ?string { - if ($authenticator instanceof MultiHeaderTokenAuthenticatorInterface) { - foreach ($authenticator->getTokenHeaders() as $header) { - $value = $request->headers->get($header); - if ($value !== null) { - // Handle Bearer token format - if ($header === 'Authorization' && str_starts_with($value, 'Bearer ')) { - return substr($value, 7); - } - return $value; + foreach ($tokenHeaders as $header) { + $value = $request->headers->get($header); + if ($value !== null) { + if ($header === 'Authorization' && str_starts_with($value, 'Bearer ')) { + return substr($value, 7); } + return $value; } - return null; } - - return $request->headers->get($authenticator->getTokenHeader()); + return null; } } From ef0d13610106248c71ecb6081899835d551e6dcc Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 8 Jan 2026 08:01:58 +0000 Subject: [PATCH 3/3] test(api-bundle): add tests for Bearer token authentication support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Tomáš Fejfar --- .../Security/AttributeAuthenticatorTest.php | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) diff --git a/libs/api-bundle/tests/Security/AttributeAuthenticatorTest.php b/libs/api-bundle/tests/Security/AttributeAuthenticatorTest.php index d27cac778..fb0ed0899 100644 --- a/libs/api-bundle/tests/Security/AttributeAuthenticatorTest.php +++ b/libs/api-bundle/tests/Security/AttributeAuthenticatorTest.php @@ -7,6 +7,7 @@ use Keboola\ApiBundle\Attribute\ManageApiTokenAuth; use Keboola\ApiBundle\Attribute\StorageApiTokenAuth; use Keboola\ApiBundle\Security\AttributeAuthenticator; +use Keboola\ApiBundle\Security\MultiHeaderTokenAuthenticatorInterface; use Keboola\ApiBundle\Security\TokenAuthenticatorInterface; use Keboola\ApiBundle\Security\TokenInterface; use Keboola\ApiBundle\Util\ControllerReflector; @@ -376,4 +377,182 @@ private function createAuthenticatorWithFailingAuthorization( return $authenticator; } + + public function testAuthenticateRequestWithBearerToken(): void + { + $controller = new + #[StorageApiTokenAuth(['foo-feature'])] + class { + public function __invoke(): void {} + }; + + $request = $this->createControllerRequest($controller, [ + 'Authorization' => 'Bearer my-oauth-token', + ]); + + $token = $this->createToken('user-id'); + + $authenticator = $this->createAuthenticator( + $controller, + [ + StorageApiTokenAuth::class => $this->createMultiHeaderSuccessAuthenticator( + $token, + 'my-oauth-token', + ), + ], + ); + $passport = $authenticator->authenticate($request); + + self::assertSame($token, $passport->getUser()); + } + + public function testAuthenticateRequestWithXStorageApiTokenFallback(): void + { + $controller = new + #[StorageApiTokenAuth(['foo-feature'])] + class { + public function __invoke(): void {} + }; + + $request = $this->createControllerRequest($controller, [ + 'X-StorageApi-Token' => 'my-storage-token', + ]); + + $token = $this->createToken('user-id'); + + $authenticator = $this->createAuthenticator( + $controller, + [ + StorageApiTokenAuth::class => $this->createMultiHeaderSuccessAuthenticator( + $token, + 'my-storage-token', + ), + ], + ); + $passport = $authenticator->authenticate($request); + + self::assertSame($token, $passport->getUser()); + } + + public function testAuthenticateRequestBearerTokenTakesPrecedence(): void + { + $controller = new + #[StorageApiTokenAuth(['foo-feature'])] + class { + public function __invoke(): void {} + }; + + // Both headers present - Bearer should take precedence + $request = $this->createControllerRequest($controller, [ + 'Authorization' => 'Bearer my-oauth-token', + 'X-StorageApi-Token' => 'my-storage-token', + ]); + + $token = $this->createToken('user-id'); + + // The authenticator should receive the Bearer token, not the X-StorageApi-Token + $authenticator = $this->createAuthenticator( + $controller, + [ + StorageApiTokenAuth::class => $this->createMultiHeaderSuccessAuthenticator( + $token, + 'my-oauth-token', // Bearer token value (without "Bearer " prefix) + ), + ], + ); + $passport = $authenticator->authenticate($request); + + self::assertSame($token, $passport->getUser()); + } + + public function testAuthenticateRequestWithMultiHeaderAuthenticatorNoToken(): void + { + $controller = new + #[StorageApiTokenAuth(['foo-feature'])] + class { + public function __invoke(): void {} + }; + + $request = $this->createControllerRequest($controller, []); + + $multiHeaderAuthenticator = $this->createMock(MultiHeaderTokenAuthenticatorInterface::class); + $multiHeaderAuthenticator->expects(self::once()) + ->method('getTokenHeaders') + ->willReturn(['Authorization', 'X-StorageApi-Token']) + ; + + $authenticator = $this->createAuthenticator( + $controller, + [ + StorageApiTokenAuth::class => $multiHeaderAuthenticator, + ], + ); + + $this->expectException(CustomUserMessageAuthenticationException::class); + $this->expectExceptionMessage('Authentication header "Authorization" or "X-StorageApi-Token" is missing'); + + $authenticator->authenticate($request); + } + + public function testAuthenticateRequestWithAuthorizationHeaderWithoutBearerPrefix(): void + { + $controller = new + #[StorageApiTokenAuth(['foo-feature'])] + class { + public function __invoke(): void {} + }; + + // Authorization header without "Bearer " prefix should be used as-is + $request = $this->createControllerRequest($controller, [ + 'Authorization' => 'Basic some-basic-auth', + ]); + + $token = $this->createToken('user-id'); + + $authenticator = $this->createAuthenticator( + $controller, + [ + StorageApiTokenAuth::class => $this->createMultiHeaderSuccessAuthenticator( + $token, + 'Basic some-basic-auth', // Full value since it's not Bearer + ), + ], + ); + $passport = $authenticator->authenticate($request); + + self::assertSame($token, $passport->getUser()); + } + + /** + * @return MultiHeaderTokenAuthenticatorInterface + */ + private function createMultiHeaderSuccessAuthenticator( + TokenInterface $token, + string $expectedTokenValue, + ): MultiHeaderTokenAuthenticatorInterface { + $authenticator = $this->createMock(MultiHeaderTokenAuthenticatorInterface::class); + $authenticator->expects(self::once()) + ->method('getTokenHeaders') + ->willReturn(['Authorization', 'X-StorageApi-Token']) + ; + + $authenticator->expects(self::once()) + ->method('authenticateToken') + ->with( + $this->isInstanceOf(StorageApiTokenAuth::class), + $expectedTokenValue, + ) + ->willReturn($token) + ; + + $authenticator->expects(self::once()) + ->method('authorizeToken') + ->with( + $this->isInstanceOf(StorageApiTokenAuth::class), + $token, + ) + ; + + return $authenticator; + } }