Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

API Token Scopes

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Each ApiToken has an array of scopes, though we're not using that yet. The idea is cool: when a token is created, you can select which permissions it has. Like maybe a token gives the permission to create new treasures but not edit existing treasures. To allow that, we're going to map the scopes of a token to roles in Symfony.

How are Roles Loaded Now?

Right now in ApiTokenHandler, we're basically returning the user... and then the system authenticates fully as that user. This means we get whatever roles are on that User object. How could we change that so that we authenticate as this user... but with a different set of roles? A set based on the scopes from the token?

We're using the access_token security system. Hit Shift+Shift and open a core class called AccessTokenAuthenticator. This is cool: it's the actual code behind that authentication system! For example, this is where it grabs the token off of the request and calls our token handler's getUserBadgeFrom() method.

The roles the user will have are also determined here: down inside createToken(). The "token" is, sort of, a "wrapper" around the User object in the security system. And this is where we pass it the roles it should have. As you can see, no matter what, the roles will be $passport->getUser()->getRoles(). In other words, we always get the roles by calling getRoles() on the User class... which just returns the roles property.

Setting up the Custom Roles System

So there's no great hook point. We could create a custom authenticator class and implement our own createToken() method. But that's a bummer because we would need to completely reimplement the logic form this authenticator class. So, instead we can... kind of cheat.

Start in User. Scroll up to the top where we have our properties. Add a new one: private ?array called $accessTokenScopes and initialize it to null:

... lines 1 - 38
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
... lines 41 - 54
/* Scopes given during API authentication */
private ?array $accessTokenScopes = null;
... lines 57 - 248
}

Notice that this is not a persisted column. It's just a place to temporarily store the scopes that the user should have. Next, down at the bottom add a new public method called markAsTokenAuthenticated() with an array $scopes argument. We're going to call this during authentication. Inside, say $this->accessTokenScopes = $scopes:

... lines 1 - 38
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
... lines 41 - 244
public function markAsTokenAuthenticated(array $scopes)
{
$this->accessTokenScopes = $scopes;
}
}

Here's where things get interesting. Search for the getRoles() method. We know that, no matter what, Symfony will call this during authentication and whatever this returns, that's the roles the user will have. We're going to "sneak in" our scope roles.

First if the $accessTokenScopes property is null, that means we're logging in as a normal user. In this case, set $roles to $this->roles so that we get all the $roles on the User. Then add an extra role called ROLE_FULL_USER:

... lines 1 - 38
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
... lines 41 - 113
public function getRoles(): array
{
if (null === $this->accessTokenScopes) {
// logged in via the full user mechanism
$roles = $this->roles;
$roles[] = 'ROLE_FULL_USER';
} else {
... line 121
}
... lines 123 - 127
}
... lines 129 - 248
}

We'll talk about that in a minute.

Else, if we did log in via an access token, say $roles = $this->accessTokenScopes:

... lines 1 - 38
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
... lines 41 - 113
public function getRoles(): array
{
if (null === $this->accessTokenScopes) {
// logged in via the full user mechanism
$roles = $this->roles;
$roles[] = 'ROLE_FULL_USER';
} else {
$roles = $this->accessTokenScopes;
}
... lines 123 - 127
}
... lines 129 - 248
}

And, in both cases, make sure that we always have ROLE_USER:

... lines 1 - 38
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
... lines 41 - 113
public function getRoles(): array
{
if (null === $this->accessTokenScopes) {
// logged in via the full user mechanism
$roles = $this->roles;
$roles[] = 'ROLE_FULL_USER';
} else {
$roles = $this->accessTokenScopes;
}
// guarantee every user at least has ROLE_USER
$roles[] = 'ROLE_USER';
return array_unique($roles);
}
... lines 129 - 248
}

With this in place, head over to ApiTokenHandler. Right before we return UserBadge, add $token->getOwnedBy()->markAsTokenAuthenticated() and pass $token->getScopes():

... lines 1 - 10
class ApiTokenHandler implements AccessTokenHandlerInterface
{
... lines 13 - 16
public function getUserBadgeFrom(#[\SensitiveParameter] string $accessToken): UserBadge
{
... lines 19 - 28
$token->getOwnedBy()->markAsTokenAuthenticated($token->getScopes());
return new UserBadge($token->getOwnedBy()->getUserIdentifier());
}
}

Done! Let's take it for a test drive! Back over on Swagger, it already has our API token... so we can just re-execute the request. Cool: we see the Authorization header. Did it authenticate with the correct scopes?

Click to open the profiler for that request... and head down to "Security". It did! Look: we're logged in as that user, but with ROLE_USER, ROLE_USER_EDIT and ROLE_TREASURE_CREATE: the two scopes from the token. But if we were to log in via the login form, instead of these scopes, we would have whatever roles the user normally has, plus ROLE_FULL_USER.

Giving Normal Users sudo Access with role_hierarchy

In the next chapter, we'll use these roles to protect different API operations. For example, to use the POST treasures endpoint, we'll require ROLE_TREASURE_CREATE. But we also need to make sure that if a user logs in via the login form, they can still use this operation, even though they won't have that exact role. That is where ROLE_FULL_USER comes in handy.

Open config/packages/security.yaml and, anywhere, add role_hierarchy... I recommend spelling it correctly. Say ROLE_FULL_USER. So, if you're logged in as a full user, we're going to give you all possible scopes that a token could have. Copy the three scope roles: ROLE_USER_EDIT, ROLE_TREASURE_CREATE and ROLE_TREASURE_EDIT:

security:
... lines 2 - 12
role_hierarchy:
ROLE_FULL_USER: [ROLE_USER_EDIT, ROLE_TREASURE_CREATE, ROLE_TREASURE_EDIT]
... lines 15 - 56

We do need to be careful to make sure that if we add more scopes, we add them here too.

Thanks to this, if we protect something by requiring ROLE_USER_EDIT, users that are logged in via the login form will have access.

Ok team, we are done with authentication! Woo! Next, let's start into "authorization" by learning how to lock down operations so that only certain users can access them.

Leave a comment!

0
Login or Register to join the conversation
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
    }
}