Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions libs/api-bundle/src/Security/AttributeAuthenticator.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,16 @@ public function authenticate(Request $request): SelfValidatingPassport
$authenticator = $this->authenticators->get($authAttribute->getName());
assert($authenticator instanceof TokenAuthenticatorInterface);

$tokenHeader = $authenticator->getTokenHeader();
$token = $request->headers->get($tokenHeader);
$tokenHeaders = $authenticator instanceof MultiHeaderTokenAuthenticatorInterface
? $authenticator->getTokenHeaders()
: [$authenticator->getTokenHeader()];

$token = $this->getTokenFromHeaders($request, $tokenHeaders);

if ($token === null) {
$error = new CustomUserMessageAuthenticationException(sprintf(
'Authentication header "%s" is missing',
$tokenHeader,
implode('" or "', $tokenHeaders),
));
continue;
}
Expand Down Expand Up @@ -125,4 +128,21 @@ private function getControllerAuthAttributes(Request $request): array
ReflectionAttribute::IS_INSTANCEOF,
);
}

/**
* @param list<string> $tokenHeaders
*/
private function getTokenFromHeaders(Request $request, array $tokenHeaders): ?string
{
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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace Keboola\ApiBundle\Security;

/**
* Extension of TokenAuthenticatorInterface that supports multiple authentication headers.
* When an authenticator implements this interface, the AttributeAuthenticator will try
* each header in order until it finds a non-null value.
*
* @template TokenType of TokenInterface
* @extends TokenAuthenticatorInterface<TokenType>
*/
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<string>
*/
public function getTokenHeaders(): array;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -15,9 +15,9 @@
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;

/**
* @implements TokenAuthenticatorInterface<StorageApiToken>
* @implements MultiHeaderTokenAuthenticatorInterface<StorageApiToken>
*/
class StorageApiTokenAuthenticator implements TokenAuthenticatorInterface
class StorageApiTokenAuthenticator implements MultiHeaderTokenAuthenticatorInterface
{
public function __construct(
private readonly StorageClientRequestFactory $clientRequestFactory,
Expand All @@ -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);
Expand Down
179 changes: 179 additions & 0 deletions libs/api-bundle/tests/Security/AttributeAuthenticatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<TokenInterface>
*/
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;
}
}