Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Access Token Authenticator

Video not working?

It looks like your browser may not support the H264 codec. If you're using Linux, try a different browser or try installing the gstreamer0.10-ffmpeg gstreamer0.10-plugins-good packages.

Thanks! This saves us from needing to use Flash or encode videos in multiple formats. And that let's us get back to making more videos :). But as always, please feel free to message us.

To authenticate with a token, an API client will send an Authorization header set to the word Bearer then the token string... which is just a standard practice:

$client->request('GET', '/api/treasures', [
    'headers' => [
        'Authorization' => 'Bearer TOKEN',
    ],
]);

Then something in our app will read that header, make sure the token is valid, authenticate the user and throw a big party to celebrate.

Activating access_token

Fortunately, Symfony has the perfect system just for this! Spin over and open up config/packages/security.yaml. Anywhere under your firewall add access_token:

security:
... lines 2 - 11
firewalls:
... lines 13 - 15
main:
... lines 17 - 24
access_token:
... lines 26 - 52

This activates a listener that will watch every request to see if it has an Authorization header. If it does, it will read that and try to authenticate the user.

Though, it requires a helper class... because even though it knows where to find the token on the request... it has no idea what to do with! It doesn't know if it's a JWT that it should decode... or, in our case, that it can query the database for the matching record. So, to help it, add a token_handler option set to the id of a service we'll create: App\Security\ApiTokenHandler:

security:
... lines 2 - 11
firewalls:
... lines 13 - 15
main:
... lines 17 - 24
access_token:
token_handler: App\Security\ApiTokenHandler
... lines 27 - 52

Stateless Firewall

By the way, if your security system only allows authentication via an API token, then you don't need session storage. In that case, you can set a stateless: true flag that tells the security system that when a user authenticates, not to bother storing the user info in the session. I'm going to remove that, because we do have a way to log in that relies on the session.

The Token Handler Class

Ok, let's go create that handler class. In the src/ directory create a new sub-directory called Security/ and inside of that a new PHP class called ApiTokenHandler. This is a beautifully simple class. Make it implement AccessTokenHandlerInterface and then go to "Code"->"Generate" or Command+N on a Mac and select "Implement Methods" to generate the one we need: getUserBadgeFrom():

... lines 1 - 2
namespace App\Security;
use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
class ApiTokenHandler implements AccessTokenHandlerInterface
{
public function getUserBadgeFrom(#[\SensitiveParameter] string $accessToken): UserBadge
{
// TODO: Implement getUserBadgeFrom() method.
}
}

The access_token system knows how to find the token: it knows it will live on an Authorization header with the word Bearer in front of it. So it grabs that string then calls getUserBadgeFrom() and passes it to us. By the way this #[\SensitiveParameter] attribute is new feature in PHP. It's cool, but not important: it just makes sure that if an exception is thrown, this value won't be shown in the stacktrace.

Our job here is to query the database using the $accessToken and then return which user it relates to. To do that, we need the ApiTokenRepository! Add a construct method with a private ApiTokenRepository $apiTokenRepository argument:

... lines 1 - 4
use App\Repository\ApiTokenRepository;
... lines 6 - 9
class ApiTokenHandler implements AccessTokenHandlerInterface
{
public function __construct(private ApiTokenRepository $apiTokenRepository)
{
}
... lines 15 - 25
}

Below, say $token = $this->apiTokenRepository and then call findOneBy() passing it an array, so it will query where the token field equals $accessToken:

... lines 1 - 9
class ApiTokenHandler implements AccessTokenHandlerInterface
{
... lines 12 - 15
public function getUserBadgeFrom(#[\SensitiveParameter] string $accessToken): UserBadge
{
$token = $this->apiTokenRepository->findOneBy(['token' => $accessToken]);
... lines 19 - 24
}
}

If authentication should fail for any reason, we need to throw a type of security exception. For example, if the token doesn't exist, throw a new BadCredentialsException: the one from Symfony components:

... lines 1 - 5
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
... lines 7 - 9
class ApiTokenHandler implements AccessTokenHandlerInterface
{
... lines 12 - 15
public function getUserBadgeFrom(#[\SensitiveParameter] string $accessToken): UserBadge
{
$token = $this->apiTokenRepository->findOneBy(['token' => $accessToken]);
if (!$token) {
throw new BadCredentialsException();
}
... lines 23 - 24
}
}

That will cause authentication to fail... but we don't need to pass a message. This will return a "Bad Credentials." message to the user.

At this point, we have found the ApiToken entity. But, ultimately our security system wants to authenticate a user... not an "API Token". We do that by returning a UserBadge that, sort of, wraps the User object. Watch: return a new UserBadge(). The first argument is the "user identifier". Pass $token->getOwnedBy() to get the User and then ->getUserIdentifier():

... lines 1 - 7
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
class ApiTokenHandler implements AccessTokenHandlerInterface
{
... lines 12 - 15
public function getUserBadgeFrom(#[\SensitiveParameter] string $accessToken): UserBadge
{
... lines 18 - 23
return new UserBadge($token->getOwnedBy()->getUserIdentifier());
}
}

How the User Object is Loaded

Notice that we're not actually returning the User object. That's mostly because... we don't need to! Let me explain. Hold Command or Ctrl and click getUserIdentifier(). What this really returns is the user's email. So we're returning a UserBadge with the user's email inside. What happens next is the same thing that happens when we send an email to the json_login authentication endpoint. Symfony's security system takes that email and, because we have this user provider, it knows to query the database for a User with that email.

So it's going to query the database again for the User via the email... which is a bit unnecessary, but fine. If you want to avoid that, you could pass a callable to the second argument and return $token->getOwnedBy(). But this will work fine as it is.

Oh, and it's probably a good idea to check and make sure the token is valid! If not $token->isValid(), then we could throw another BadCredentialsException. But if you want to customize the message, you can also throw a new CustomUserMessageAuthenticationException with "Token expired" to return that message to the user:

... lines 1 - 6
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
... lines 8 - 10
class ApiTokenHandler implements AccessTokenHandlerInterface
{
... lines 13 - 16
public function getUserBadgeFrom(#[\SensitiveParameter] string $accessToken): UserBadge
{
... lines 19 - 24
if (!$token->isValid()) {
throw new CustomUserMessageAuthenticationException('Token expired');
}
return new UserBadge($token->getOwnedBy()->getUserIdentifier());
}
}

Using the Token in Swagger?

And... done! So... how do we try this? Well, ideally we could try it in our Swagger docs. I'm going to open a new tab... then log out. But I'll keep my original tab open... so I can steal these valid tokens!

Head to the API docs. How can we tell this interface to send an API token when it makes the requests? Well you may have noticed an "Authorize" button. But when we click it... it's empty! That's because we haven't, yet, told Open API how users are able to authenticate. Fortunately we can do this via API Platform.

Open up config/packages/api_platform.yaml. And a new key called swagger, though we're actually configuring the OpenAPI docs. To add a new way of authenticating, set api_keys to activate that type, then access_token... which can be anything you want. Below this, give this authentication mechanism a name... and type: header because we want to pass the token as a header:

api_platform:
... lines 2 - 7
swagger:
api_keys:
access_token:
name: Authorization
type: header
... lines 13 - 18

This will tell Swagger - via our OpenAPI docs - that we can send API tokens via the Authorization header. Now when we click the "Authorize" button... yea! It says "Name: Authorization", "In Header".

To use this, we need to start with the word Bearer then a space... because it doesn't fill that in for us. More on that in a minute. Let's first try an invalid token. Hit "Authorize". That didn't actually make any requests yet: it just stored the token in JavaScript.

Let's try the get treasure collection endpoint. When we execute... awesome! A 401! We don't need to be authenticated to use this endpoint, but because we passed an Authorization header with Bearer and then a token, the new access_token system caught that, passed the string to our handler... but then we couldn't find a matching token in the database, so we threw the BadCredentialsException

You can see it down here: the API returned an empty response, but with a header containing invalid_token and error_description: "Invalid credentials.".

Checking the Token Authentication is Working

So the bad case is working. Let's try the happy case! In the other tab, copy one of the valid tokens. Then slide back up, hit "Authorize", then "Log out". Logging out just means that it "forgets" the API token we set a minute ago. Re-type Bearer , paste, hit "Authorize", close... and let's go down and try this endpoint again. And... woohoo! A 200!

So it seems like that worked... but how can we tell? Whelp, down on the web debug toolbar, click to open the profiler for that request. On the Security tab... yes! We're logged in as Bernie. Success!

The only thing I don't like is needing to type that Bearer string in the authorization box. That's not super user-friendly. So next, let's fix that by learning how we can customize the OpenAPI spec document that Swagger uses.

Leave a comment!

2
Login or Register to join the conversation
Ange-B Avatar

Hi !

This example shows how to get User when JWT TOKENs are stored in database.

I'm using LexikJWTAuthenticationBundle. Users are not stored in database.
I want to retrieve Connected User when making request with the generated TOKEN.
How can i do this with class ApiTokenHandler ?

Please help me ! Thanks You

Reply

Hi @Ange-B,

Can you give more context? Where exactly do you want to get User info?

IIRC if you are using LexikJWTAuthenticationBundle it already stores the user info in symfony security system, so it can be easily accessed from anywhere, all you need is love autowire Security class from Security bundle and use $this->security->getUser() method to get current authenticated user

PS maybe I'm missing something so waiting for your feedback

Cheers!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=8.1",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^3.0", // v3.1.2
        "doctrine/annotations": "^2.0", // 2.0.1
        "doctrine/doctrine-bundle": "^2.8", // 2.8.3
        "doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.2
        "doctrine/orm": "^2.14", // 2.14.1
        "nelmio/cors-bundle": "^2.2", // 2.2.0
        "nesbot/carbon": "^2.64", // 2.66.0
        "phpdocumentor/reflection-docblock": "^5.3", // 5.3.0
        "phpstan/phpdoc-parser": "^1.15", // 1.16.1
        "symfony/asset": "6.2.*", // v6.2.5
        "symfony/console": "6.2.*", // v6.2.5
        "symfony/dotenv": "6.2.*", // v6.2.5
        "symfony/expression-language": "6.2.*", // v6.2.5
        "symfony/flex": "^2", // v2.2.4
        "symfony/framework-bundle": "6.2.*", // v6.2.5
        "symfony/property-access": "6.2.*", // v6.2.5
        "symfony/property-info": "6.2.*", // v6.2.5
        "symfony/runtime": "6.2.*", // v6.2.5
        "symfony/security-bundle": "6.2.*", // v6.2.6
        "symfony/serializer": "6.2.*", // v6.2.5
        "symfony/twig-bundle": "6.2.*", // v6.2.5
        "symfony/ux-react": "^2.6", // v2.7.1
        "symfony/ux-vue": "^2.7", // v2.7.1
        "symfony/validator": "6.2.*", // v6.2.5
        "symfony/webpack-encore-bundle": "^1.16", // v1.16.1
        "symfony/yaml": "6.2.*" // v6.2.5
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.2
        "mtdowling/jmespath.php": "^2.6", // 2.6.1
        "phpunit/phpunit": "^9.5", // 9.6.3
        "symfony/browser-kit": "6.2.*", // v6.2.5
        "symfony/css-selector": "6.2.*", // v6.2.5
        "symfony/debug-bundle": "6.2.*", // v6.2.5
        "symfony/maker-bundle": "^1.48", // v1.48.0
        "symfony/monolog-bundle": "^3.0", // v3.8.0
        "symfony/phpunit-bridge": "^6.2", // v6.2.5
        "symfony/stopwatch": "6.2.*", // v6.2.5
        "symfony/web-profiler-bundle": "6.2.*", // v6.2.5
        "zenstruck/browser": "^1.2", // v1.2.0
        "zenstruck/foundry": "^1.26" // v1.28.0
    }
}
userVoice