Symfony 6 and JWT bundles: Refresh token
Tags: php, symfony, security, jwt
Published: 2022-11-12 07:07:47

<small>\* The cover image is originally by [geralt](https://pixabay.com/users/geralt-9301/) and edited with great appreciation.</small>

---

## Summary

Are you interested in JSON Web Token (JWT) authentication and authorization in [PHP](https://www.php.net/) or [Symfony](https://symfony.com/), one of its frameworks?
<span id="my-previous-post"></span>
If so, this post might be helpful:

{% post nabbisen/symfony-6-and-lexikjwtauthenticationbundle-2-json-web-token-jwt-auth-41mp %}

Well, lifetime of each access token should be short within practical term in order to mitigate risk on impersonation.
However, when access token is expired, what should we do? Request authentication information to users, again? It must be inconvenient in many cases, mustn't it 😅

That's where refresh token steps in.
This post shows how to implement it in Symfony with [JWTRefreshTokenBundle](https://github.com/markitosgv/JWTRefreshTokenBundle).
Here we go.

### Environment

- [Alpine Linux](https://www.alpinelinux.org/) [3.16](https://wiki.alpinelinux.org/wiki/Release_Notes_for_Alpine_3.16.0) (on [Docker](https://www.docker.com/))
- PHP [8.1](https://www.php.net/releases/8.1/)
- Symfony [6.1](https://symfony.com/releases/6.1)
- [LexikJWTAuthenticationBundle](https://symfony.com/bundles/LexikJWTAuthenticationBundle/2.x/index.html) 2
- JWTRefreshTokenBundle 1.1
- [MariaDB](https://mariadb.org/) [10.9](https://mariadb.com/kb/en/mariadb-server-109/)

---

## Tutorial

### Overview

Remember you need install LexikJWTAuthenticationBundle and configure your app beforehand.

The steps here are as follows:

1. Install the bundle
2. PHP 8 specific operation (currently)
3. Update database
4. Configure
5. Testing

---

### 1. Install the bundle

[JWTRefreshTokenBundle](https://github.com/markitosgv/JWTRefreshTokenBundle) is almost in your hand with [composer](`https://getcomposer.org/`). Run:

```console
$ composer require gesdinet/jwt-refresh-token-bundle
```

The output started with:

```console
Info from https://repo.packagist.org: #StandWithUkraine
Using version ^1.1 for gesdinet/jwt-refresh-token-bundle
./composer.json has been updated
Running composer update gesdinet/jwt-refresh-token-bundle
Loading composer repositories with package information
Updating dependencies
Lock file operations: 1 install, 0 updates, 0 removals
  - Locking gesdinet/jwt-refresh-token-bundle (v1.1.1)
Writing lock file
Installing dependencies from lock file (including require-dev)
Package operations: 1 install, 0 updates, 0 removals
  - Downloading gesdinet/jwt-refresh-token-bundle (v1.1.1)
  - Installing gesdinet/jwt-refresh-token-bundle (v1.1.1): Extracting archive
Generating optimized autoload files
116 packages you are using are looking for funding.
Use the `composer fund` command to find out more!
```

Then it was followed by:

```console
Symfony operations: 1 recipe (44a1f19720c3d647b7a54653d52ca981)
  -  WARNING  gesdinet/jwt-refresh-token-bundle (>=1.0): From github.com/symfony/recipes-contrib:main
    The recipe for this package comes from the "contrib" repository, which is open to community contributions.
    Review the recipe at https://github.com/symfony/recipes-contrib/tree/main/gesdinet/jwt-refresh-token-bundle/1.0
```

Read the warning carefully and enter "y" to continue:

```console
    Do you want to execute this recipe?
    [y] Yes
    [n] No
    [a] Yes for all packages, only for the current installation session
    [p] Yes permanently, never ask again for this project
    (defaults to n): y
```

The rest was:

```console
  - Configuring gesdinet/jwt-refresh-token-bundle (>=1.0): From github.com/symfony/recipes-contrib:main
Executing script cache:clear [OK]
Executing script assets:install public [OK]
              
 What's next? 
              

Some files have been created and/or updated to configure your new packages.
Please review, edit and commit them: these files are yours.

No security vulnerability advisories found
```

### 2. Adapt it to PHP 8 (currently)

Well, there was a problem. It was unfamiliar with PHP 8 and Symfony 6 by default, because it uses [annotations](https://wiki.php.net/rfc/annotations). However, what was required without Symfony flex was [attributes](https://www.php.net/manual/language.attributes.overview.php).
To fix it, edit `src/Entity/RefreshToken.php`:

```diff
- /**
-  * @ORM\Entity
-  * @ORM\Table("refresh_tokens")
-  */
+ #[ORM\Entity]
+ #[ORM\Table(name: 'refresh_token')]
```

Then run:

```console
$ composer install
```

### 3. Update database

You are perhaps familiar with these command lines. Run them:

```console
$ php bin/console make:migration
$ php bin/console doctrine:migrations:migrate
```

### 4. Configure routes and firewalls for refresh tokens

Edit `config/routes.yaml`

```diff
  # ...
  jwt_auth:
      path: /auth
+ jwt_refresh:
+     path: /auth/refresh
```

Then edit `config/packages/security.yaml`

```diff
  security:
      # ...
      firewalls:
          # ...
          jwt_auth:
              pattern: ^/auth
              stateless: true
              json_login:
                  check_path: jwt_auth
                  success_handler: lexik_jwt_authentication.handler.authentication_success
                  failure_handler: lexik_jwt_authentication.handler.authentication_failure
          api:
              pattern: ^/api
              stateless: true
              jwt: ~
+             refresh_jwt:
+                 check_path: jwt_refresh
          # ...
     # Note: Only the *first* access control that matches will be used
     access_control:
         # ...
         - { path: ^/auth, roles: PUBLIC_ACCESS }
         - { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
```

In addition, alternatively, you may want to integrate API routes of both auth and use:

```yaml
# config/routes.yaml
jwt_auth:
    path: /api/auth
jwt_refresh:
    path: /api/auth/refresh
```

+

```yaml
# config/packages/security.yaml
security:
    # ...
    firewalls:
        # ...
        api:
            pattern: ^/api
            stateless: true
            json_login:
                check_path: jwt_auth
                success_handler: lexik_jwt_authentication.handler.authentication_success
                failure_handler: lexik_jwt_authentication.handler.authentication_failure
            jwt: ~
            entry_point: jwt
            refresh_jwt:
                check_path: jwt_refresh
    # ...
    access_control:
        - { path: ^/api/auth, roles: PUBLIC_ACCESS }
        - { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
```

That's it!

### 5. Let's play: API access with JWT

Just as [my previous post](#my-previous-post), connect to /auth with [curl](https://curl.se/) to get token:

```console
$ curl -X POST \
      -H "Content-Type: application/json" \
      -d '{"username":"your-username","password":"your-password"}' \
      https://your-domain/auth
```

You will get refresh token as well as access token.

```json
{"token":"xxx.xxx.xxx","refresh_token":"xxx"}
```

Gotcha 😎
Let's try a few with the refresh token!

(1) Case of missing 😩

```console
$ curl -X POST \
      https://(your-domain)/auth/refresh
```

```json
{"code":401,"message":"Missing JWT Refresh Token"}
```

(2) Case of an invalid one 😩

```console
$ curl -X POST \
      -d refresh_token="wrong-value" \
      https://(your-domain)/auth/refresh
```

```json
{"code":401,"message":"JWT Refresh Token Not Found"}
```

(3) Case of the valid one 😃

```console
$ curl -X POST \
      -d refresh_token="xxx" \
      https://(your-domain)/auth/refresh
```

```json
{"token":"xxx.xxx.xxx","refresh_token":"xxx"}
```

Yay, here come new tokens of yours 🚀 🎫🎫