From f365c3ed1150ec4adc56ce41022f1e41970f92d3 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/6] 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 8494b96ff4bbd43296091516c73a404328397744 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/6] 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 2ac51709729de1d51eeeb34b4765e3d18faee91f 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/6] 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 e4a28e13c8e962775b660a6957d26bfa3eb84f1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Jodas?= Date: Tue, 27 Jan 2026 16:18:34 +0100 Subject: [PATCH 4/6] refactor(api-bundle): move token extraction logic to individual authenticators Simplify TokenAuthenticatorInterface by replacing getTokenHeader() and getAuthorizationHeader() with a single extractToken(Request) method. Each authenticator now handles its own token extraction logic, making the code cleaner and more flexible. --- .../src/Security/AttributeAuthenticator.php | 42 +---- .../ManageApiTokenAuthenticator.php | 12 +- .../StorageApiTokenAuthenticator.php | 24 ++- .../Security/TokenAuthenticatorInterface.php | 10 +- .../Security/AttributeAuthenticatorTest.php | 171 +++--------------- .../ManageApiTokenAuthenticatorTest.php | 16 +- .../StorageApiTokenAuthenticatorTest.php | 53 +++++- 7 files changed, 114 insertions(+), 214 deletions(-) diff --git a/libs/api-bundle/src/Security/AttributeAuthenticator.php b/libs/api-bundle/src/Security/AttributeAuthenticator.php index 5881187e7..1d11cb21d 100644 --- a/libs/api-bundle/src/Security/AttributeAuthenticator.php +++ b/libs/api-bundle/src/Security/AttributeAuthenticator.php @@ -18,7 +18,6 @@ use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException; use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; -use Symfony\Component\Security\Http\Authenticator\Passport\Passport; use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; class AttributeAuthenticator extends AbstractAuthenticator @@ -42,47 +41,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 diff --git a/libs/api-bundle/tests/Security/AttributeAuthenticatorTest.php b/libs/api-bundle/tests/Security/AttributeAuthenticatorTest.php index df4f0ff17..7f3016d22 100644 --- a/libs/api-bundle/tests/Security/AttributeAuthenticatorTest.php +++ b/libs/api-bundle/tests/Security/AttributeAuthenticatorTest.php @@ -82,7 +82,7 @@ public function __invoke(): void {} $authenticator = $this->createAuthenticator( $controller, [ - StorageApiTokenAuth::class => $this->createSuccessAuthenticator($token), + StorageApiTokenAuth::class => $this->createSuccessAuthenticator($token, $request), ], ); $passport = $authenticator->authenticate($request); @@ -100,111 +100,11 @@ public function __invoke(): void {} $request = $this->createControllerRequest($controller, []); - $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') - ; - - $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') + ->method('extractToken') + ->with($request) + ->willReturn(null) ; $authenticator = $this->createAuthenticator( @@ -215,7 +115,7 @@ public function __invoke(): void {} ); $this->expectException(CustomUserMessageAuthenticationException::class); - $this->expectExceptionMessage('Cannot use both "X-Auth-Token" and "Authorization" headers simultaneously'); + $this->expectExceptionMessage('Authentication token is missing'); $authenticator->authenticate($request); } @@ -235,7 +135,7 @@ public function __invoke(): void {} $authenticator = $this->createAuthenticator( $controller, [ - StorageApiTokenAuth::class => $this->createAuthenticatorWithFailingAuthentication('X-Auth-Token'), + StorageApiTokenAuth::class => $this->createAuthenticatorWithFailingAuthentication($request), ], ); @@ -263,7 +163,7 @@ public function __invoke(): void {} $controller, [ StorageApiTokenAuth::class => $this->createAuthenticatorWithFailingAuthorization( - 'X-Auth-Token', + $request, $token, ), ], @@ -292,19 +192,16 @@ public function __invoke(): void {} $failingAuthenticator = $this->createMock(TokenAuthenticatorInterface::class); $failingAuthenticator->expects(self::once()) - ->method('getTokenHeader') - ->willReturn('X-Other-Token') - ; - $failingAuthenticator->expects(self::once()) - ->method('getAuthorizationHeader') - ->willThrowException(new AuthenticationException('Authorization header not supported')) + ->method('extractToken') + ->with($request) + ->willReturn(null) ; $authenticator = $this->createAuthenticator( $controller, [ ManageApiTokenAuth::class => $failingAuthenticator, - StorageApiTokenAuth::class => $this->createSuccessAuthenticator($token), + StorageApiTokenAuth::class => $this->createSuccessAuthenticator($token, $request), ], ); $passport = $authenticator->authenticate($request); @@ -331,8 +228,8 @@ public function __invoke(): void {} $authenticator = $this->createAuthenticator( $controller, [ - ManageApiTokenAuth::class => $this->createAuthenticatorWithFailingAuthentication('X-Other-Token'), - StorageApiTokenAuth::class => $this->createSuccessAuthenticator($token), + ManageApiTokenAuth::class => $this->createAuthenticatorWithFailingAuthentication($request), + StorageApiTokenAuth::class => $this->createSuccessAuthenticator($token, $request), ], ); $passport = $authenticator->authenticate($request); @@ -361,10 +258,10 @@ public function __invoke(): void {} $controller, [ ManageApiTokenAuth::class => $this->createAuthenticatorWithFailingAuthorization( - 'X-Other-Token', + $request, $otherToken, ), - StorageApiTokenAuth::class => $this->createSuccessAuthenticator($token), + StorageApiTokenAuth::class => $this->createSuccessAuthenticator($token, $request), ], ); $passport = $authenticator->authenticate($request); @@ -409,17 +306,15 @@ private function createToken(string $userIdentifier): TokenInterface /** * @return TokenAuthenticatorInterface */ - private function createSuccessAuthenticator(TokenInterface $token): TokenAuthenticatorInterface - { + private function createSuccessAuthenticator( + TokenInterface $token, + ?Request $request = null, + ): TokenAuthenticatorInterface { $authenticator = $this->createMock(TokenAuthenticatorInterface::class); $authenticator->expects(self::once()) - ->method('getTokenHeader') - ->willReturn('X-Auth-Token') - ; - - $authenticator->expects(self::once()) - ->method('getAuthorizationHeader') - ->willThrowException(new AuthenticationException('Authorization header not supported')) + ->method('extractToken') + ->with($request ?? $this->anything()) + ->willReturn('token') ; $authenticator->expects(self::once()) @@ -445,16 +340,13 @@ private function createSuccessAuthenticator(TokenInterface $token): TokenAuthent /** * @return TokenAuthenticatorInterface */ - private function createAuthenticatorWithFailingAuthentication(string $tokenHeader): TokenAuthenticatorInterface + private function createAuthenticatorWithFailingAuthentication(Request $request): TokenAuthenticatorInterface { $authenticator = $this->createMock(TokenAuthenticatorInterface::class); $authenticator->expects(self::once()) - ->method('getTokenHeader') - ->willReturn($tokenHeader) - ; - $authenticator->expects(self::once()) - ->method('getAuthorizationHeader') - ->willThrowException(new AuthenticationException('Authorization header not supported')) + ->method('extractToken') + ->with($request) + ->willReturn('token') ; $authenticator->expects(self::once()) ->method('authenticateToken') @@ -469,17 +361,14 @@ private function createAuthenticatorWithFailingAuthentication(string $tokenHeade * @return TokenAuthenticatorInterface */ private function createAuthenticatorWithFailingAuthorization( - string $tokenHeader, + Request $request, TokenInterface $authenticatedToken, ): TokenAuthenticatorInterface { $authenticator = $this->createMock(TokenAuthenticatorInterface::class); $authenticator->expects(self::once()) - ->method('getTokenHeader') - ->willReturn($tokenHeader) - ; - $authenticator->expects(self::once()) - ->method('getAuthorizationHeader') - ->willThrowException(new AuthenticationException('Authorization header not supported')) + ->method('extractToken') + ->with($request) + ->willReturn('token') ; $authenticator->expects(self::once()) ->method('authenticateToken') diff --git a/libs/api-bundle/tests/Security/ManageApiToken/ManageApiTokenAuthenticatorTest.php b/libs/api-bundle/tests/Security/ManageApiToken/ManageApiTokenAuthenticatorTest.php index 251cd1639..2638df9fc 100644 --- a/libs/api-bundle/tests/Security/ManageApiToken/ManageApiTokenAuthenticatorTest.php +++ b/libs/api-bundle/tests/Security/ManageApiToken/ManageApiTokenAuthenticatorTest.php @@ -11,8 +11,8 @@ use Keboola\ApiBundle\Security\ManageApiToken\ManageApiTokenAuthenticator; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Security\Core\Exception\AccessDeniedException; -use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException; class ManageApiTokenAuthenticatorTest extends TestCase { @@ -317,24 +317,26 @@ public static function provideAuthorizeTokenExceptionsData(): Generator ]; } - public function testGetTokenHeader(): void + public function testExtractTokenFromHeader(): void { $authenticator = new ManageApiTokenAuthenticator( $this->createMock(ManageApiClientFactory::class), ); - self::assertSame('X-KBC-ManageApiToken', $authenticator->getTokenHeader()); + $request = Request::create('https://keboola.com'); + $request->headers->set('X-KBC-ManageApiToken', 'my-manage-token'); + + self::assertSame('my-manage-token', $authenticator->extractToken($request)); } - public function testGetAuthorizationHeaderThrowsException(): void + public function testExtractTokenReturnsNullWhenNoHeader(): void { $authenticator = new ManageApiTokenAuthenticator( $this->createMock(ManageApiClientFactory::class), ); - $this->expectException(CustomUserMessageAuthenticationException::class); - $this->expectExceptionMessage('Authorization header is not supported for Manage API tokens'); + $request = Request::create('https://keboola.com'); - $authenticator->getAuthorizationHeader(); + self::assertNull($authenticator->extractToken($request)); } } diff --git a/libs/api-bundle/tests/Security/StorageApiToken/StorageApiTokenAuthenticatorTest.php b/libs/api-bundle/tests/Security/StorageApiToken/StorageApiTokenAuthenticatorTest.php index 382dfbc51..ee7d59944 100644 --- a/libs/api-bundle/tests/Security/StorageApiToken/StorageApiTokenAuthenticatorTest.php +++ b/libs/api-bundle/tests/Security/StorageApiToken/StorageApiTokenAuthenticatorTest.php @@ -111,23 +111,68 @@ public static function provideExceptionData(): Generator ]; } - public function testGetTokenHeader(): void + public function testExtractTokenFromPrimaryHeader(): void { $authenticator = new StorageApiTokenAuthenticator( $this->createMock(StorageClientRequestFactory::class), new RequestStack(), ); - self::assertSame('X-StorageApi-Token', $authenticator->getTokenHeader()); + $request = Request::create('https://keboola.com'); + $request->headers->set('X-StorageApi-Token', 'my-token'); + + self::assertSame('my-token', $authenticator->extractToken($request)); + } + + public function testExtractTokenFromBearerHeader(): void + { + $authenticator = new StorageApiTokenAuthenticator( + $this->createMock(StorageClientRequestFactory::class), + new RequestStack(), + ); + + $request = Request::create('https://keboola.com'); + $request->headers->set('Authorization', 'Bearer my-bearer-token'); + + self::assertSame('my-bearer-token', $authenticator->extractToken($request)); + } + + public function testExtractTokenFromAuthorizationHeaderWithoutBearer(): void + { + $authenticator = new StorageApiTokenAuthenticator( + $this->createMock(StorageClientRequestFactory::class), + new RequestStack(), + ); + + $request = Request::create('https://keboola.com'); + $request->headers->set('Authorization', 'some-token-without-bearer'); + + self::assertSame('some-token-without-bearer', $authenticator->extractToken($request)); } - public function testGetAuthorizationHeader(): void + public function testExtractTokenPrefersStorageApiTokenHeader(): void { $authenticator = new StorageApiTokenAuthenticator( $this->createMock(StorageClientRequestFactory::class), new RequestStack(), ); - self::assertSame('Authorization', $authenticator->getAuthorizationHeader()); + $request = Request::create('https://keboola.com'); + $request->headers->set('X-StorageApi-Token', 'primary-token'); + $request->headers->set('Authorization', 'Bearer bearer-token'); + + self::assertSame('primary-token', $authenticator->extractToken($request)); + } + + public function testExtractTokenReturnsNullWhenNoHeader(): void + { + $authenticator = new StorageApiTokenAuthenticator( + $this->createMock(StorageClientRequestFactory::class), + new RequestStack(), + ); + + $request = Request::create('https://keboola.com'); + + self::assertNull($authenticator->extractToken($request)); } } From bf1d46b4c9fe49e84152b3a6d43e56a054ce5680 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Jodas?= Date: Tue, 27 Jan 2026 16:45:07 +0100 Subject: [PATCH 5/6] chore(api-bundle): update storage-api-php-client-branch-wrapper to stable version --- 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 918076035..c458e1c4a 100644 --- a/libs/api-bundle/composer.json +++ b/libs/api-bundle/composer.json @@ -7,7 +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", + "keboola/storage-api-php-client-branch-wrapper": "^6.7", "monolog/monolog": "^2.0|^3.0", "symfony/dependency-injection": "^6.0|^7.0", "symfony/monolog-bundle": "^3.8", From a5d35c516bc31bc144aa58d9c591cedcf5cacc03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Jodas?= Date: Tue, 27 Jan 2026 21:37:20 +0100 Subject: [PATCH 6/6] fix(api-bundle): prefer Authorization header over X-StorageApi-Token --- .../StorageApiToken/StorageApiTokenAuthenticator.php | 11 +++-------- .../StorageApiTokenAuthenticatorTest.php | 6 +++--- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/libs/api-bundle/src/Security/StorageApiToken/StorageApiTokenAuthenticator.php b/libs/api-bundle/src/Security/StorageApiToken/StorageApiTokenAuthenticator.php index c80907b76..f8bf3b636 100644 --- a/libs/api-bundle/src/Security/StorageApiToken/StorageApiTokenAuthenticator.php +++ b/libs/api-bundle/src/Security/StorageApiToken/StorageApiTokenAuthenticator.php @@ -28,13 +28,7 @@ public function __construct( public function extractToken(Request $request): ?string { - // Check primary header first - $token = $request->headers->get('X-StorageApi-Token'); - if ($token !== null) { - return $token; - } - - // Check Authorization header + // Check Authorization header first $authHeader = $request->headers->get('Authorization'); if ($authHeader !== null) { // Validate it's a Bearer token and strip prefix @@ -44,7 +38,8 @@ public function extractToken(Request $request): ?string return $authHeader; } - return null; + // Check X-StorageApi-Token header + return $request->headers->get('X-StorageApi-Token'); } public function authenticateToken(AuthAttributeInterface $authAttribute, string $token): StorageApiToken diff --git a/libs/api-bundle/tests/Security/StorageApiToken/StorageApiTokenAuthenticatorTest.php b/libs/api-bundle/tests/Security/StorageApiToken/StorageApiTokenAuthenticatorTest.php index ee7d59944..1051f0593 100644 --- a/libs/api-bundle/tests/Security/StorageApiToken/StorageApiTokenAuthenticatorTest.php +++ b/libs/api-bundle/tests/Security/StorageApiToken/StorageApiTokenAuthenticatorTest.php @@ -150,7 +150,7 @@ public function testExtractTokenFromAuthorizationHeaderWithoutBearer(): void self::assertSame('some-token-without-bearer', $authenticator->extractToken($request)); } - public function testExtractTokenPrefersStorageApiTokenHeader(): void + public function testExtractTokenPrefersAuthorizationHeader(): void { $authenticator = new StorageApiTokenAuthenticator( $this->createMock(StorageClientRequestFactory::class), @@ -158,10 +158,10 @@ public function testExtractTokenPrefersStorageApiTokenHeader(): void ); $request = Request::create('https://keboola.com'); - $request->headers->set('X-StorageApi-Token', 'primary-token'); + $request->headers->set('X-StorageApi-Token', 'storage-token'); $request->headers->set('Authorization', 'Bearer bearer-token'); - self::assertSame('primary-token', $authenticator->extractToken($request)); + self::assertSame('bearer-token', $authenticator->extractToken($request)); } public function testExtractTokenReturnsNullWhenNoHeader(): void