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", diff --git a/libs/api-bundle/src/Security/AttributeAuthenticator.php b/libs/api-bundle/src/Security/AttributeAuthenticator.php index 87b5dd062..e49beeefe 100644 --- a/libs/api-bundle/src/Security/AttributeAuthenticator.php +++ b/libs/api-bundle/src/Security/AttributeAuthenticator.php @@ -42,14 +42,10 @@ 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 = $authenticator->extractToken($request); if ($token === null) { - $error = new CustomUserMessageAuthenticationException(sprintf( - 'Authentication header "%s" is missing', - $tokenHeader, - )); + $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 4adc618e2..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,9 +23,9 @@ public function __construct( ) { } - public function getTokenHeader(): string + public function extractToken(Request $request): ?string { - return 'X-KBC-ManageApiToken'; + 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 f4ceca986..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,9 +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; + } + + // 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 76e496493..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,7 +13,10 @@ */ interface TokenAuthenticatorInterface { - public function getTokenHeader(): string; + /** + * Extract token from request. Returns null if no valid token header found. + */ + 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 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) 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()); + } }