From 88276c5f835d33a98d26fc2d72167c6719d47d89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Jodas?= Date: Fri, 23 Jan 2026 12:34:44 +0100 Subject: [PATCH 1/4] feat(api-bundle): add Bearer token authentication for Storage API --- .../ManageApiTokenAuthenticator.php | 7 ++++++ .../StorageApiTokenAuthenticator.php | 5 +++++ .../Security/TokenAuthenticatorInterface.php | 8 +++++++ .../ManageApiTokenAuthenticatorTest.php | 22 +++++++++++++++++++ .../StorageApiTokenAuthenticatorTest.php | 20 +++++++++++++++++ 5 files changed, 62 insertions(+) diff --git a/libs/api-bundle/src/Security/ManageApiToken/ManageApiTokenAuthenticator.php b/libs/api-bundle/src/Security/ManageApiToken/ManageApiTokenAuthenticator.php index 4adc618e2..742cacbd2 100644 --- a/libs/api-bundle/src/Security/ManageApiToken/ManageApiTokenAuthenticator.php +++ b/libs/api-bundle/src/Security/ManageApiToken/ManageApiTokenAuthenticator.php @@ -27,6 +27,13 @@ public function getTokenHeader(): string return 'X-KBC-ManageApiToken'; } + public function getAuthorizationHeader(): string + { + throw new CustomUserMessageAuthenticationException( + 'Authorization header is not supported for Manage API tokens', + ); + } + public function authenticateToken(AuthAttributeInterface $authAttribute, string $token): ManageApiToken { assert($authAttribute instanceof ManageApiTokenAuth); diff --git a/libs/api-bundle/src/Security/StorageApiToken/StorageApiTokenAuthenticator.php b/libs/api-bundle/src/Security/StorageApiToken/StorageApiTokenAuthenticator.php index f4ceca986..912991f38 100644 --- a/libs/api-bundle/src/Security/StorageApiToken/StorageApiTokenAuthenticator.php +++ b/libs/api-bundle/src/Security/StorageApiToken/StorageApiTokenAuthenticator.php @@ -30,6 +30,11 @@ public function getTokenHeader(): string return 'X-StorageApi-Token'; } + public function getAuthorizationHeader(): string + { + return 'Authorization'; + } + public function authenticateToken(AuthAttributeInterface $authAttribute, string $token): StorageApiToken { assert($authAttribute instanceof StorageApiTokenAuth); diff --git a/libs/api-bundle/src/Security/TokenAuthenticatorInterface.php b/libs/api-bundle/src/Security/TokenAuthenticatorInterface.php index 76e496493..22c077086 100644 --- a/libs/api-bundle/src/Security/TokenAuthenticatorInterface.php +++ b/libs/api-bundle/src/Security/TokenAuthenticatorInterface.php @@ -14,6 +14,14 @@ interface TokenAuthenticatorInterface { public function getTokenHeader(): string; + /** + * Returns the Authorization header name if the authenticator supports + * extracting tokens from the Authorization header, or null if not supported. + * + * @throws AuthenticationException if the Authorization header is not supported + */ + public function getAuthorizationHeader(): string; + /** * @return TokenType * @throws AuthenticationException diff --git a/libs/api-bundle/tests/Security/ManageApiToken/ManageApiTokenAuthenticatorTest.php b/libs/api-bundle/tests/Security/ManageApiToken/ManageApiTokenAuthenticatorTest.php index f08deac2b..251cd1639 100644 --- a/libs/api-bundle/tests/Security/ManageApiToken/ManageApiTokenAuthenticatorTest.php +++ b/libs/api-bundle/tests/Security/ManageApiToken/ManageApiTokenAuthenticatorTest.php @@ -12,6 +12,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Symfony\Component\Security\Core\Exception\AccessDeniedException; +use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException; class ManageApiTokenAuthenticatorTest extends TestCase { @@ -315,4 +316,25 @@ public static function provideAuthorizeTokenExceptionsData(): Generator 'Authentication token must not be super admin', ]; } + + public function testGetTokenHeader(): void + { + $authenticator = new ManageApiTokenAuthenticator( + $this->createMock(ManageApiClientFactory::class), + ); + + self::assertSame('X-KBC-ManageApiToken', $authenticator->getTokenHeader()); + } + + public function testGetAuthorizationHeaderThrowsException(): void + { + $authenticator = new ManageApiTokenAuthenticator( + $this->createMock(ManageApiClientFactory::class), + ); + + $this->expectException(CustomUserMessageAuthenticationException::class); + $this->expectExceptionMessage('Authorization header is not supported for Manage API tokens'); + + $authenticator->getAuthorizationHeader(); + } } diff --git a/libs/api-bundle/tests/Security/StorageApiToken/StorageApiTokenAuthenticatorTest.php b/libs/api-bundle/tests/Security/StorageApiToken/StorageApiTokenAuthenticatorTest.php index 0b304d623..382dfbc51 100644 --- a/libs/api-bundle/tests/Security/StorageApiToken/StorageApiTokenAuthenticatorTest.php +++ b/libs/api-bundle/tests/Security/StorageApiToken/StorageApiTokenAuthenticatorTest.php @@ -110,4 +110,24 @@ public static function provideExceptionData(): Generator 'expectedExceptionCode' => 500, ]; } + + public function testGetTokenHeader(): void + { + $authenticator = new StorageApiTokenAuthenticator( + $this->createMock(StorageClientRequestFactory::class), + new RequestStack(), + ); + + self::assertSame('X-StorageApi-Token', $authenticator->getTokenHeader()); + } + + public function testGetAuthorizationHeader(): void + { + $authenticator = new StorageApiTokenAuthenticator( + $this->createMock(StorageClientRequestFactory::class), + new RequestStack(), + ); + + self::assertSame('Authorization', $authenticator->getAuthorizationHeader()); + } } From 42e031248c41ca3a166261b03d49c68f74524d91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Jodas?= Date: Fri, 23 Jan 2026 12:37:18 +0100 Subject: [PATCH 2/4] feat(api-bundle): implement Bearer token support in AttributeAuthenticator --- .../src/Security/AttributeAuthenticator.php | 39 +++++- .../Security/AttributeAuthenticatorTest.php | 118 +++++++++++++++++- 2 files changed, 153 insertions(+), 4 deletions(-) diff --git a/libs/api-bundle/src/Security/AttributeAuthenticator.php b/libs/api-bundle/src/Security/AttributeAuthenticator.php index 87b5dd062..5881187e7 100644 --- a/libs/api-bundle/src/Security/AttributeAuthenticator.php +++ b/libs/api-bundle/src/Security/AttributeAuthenticator.php @@ -43,16 +43,49 @@ public function authenticate(Request $request): SelfValidatingPassport assert($authenticator instanceof TokenAuthenticatorInterface); $tokenHeader = $authenticator->getTokenHeader(); - $token = $request->headers->get($tokenHeader); + $hasPrimaryHeader = $request->headers->has($tokenHeader); - if ($token === null) { + // Try to get authorization header name + try { + $authorizationHeaderName = $authenticator->getAuthorizationHeader(); + $hasAuthorizationHeader = $request->headers->has($authorizationHeaderName); + } catch (AuthenticationException $e) { + $authorizationHeaderName = null; + $hasAuthorizationHeader = false; + } + + // Check if both headers are present + if ($hasPrimaryHeader && $hasAuthorizationHeader) { $error = new CustomUserMessageAuthenticationException(sprintf( - 'Authentication header "%s" is missing', + 'Cannot use both "%s" and "%s" headers simultaneously', $tokenHeader, + $authorizationHeaderName, )); continue; } + // Get token from primary header or Authorization header + $token = null; + if ($hasPrimaryHeader) { + $token = $request->headers->get($tokenHeader); + } elseif ($hasAuthorizationHeader) { + assert($authorizationHeaderName !== null); + $token = $request->headers->get($authorizationHeaderName); + } + + if ($token === null) { + $errorMessage = $authorizationHeaderName !== null + ? sprintf( + 'Authentication header "%s" or "%s: Bearer" is missing', + $tokenHeader, + $authorizationHeaderName, + ) + : sprintf('Authentication header "%s" is missing', $tokenHeader); + + $error = new CustomUserMessageAuthenticationException($errorMessage); + continue; + } + $authAttributeInstance = $authAttribute->newInstance(); try { diff --git a/libs/api-bundle/tests/Security/AttributeAuthenticatorTest.php b/libs/api-bundle/tests/Security/AttributeAuthenticatorTest.php index d27cac778..df4f0ff17 100644 --- a/libs/api-bundle/tests/Security/AttributeAuthenticatorTest.php +++ b/libs/api-bundle/tests/Security/AttributeAuthenticatorTest.php @@ -107,6 +107,105 @@ public function __invoke(): void {} ->method('getTokenHeader') ->willReturn('X-Auth-Token') ; + $tokenAuthenticator->expects(self::once()) + ->method('getAuthorizationHeader') + ->willReturn('Authorization') + ; + + $authenticator = $this->createAuthenticator( + $controller, + [ + StorageApiTokenAuth::class => $tokenAuthenticator, + ], + ); + + $this->expectException(CustomUserMessageAuthenticationException::class); + $this->expectExceptionMessage('Authentication header "X-Auth-Token" or "Authorization: Bearer" is missing'); + + $authenticator->authenticate($request); + } + + public function testAuthenticateRequestWithBearerToken(): void + { + $controller = new + #[StorageApiTokenAuth(['foo-feature'])] + class { + public function __invoke(): void {} + }; + + $request = $this->createControllerRequest($controller, [ + 'Authorization' => 'Bearer my-bearer-token', + ]); + + $token = $this->createToken('user-id'); + + $tokenAuthenticator = $this->createMock(TokenAuthenticatorInterface::class); + $tokenAuthenticator->expects(self::once()) + ->method('getTokenHeader') + ->willReturn('X-Auth-Token') + ; + + $tokenAuthenticator->expects(self::once()) + ->method('getAuthorizationHeader') + ->willReturn('Authorization') + ; + + $tokenAuthenticator->expects(self::once()) + ->method('authenticateToken') + ->with( + $this->isInstanceOf(StorageApiTokenAuth::class), + 'Bearer my-bearer-token', + ) + ->willReturn($token) + ; + + $tokenAuthenticator->expects(self::once()) + ->method('authorizeToken') + ->with( + $this->isInstanceOf(StorageApiTokenAuth::class), + $token, + ) + ; + + $authenticator = $this->createAuthenticator( + $controller, + [ + StorageApiTokenAuth::class => $tokenAuthenticator, + ], + ); + + $passport = $authenticator->authenticate($request); + + self::assertSame($token, $passport->getUser()); + } + + public function testAuthenticateRequestWithBothHeadersThrowsException(): void + { + $controller = new + #[StorageApiTokenAuth(['foo-feature'])] + class { + public function __invoke(): void {} + }; + + $request = $this->createControllerRequest($controller, [ + 'X-Auth-Token' => 'primary-token', + 'Authorization' => 'Bearer bearer-token', + ]); + + $tokenAuthenticator = $this->createMock(TokenAuthenticatorInterface::class); + $tokenAuthenticator->expects(self::once()) + ->method('getTokenHeader') + ->willReturn('X-Auth-Token') + ; + + $tokenAuthenticator->expects(self::once()) + ->method('getAuthorizationHeader') + ->willReturn('Authorization') + ; + + $tokenAuthenticator->expects(self::never()) + ->method('authenticateToken') + ; $authenticator = $this->createAuthenticator( $controller, @@ -116,7 +215,7 @@ public function __invoke(): void {} ); $this->expectException(CustomUserMessageAuthenticationException::class); - $this->expectExceptionMessage('Authentication header "X-Auth-Token" is missing'); + $this->expectExceptionMessage('Cannot use both "X-Auth-Token" and "Authorization" headers simultaneously'); $authenticator->authenticate($request); } @@ -196,6 +295,10 @@ public function __invoke(): void {} ->method('getTokenHeader') ->willReturn('X-Other-Token') ; + $failingAuthenticator->expects(self::once()) + ->method('getAuthorizationHeader') + ->willThrowException(new AuthenticationException('Authorization header not supported')) + ; $authenticator = $this->createAuthenticator( $controller, @@ -314,6 +417,11 @@ private function createSuccessAuthenticator(TokenInterface $token): TokenAuthent ->willReturn('X-Auth-Token') ; + $authenticator->expects(self::once()) + ->method('getAuthorizationHeader') + ->willThrowException(new AuthenticationException('Authorization header not supported')) + ; + $authenticator->expects(self::once()) ->method('authenticateToken') ->with( @@ -344,6 +452,10 @@ private function createAuthenticatorWithFailingAuthentication(string $tokenHeade ->method('getTokenHeader') ->willReturn($tokenHeader) ; + $authenticator->expects(self::once()) + ->method('getAuthorizationHeader') + ->willThrowException(new AuthenticationException('Authorization header not supported')) + ; $authenticator->expects(self::once()) ->method('authenticateToken') ->willThrowException(new AuthenticationException('Token is not valid')) @@ -365,6 +477,10 @@ private function createAuthenticatorWithFailingAuthorization( ->method('getTokenHeader') ->willReturn($tokenHeader) ; + $authenticator->expects(self::once()) + ->method('getAuthorizationHeader') + ->willThrowException(new AuthenticationException('Authorization header not supported')) + ; $authenticator->expects(self::once()) ->method('authenticateToken') ->willReturn($authenticatedToken) From b16dfaae1d7e1b652d311ce9af7739fca46f5da8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Jodas?= Date: Fri, 23 Jan 2026 12:38:01 +0100 Subject: [PATCH 3/4] feat(api-bundle): move storage-api-client to require for Bearer token support --- libs/api-bundle/composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/api-bundle/composer.json b/libs/api-bundle/composer.json index d3465c78c..918076035 100644 --- a/libs/api-bundle/composer.json +++ b/libs/api-bundle/composer.json @@ -7,6 +7,7 @@ "cuyz/valinor-bundle": "^0.4", "keboola/permission-checker": "^2.0", "keboola/service-client": "^1.0", + "keboola/storage-api-php-client-branch-wrapper": "dev-devin/AJDA-2160-1768904619-bearer-token-auth", "monolog/monolog": "^2.0|^3.0", "symfony/dependency-injection": "^6.0|^7.0", "symfony/monolog-bundle": "^3.8", @@ -16,7 +17,6 @@ "keboola/api-error-control": "4.3", "keboola/coding-standard": "^15.0", "keboola/kbc-manage-api-php-client": "^v9.0", - "keboola/storage-api-php-client-branch-wrapper": "^6.1", "phpstan/phpstan": "^2.1", "phpstan/phpstan-phpunit": "^2.0", "phpstan/phpstan-symfony": "^2.0", From f51dfbf07f5a3059805cf584cd93ab65440a0fc9 Mon Sep 17 00:00:00 2001 From: Ondrej Popelka Date: Mon, 26 Jan 2026 15:02:52 +0100 Subject: [PATCH 4/4] wip --- .../src/Security/AttributeAuthenticator.php | 41 +------------------ .../ManageApiTokenAuthenticator.php | 12 ++---- .../StorageApiTokenAuthenticator.php | 24 ++++++++--- .../Security/TokenAuthenticatorInterface.php | 10 ++--- 4 files changed, 26 insertions(+), 61 deletions(-) diff --git a/libs/api-bundle/src/Security/AttributeAuthenticator.php b/libs/api-bundle/src/Security/AttributeAuthenticator.php index 5881187e7..e49beeefe 100644 --- a/libs/api-bundle/src/Security/AttributeAuthenticator.php +++ b/libs/api-bundle/src/Security/AttributeAuthenticator.php @@ -42,47 +42,10 @@ public function authenticate(Request $request): SelfValidatingPassport $authenticator = $this->authenticators->get($authAttribute->getName()); assert($authenticator instanceof TokenAuthenticatorInterface); - $tokenHeader = $authenticator->getTokenHeader(); - $hasPrimaryHeader = $request->headers->has($tokenHeader); - - // Try to get authorization header name - try { - $authorizationHeaderName = $authenticator->getAuthorizationHeader(); - $hasAuthorizationHeader = $request->headers->has($authorizationHeaderName); - } catch (AuthenticationException $e) { - $authorizationHeaderName = null; - $hasAuthorizationHeader = false; - } - - // Check if both headers are present - if ($hasPrimaryHeader && $hasAuthorizationHeader) { - $error = new CustomUserMessageAuthenticationException(sprintf( - 'Cannot use both "%s" and "%s" headers simultaneously', - $tokenHeader, - $authorizationHeaderName, - )); - continue; - } - - // Get token from primary header or Authorization header - $token = null; - if ($hasPrimaryHeader) { - $token = $request->headers->get($tokenHeader); - } elseif ($hasAuthorizationHeader) { - assert($authorizationHeaderName !== null); - $token = $request->headers->get($authorizationHeaderName); - } + $token = $authenticator->extractToken($request); if ($token === null) { - $errorMessage = $authorizationHeaderName !== null - ? sprintf( - 'Authentication header "%s" or "%s: Bearer" is missing', - $tokenHeader, - $authorizationHeaderName, - ) - : sprintf('Authentication header "%s" is missing', $tokenHeader); - - $error = new CustomUserMessageAuthenticationException($errorMessage); + $error = new CustomUserMessageAuthenticationException('Authentication token is missing'); continue; } diff --git a/libs/api-bundle/src/Security/ManageApiToken/ManageApiTokenAuthenticator.php b/libs/api-bundle/src/Security/ManageApiToken/ManageApiTokenAuthenticator.php index 742cacbd2..e55f2398b 100644 --- a/libs/api-bundle/src/Security/ManageApiToken/ManageApiTokenAuthenticator.php +++ b/libs/api-bundle/src/Security/ManageApiToken/ManageApiTokenAuthenticator.php @@ -9,6 +9,7 @@ use Keboola\ApiBundle\Security\TokenAuthenticatorInterface; use Keboola\ApiBundle\Security\TokenInterface; use Keboola\ManageApi\ClientException as ManageApiClientException; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Security\Core\Exception\AccessDeniedException; use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException; @@ -22,16 +23,9 @@ public function __construct( ) { } - public function getTokenHeader(): string + public function extractToken(Request $request): ?string { - return 'X-KBC-ManageApiToken'; - } - - public function getAuthorizationHeader(): string - { - throw new CustomUserMessageAuthenticationException( - 'Authorization header is not supported for Manage API tokens', - ); + return $request->headers->get('X-KBC-ManageApiToken'); } public function authenticateToken(AuthAttributeInterface $authAttribute, string $token): ManageApiToken diff --git a/libs/api-bundle/src/Security/StorageApiToken/StorageApiTokenAuthenticator.php b/libs/api-bundle/src/Security/StorageApiToken/StorageApiTokenAuthenticator.php index 912991f38..c80907b76 100644 --- a/libs/api-bundle/src/Security/StorageApiToken/StorageApiTokenAuthenticator.php +++ b/libs/api-bundle/src/Security/StorageApiToken/StorageApiTokenAuthenticator.php @@ -10,6 +10,7 @@ use Keboola\ApiBundle\Security\TokenInterface; use Keboola\StorageApi\ClientException; use Keboola\StorageApiBranch\Factory\StorageClientRequestFactory; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\Security\Core\Exception\AccessDeniedException; use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException; @@ -25,14 +26,25 @@ public function __construct( ) { } - public function getTokenHeader(): string + public function extractToken(Request $request): ?string { - return 'X-StorageApi-Token'; - } + // Check primary header first + $token = $request->headers->get('X-StorageApi-Token'); + if ($token !== null) { + return $token; + } - public function getAuthorizationHeader(): string - { - return 'Authorization'; + // Check Authorization header + $authHeader = $request->headers->get('Authorization'); + if ($authHeader !== null) { + // Validate it's a Bearer token and strip prefix + if (preg_match('/^Bearer\s+(.+)$/i', $authHeader, $matches)) { + return $matches[1]; + } + return $authHeader; + } + + return null; } public function authenticateToken(AuthAttributeInterface $authAttribute, string $token): StorageApiToken diff --git a/libs/api-bundle/src/Security/TokenAuthenticatorInterface.php b/libs/api-bundle/src/Security/TokenAuthenticatorInterface.php index 22c077086..58fc54818 100644 --- a/libs/api-bundle/src/Security/TokenAuthenticatorInterface.php +++ b/libs/api-bundle/src/Security/TokenAuthenticatorInterface.php @@ -5,6 +5,7 @@ namespace Keboola\ApiBundle\Security; use Keboola\ApiBundle\Attribute\AuthAttributeInterface; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Security\Core\Exception\AuthenticationException; /** @@ -12,15 +13,10 @@ */ interface TokenAuthenticatorInterface { - public function getTokenHeader(): string; - /** - * Returns the Authorization header name if the authenticator supports - * extracting tokens from the Authorization header, or null if not supported. - * - * @throws AuthenticationException if the Authorization header is not supported + * Extract token from request. Returns null if no valid token header found. */ - public function getAuthorizationHeader(): string; + public function extractToken(Request $request): ?string; /** * @return TokenType