Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Token Types & The ApiToken Entity

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.

Okay, so what if you need to allow programmatic access to your API?

The Types of Access Tokens

When you talk to an API via code, you send an API token, commonly known as an access token:

fetch('/api/kittens', {
    headers: {
        'Authorization': 'Bearer THE-ACCESS-TOKEN',
    }
});

Exactly how you get that token will vary. But there are two main cases.

First, as a user on the site, like a dragon, you want to generate an API token so that you can personally use it in a script you're writing. This is like a GitHub personal access token. These are literally created via a web interface. We're going to show this.

The second main use case is when a third party wants to make requests to your API on behalf of a user of your system. Like some new site called DragonTreasureOrganizer.com wants to be able to make an API request to our API on behalf of some of our users - like it will fetch the treasure's for a user and display them artfully on their site. In this situation, instead of our users generating tokens manually and then... like... entering them into that site, you'll offer OAuth. OAuth is basically a mechanism for normal users to securely give access tokens for their account to a third party. And so, your site, or somewhere in your infrastructure you'll have an OAuth server.

That's beyond the scope of this tutorial. But the important thing is that after OAuth is done, the API client wll end up with, you guessed it, an API token! So no matter which journey you're in, if you're doing programmatic access, your API users will end up with an access token. And then your job will be to read and understand that. We'll do exactly that.

JWT vs Database Storage?

So as I mentioned, we're going to show a system where we allow users to generate their own access tokens. So how do we do that? Again, there are two main ways. Death by choices!

The first is to generate something called a JSON Web Token or JWT. The cool thing about JWTs are that no database storage is needed. They're special strings that actually contain info inside of them. For example, you could create a JWT string that includes the user id and some scopes.

One downside of JWTs are that there's no easy way to "log out"... because there's no out-of-the-box way to invalidate JWTs. You give them an expiration when you create them... but then they're valid until then... no matter what, unless you add some extra complexity... which kinda defeats the purpose.

JWT's are trendy, popular and fun! But... you may not need them. They're awesome when you have a single sign-on system because, if that JWT is used to authenticate with multiple systems or APIs, each API can validate the JWT all on their own: without needing to make an API request to a central authentication system.

So you might end up using JWTs and there's a great bundle for them called LexikJWTAuthenticationBundle. JWT's are also the type of access token that OpenID gives you in the end.

Instead of JWTs, the second main option is dead simple: generate a random token string and store it in the database. This also allows you to invalidate access tokens by... just deleting them! This is what we'll do.

Generating the Entity

So let's get to work. To store API tokens, we need a new entity! Find your terminal and run:

php ./bin/console make:entity

And let's call it ApiToken. Say no to making this an API resource. In theory, you could allow users to authenticate via a login form or HTTP basic and then send a POST request to create API tokens if you want to... but we won't.

Add an ownedBy property. This is going to be a ManyToOne to User and not nullable. And I'll say "yes" to the inverse. So the idea is that every User can have many API tokens. When an API token is used, we want to know which User it's related to. We'll use that during authentication. Calling the property apiTokens is fine and say no to orphan removal. Next property: expiresAt, make that a datetime_immutable and I'll say yes to nullable. Maybe we allow tokens to never expire by leaving this field blank. Next up is token, which will be a string. I'm going to set the length to 68 - we'll see why in a minute - not nullable. And finally, add a scopes property as a json type. This is going to be kind of cool: we'll store an array of "permissions" that this API token should have. Say, not nullable on that one as well. Hit enter to finish.

All right, spin over to your editor. No surprises: that created an ApiToken entity... and there's nothing very interesting inside of it:

... lines 1 - 2
namespace App\Entity;
use App\Repository\ApiTokenRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ApiTokenRepository::class)]
class ApiToken
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'apiTokens')]
#[ORM\JoinColumn(nullable: false)]
private ?User $ownedBy = null;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $expiresAt = null;
#[ORM\Column(length: 68)]
private string $token = null;
#[ORM\Column]
private array $scopes = [];
... lines 28 - 80
}

So let's go over and make the migration for it:

symfony console make:migration

Spin over and peek at that file to make sure it looks good. Yup! It creates the api_token table:

... lines 1 - 12
final class Version20230209183006 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SEQUENCE api_token_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE TABLE api_token (id INT NOT NULL, owned_by_id INT NOT NULL, expires_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, token VARCHAR(68) NOT NULL, scopes JSON NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_7BA2F5EB5E70BCD7 ON api_token (owned_by_id)');
$this->addSql('COMMENT ON COLUMN api_token.expires_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE api_token ADD CONSTRAINT FK_7BA2F5EB5E70BCD7 FOREIGN KEY (owned_by_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('DROP SEQUENCE api_token_id_seq CASCADE');
$this->addSql('ALTER TABLE api_token DROP CONSTRAINT FK_7BA2F5EB5E70BCD7');
$this->addSql('DROP TABLE api_token');
}
}

Run that with:

symfony console doctrine:migrations:migrate

And... awesome! Next: let's add a way to generate the random token string. Then, we'll talk about scopes and load up our fixtures with some API tokens.

Leave a comment!

6
Login or Register to join the conversation
MildDisaster Avatar
MildDisaster Avatar MildDisaster | posted 19 days ago

I'm curious in this example, why you have potentially one user - many tokens relationship?
Wouldn't one user - one token suffice ?

Reply

Hey @MildDisaster ,

That's how GitHub access tokens, you can create many tokens on GitHub for your account. First of all, you can create different tokens with different scopes, like some tokens may have access to the private repos while others - only public ones. That's the most common and flexible structure I think :) Moreover, you can use one token e.g. for Composer and another token for your website that sends API requests to GitHub. And it's convenient this way, as you can e.g. remove that second token but the first one will still work.

But of course it depends on your personal use case, and if you're happy with OneToOne relation - go for it, there's nothing wrong with it, just some limitations I mentioned above :)

Cheers!

1 Reply
MildDisaster Avatar
MildDisaster Avatar MildDisaster | Victor | posted 19 days ago | edited

I think I understand. So the relationship is more (but not exclusively) between tokens and apps ( that a user will use to access api with ) ?

What is the preferred way to handle tokens once an associated account is changed, ie (password recovered) ?

Should one invalidate existing tokens, or just let them be ?

I noticed in the symfonycasts github there are projects for account registration and password recovery. Without double checking, don't think there was mention of token cleanup in them.

Thanks !

Reply

Hey @MildDisaster ,

I think I understand. So the relationship is more (but not exclusively) between tokens and apps ( that a user will use to access api with ) ?

Mostly yes, I suppose, but once again, it depends on your specific use case. But from my experience it seems more like this :)

What is the preferred way to handle tokens once an associated account is changed, ie (password recovered) ?

Good question. I think it also depends on your specific use case and your strategy. I don't think GitHub invalidate all tokens if you changed password... but in some cases this might be a good idea. It depends on how much your users will be annoyed by it :)

Yeah, we support those bundles. And yeah, probably it's a good use case when you have to clean up password reset tokens on password change :)

Cheers!

1 Reply
Someswara-rao-J Avatar
Someswara-rao-J Avatar Someswara-rao-J | posted 4 months ago

How to authenticate jwt token string (First Way to make token). I have done react login page. Successfully logged in, But not getting Authenticated.

Reply

Hey @Someswara-rao-J!

Hmm. Did you create an access token authenticator like we did? https://symfonycasts.com/screencast/api-platform-security/access-token-authenticator

If so, here is what I would do to debug:

A) After making the AJAX request to "log in", find the Symfony profiler for that request and open it (reminder: you can always go to /_profiler to see a list of the most recent requests to your site - and you can find the AJAX request you just made there. When you get to the profiler for that request, click on the "Security" tab. Does it show you as authenticated?

B) If it does NOT show you as authenticated, then look more closely at your access token authenticator system: make sure your ApiTokenHandler is being called, etc. If it DOES show that you are authenticated... but then future AJAX requests appear to be not authenticated, it means that you are "losing" authentication between requests.

There are a few things that could cause this:

1) If you have stateless: true on your firewall, this will happen. This is because this tells your firewall to NOT use session/cookie storage.
2) If your frontend and backend are on different subdomains this could happen. For example, if your API is api.example.com and your frontend is just frontend.example.com, by default, Symfony will create a cookie for api.example.com and (iirc) that will not be usable by your frontend. You can adjust your cookie domain in the Symfony settings if this is the cause
3) In some situations, if you've added some custom serialization logic to your User class, you can "lose" authentication on the 2nd request. If you check the logs on the first request after the successful login, you should see something like:

Cannot refresh token because user has changed

Let me know if this helps!

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