<?php
namespace App\Security;
use League\OAuth2\Server\Exception\OAuthServerException;
use League\OAuth2\Server\ResourceServer;
use Lexik\Bundle\JWTAuthenticationBundle\Security\Guard\JWTTokenAuthenticator;
use Psr\Log\LoggerInterface;
use Symfony\Bridge\PsrHttpMessage\HttpMessageFactoryInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
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;
/**
* Single API authenticator for "Authorization: Bearer" tokens. Tries league/oauth2-server
* access tokens first, then falls back to lexik JWT tokens (kept for backward compatibility
* with existing app builds). Both token types are JWTs on the same header, so handling them in
* one authenticator avoids the multi-authenticator firewall behaviour where a later failing
* authenticator overrides an earlier success.
*/
class OAuth2BearerAuthenticator extends AbstractAuthenticator
{
public function __construct(
private HttpMessageFactoryInterface $httpMessageFactory,
private ResourceServer $resourceServer,
private UserProviderInterface $userProvider,
private JWTTokenAuthenticator $lexikAuthenticator,
private ?LoggerInterface $logger = null
) {
}
public function supports(Request $request): ?bool
{
return str_starts_with($request->headers->get('Authorization', ''), 'Bearer ');
}
public function authenticate(Request $request): Passport
{
try {
$psrRequest = $this->resourceServer->validateAuthenticatedRequest($this->httpMessageFactory->createRequest($request));
$userIdentifier = (string) $psrRequest->getAttribute('oauth_user_id', '');
return new SelfValidatingPassport(new UserBadge($userIdentifier, function (string $id): UserInterface {
return $this->userProvider->loadUserByIdentifier($id);
}));
} catch (OAuthServerException $e) {
$this->logger?->debug('Not an OAuth2 access token, falling back to lexik JWT.', ['message' => $e->getMessage()]);
}
// Fall back to lexik JWT: delegates token extraction, decoding and user loading.
$preAuthToken = $this->lexikAuthenticator->getCredentials($request);
if (null === $preAuthToken) {
throw new CustomUserMessageAuthenticationException('No token provided.');
}
$user = $this->lexikAuthenticator->getUser($preAuthToken, $this->userProvider);
return new SelfValidatingPassport(new UserBadge($user->getUsername(), fn (): UserInterface => $user));
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return null;
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
// Let the firewall entry point produce the 401.
return null;
}
}