I'm building an API application with Symfony 6.2 and trying to use Access Tokens for stateless authentication. As suggested in symfony docs I have implemented AccessTokenHandler class
<?php
# src\Security\AccessTokenHandler.php
namespace App\Security;
use App\Repository\AccessTokenRepository;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
class AccessTokenHandler implements AccessTokenHandlerInterface {
public function __construct( private AccessTokenRepository $repository
) {
}
public function getUserBadgeFrom( string $accessToken ): UserBadge {
// e.g. query the "access token" database to search for this token
$accessToken = $this->repository->findValidToken($accessToken);
if ( NULL === $accessToken || !$accessToken->isValid() ) {
throw new BadCredentialsException( 'Invalid credentials.' );
}
/* DEBUG: UNCOMMENT FOR DEBUG
dump($accessToken);die;
/* DEBUG /**/
// and return a UserBadge object containing the user identifier from the found token
return new UserBadge( $accessToken->getUserId() );
}
}
and configured my security.yaml
file
security:
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
access_token_provider:
entity:
class: App\Entity\AccessToken
property: tokenValue
firewalls:
main:
lazy: true
provider: access_token_provider
stateless: true
pattern: ^/api
access_token:
token_extractors: header
token_handler: App\Security\AccessTokenHandler
access_control:
- { path: ^/api, roles: ROLE_ADMIN }
In the database there is the only user in User
table and related token in AccessToken
table.
To test the above I'm using Postman to send a GET request to https://my-host/api/test-page
with the following header:
Authorization: Bearer 59e4fb3f9c10e0fe570d486462ab417a
It gets into AccessTokenHandler::getUserBadgeFrom as expected and returns the correct token with user (see the commented debug code on AccessTokenHandler:23).
Yet, the request returns status code 401 Unauthorized
with WWW-Authenticate: Bearer error="invalid_token",error_description="Invalid credentials."
header.
It took me a while to figure out what the issue was. The above configuration is almost correct; however, a Symfony security component was missing a proper user provider to retrieve a User record even though it had the correct identifier (AccessToken).
So I had to do following steps to get the things working:
Step #1: Implement AccessTokenUserProvider
class as follows:
<?php
# src\Security\AccessTokenUserProvider.php
namespace App\Security;
use App\Entity\User;
use App\Repository\UserRepository;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
class AccessTokenUserProvider implements UserProviderInterface
{
private $userRepository;
public function __construct(UserRepository $userRepository)
{
$this->userRepository = $userRepository;
}
public function loadUserByIdentifier(string $identifier): UserInterface
{
$user = $this->userRepository->findOneByAccessToken($identifier);
if (!$user) {
throw new UserNotFoundException();
}
return $user;
}
public function refreshUser(UserInterface $user)
{
return $this->loadUserByIdentifier($user->getUserIdentifier());
}
public function supportsClass($class)
{
return $class === User::class;
}
}
Step #2: Register user provider in services.yaml
services:
# ....
app.access_token_user_provider:
class: App\Security\AccessTokenUserProvider
arguments: ['@App\Repository\UserRepository']
Step #3: and finally configure its usage in security.yaml
:
security:
# ....
providers:
access_token_provider:
id: app.access_token_user_provider
# ....
firewalls:
api:
lazy: true
provider: access_token_provider
stateless: true
pattern: ^/api
access_token:
token_extractors: header
token_handler: App\Security\AccessTokenHandler
And this is it! Now Symfony uses our AccessTokenUserProvider to load corresponding user record from the database.
I hope this will save time for anyone who follows the same path! :)