Search code examples
phpsymfonyjwtsymfony-securitysymfony6

Cannot authenticate user in Symfony 6.2 using AccessToken


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.


Solution

  • 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! :)