๐Ÿ›  Build and Secure a simple Symfony API server using Auth0

๐Ÿ“˜ In this post, you will learn how to build a Symfony API server from scratch. You will also take it a step further to secure some of the endpoints. You'll use Auth0 to handle authentication and authorization.

Prerequisites

To follow along with this tutorial, you should have reasonable knowledge of Object-Oriented Programming in PHP and basic knowledge of building applications with Symfony. You will also need the following:

  • Composer globally installed on your computer to manage dependencies
  • Symfony CLI installed on your computer. Follow the instructions here to set it up for your operating system.
  • An Auth0 account. You can sign up for a free Auth0 account here.
  • Angular CLI globally installed on your computer. Please note that you don't have to be proficient in building applications with Angular. It is only required here to test the API that we will build.

What You'll Build

You will build a simple Symfony API server with three different endpoints. Each endpoint will return different types of messages depending on the access the user has.

Public endpoint

  • GET /api/messages/public

This endpoint should be exposed to anyone unauthorized. It is expected to return the following message:

{
  "message": "The API doesn't require an access token to share this message."
}

Protected endpoint

  • GET /api/messages/protected

This endpoint will be protected against unauthorized access. Only authorized users with a valid access token in their HTTP request header will be able to see the following message:

{
  "message": "The API successfully validated your access token."
}

Admin endpoint

  • GET /api/messages/admin

Similar to the protected endpoint, this requires the access token to contain a read:admin-messages permission to access the admin data. This is often referred to as Role-Based Access Control (RBAC).

Getting Started

Here you will start building the Symfony API by setting up and installing a new Symfony application and its required dependencies.

Scaffolding the Symfony application

To begin, open your terminal, navigate to your preferred development directory, and issue the following command to scaffold a new project using Composer:

composer create-project symfony/website-skeleton api-symfony-server

Once the installation process is completed, switch to the new directory you just created:

cd api-symfony-server
cp .env .env.local

This file is ignored by Git as it matches an existing pattern in .gitignore (which Symfony generated). One of the benefits of this file is that it helps to store your credentials outside of code to keep them safe.

Next, update the DATABASE_URL parameter in .env.local so that the app uses an SQLite database instead of the PostgreSQL default. To do that, comment out the existing DATABASE_URL entry and uncomment the SQLite option so that it matches the example below.

DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"

NOTE: The database will be created in the var directory in the project's root directory and be named data.db.

Running the application

Make sure you're in the main project directory and using the Symfony CLI, start the application with the following command:

symfony serve

Navigate to http://localhost:8000 to view the default homepage of the new Symfony application:

Building the API

In this section, we will focus on creating controllers that will handle the logic for each endpoint mentioned earlier. We will start with the public endpoint and gradually proceed to handle other endpoints.

Stop the application from running using CTRL + C and then hit Enter

Create controllers and configure each endpoint

Start by issuing the following command from the terminal within the root directory of your project to create a Controller:

php bin/console make:controller APIController

You will see the following output:

created: src/Controller/APIController.php
 created: templates/api/index.html.twig

  Success!

 Next: Open your new controller class and add some pages!

Locate the newly created controller in src/Controller/APIController.php and update its content with the following:

// src/Controller/APIController.php
<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

/**
 * @Route("/api/messages")
 */
class APIController extends AbstractController
{
    /**
     * @Route("/public", name="public")
     */
    public function publicAction()
    : JsonResponse
    {
        return $this->json(["message" => "The API doesn't require an access token to share this message."], Response::HTTP_OK);
    }

    /**
     * @Route("/protected", name="protected")
     */
    public function protectedAction()
    : JsonResponse
    {
        return $this->json(["message" => "The API successfully validated your access token."], Response::HTTP_OK);
    }

    /**
     * @Route("/admin", name="admin")
     */
    public function adminAction(): JsonResponse
    {
        return $this->json(["message" => "The API successfully recognized you as an admin."], Response::HTTP_OK);
    }
}

From the snippet above, this controller contains three different methods:

  • publicAction()
  • protectedAction()
  • adminAction()

Each is designed to handle /public, /protected, and /admin endpoints and returns the appropriate messages, respectively.

Back in the terminal, start the application again with symfony serve and open up an API testing tool such as Postman to test each endpoint.

Start with the public endpoint. Create a new GET request to this endpoint http://localhost:8000/api/messages/public. You will get the message The API doesn't require an access token to share this message, as shown below:

Next, try out the protected endpoint on http://localhost:8000/api/messages/protected.

And lastly, the admin endpoint on http://localhost:8000/api/messages/admin will give you the message The API successfully recognized you as an admin.:

At the moment, all the created endpoints can be accessed by anyone. Of course, this is not what we want. You need to ensure that /protected and /admin endpoints are exposed to authorized users only. You will start the configuration in the next section.

If you haven't yet, make sure you create a free Auth0 account now.

Securing protected and admin endpoints

You will use Auth0 to secure the endpoints (protected and admin). To do that, you will need to head back to your Auth0 dashboard and configure an API.

To begin, navigate to the API section of your Auth0 management dashboard by clicking "Applications"> "APIs". If you have created any APIs before, this will show you the list of all APIs for your account, but for this tutorial, go ahead and click on the "CREATE API" button and set up a new one.

Provide a friendly name such as Symfony API Server for the API and set its identifier to https://localhost:8000. You are free to use any name and identifier, but if you want to follow this tutorial exactly, you should maintain the values above. Leave the signing algorithm as RS256 and click on the "Create" button to proceed. You will need the values from here later in the tutorial.

Install dependencies and configure authentication

To secure the GET /api/messages/protected and GET /api/messages/admin endpoints you will use the JWT authentication bundle for Symfony named auth0/jwt-auth-bundle.

Stop the application from running using CTRL + C and run the following command to install the bundle using composer:

composer require auth0/jwt-auth-bundle:"~4.0"

After installing the bundle in your project, you should find a new file located at config/packages/jwt_auth.yaml. If not, create the file and paste the following content in it:

jwt_auth:
  domain: "%env(AUTH0_DOMAIN)%"
  client_id: "%env(AUTH0_CLIENT_ID)%"
  audience: "%env(AUTH0_AUDIENCE)%"

Earlier, when you created an API, Auth0 also automatically created a test application for your API. This will be the Auth0 application that will hold your users. You can find this by clicking Applications > Applications, then selecting the Test Application from the list that matches what you named your API. If you named it the same as in this tutorial, it will be "Symfony API Server (Test Application)". You can also select and use any other applications for your account. But for this tutorial, click on the test application, and you will see a page as shown here:

Open .env.local file and update the values of the environment variables below:

CLIENT_ORIGIN_URL=http://localhost:4040
AUTH0_AUDIENCE=http://localhost:8000
AUTH0_DOMAIN=YOUR_AUTH0_DOMAIN
AUTH0_CLIENT_ID=YOUR_AUTH0_ID
AUTH0_CLIENT_SECRET=YOUR_AUTH0_CLIENT_SECRET

Ensure to replace YOUR_AUTH0_DOMAIN, YOUR_AUTH0_CLIENT_ID, and YOUR_AUTH0_CLIENT_SECRET placeholders with the appropriate values as obtained from your Auth0 Dashboard.

Setting up User and User provider

Handling authentication and authorization requires Auth0 to be aware of the currently authenticated user. This is the job of a User provider in Symfony, as it helps to reload a user from the session and load the user for other specific features like using username or email for authentication.

If users of our API were stored in the database, creating a custom user provider might not be necessary, but here, we will load users from a custom location (Auth0), hence the need to create one.

To begin with, navigate to the src folder and create a new folder named Security and within the newly created folder, create another one and call it User. Next, create the user class within the User folder and name it WebServiceUser.php. Open the newly created file and paste the following code into it:

<?php

namespace App\Security\User;

use Symfony\Component\Security\Core\User\EquatableInterface;
use Symfony\Component\Security\Core\User\UserInterface;

class WebServiceUser implements
    UserInterface, EquatableInterface {

    private $roles;
    private $jwt;

    public function __construct($jwt, $roles) {

        $this->roles = $roles;
        $this->jwt = $jwt;
    }

    /**
     * @inheritDoc
     */
    public function getRoles()
    : array {

        return $this->roles;
    }

    /**
     * @inheritDoc
     */
    public function getPassword()
    : ?string {

        return null;
    }

    /**
     * @inheritDoc
     */
    public function getSalt()
    : ?string {

        return null;
    }

    public function isEqualTo(UserInterface $user)
    : bool {

        if (!$user instanceof WebServiceUser) {
            return false;
        }

        return $this->getUsername() === $user->getUsername();
    }

    /**
     * @inheritDoc
     */
    public function getUsername() {

        return $this->jwt["email"] ?? $this->jwt["sub"];
    }

    /**
     * @inheritDoc
     */
    public function eraseCredentials() {
    }

    public function getUserIdentifier() {
        return $this->jwt["email"] ?? $this->jwt["sub"];
    }
}

Here, the WebServiceUser class implements two different interfaces:

  • UserInterface โ€” represents the interface that all User classes must implement
  • EquatableInterface โ€” used to test if two objects are equal in security and re-authentication context

Next, create a file in the User folder and name it WebServiceAnonymousUser.php. This will return the anonymous user. Use the following content for it:

<?php

namespace App\Security\User;

class WebServiceAnonymousUser extends WebServiceUser {

    public function __construct() {

        parent::__construct(null, ['IS_AUTHENTICATED_ANONYMOUSLY']);
    }

    public function getUsername() {

        return null;
    }
}

To wrap thing up, create another file within the User folder and name it WebServiceUserProvider.php. Once you are done, paste the following code in it:

<?php

namespace App\Security\User;

use Auth0\JWTAuthBundle\Security\Core\JWTUserProviderInterface;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Polyfill\Intl\Icu\Exception\NotImplementedException;


class WebServiceUserProvider implements JWTUserProviderInterface {

    public function loadUserByJWT($jwt)
    : WebServiceUser {
        $data = ['sub' => $jwt->sub];
        $roles = [];
        $roles[] = 'ROLE_OAUTH_AUTHENTICATED';

        return new WebServiceUser($data, $roles);
    }

    public function getAnonymousUser()
    : WebServiceAnonymousUser {

        return new WebServiceAnonymousUser();
    }

    public function loadUserByUsername($username) {

        throw new NotImplementedException('method not implemented');
    }

    public function refreshUser(UserInterface $user) {

        if (!$user instanceof WebServiceUser) {
            throw new UnsupportedUserException(
                sprintf('Instances of "%s" are not supported.', get_class($user))
            );
        }

        return $this->loadUserByUsername($user->getUsername());
    }

    public function supportsClass($class)
    : bool {

        return $class === 'App\Security\User\WebServiceUser';
    }

    public function loadUserByIdentifier(string $identifier)
    {
        throw new NotImplementedException('method not implemented');
    }
}

This class implements the JWTUserProviderInterface from the Auth0 bundle installed earlier, which specifies the important methods that the WebServiceUserProvider class must implement. These methods are:

  • loadUserByJWT: it receives the decoded JWT Access Token and returns a User.
  • getAnonymousUser: returns an anonymous user that represents an unauthenticated one (usually represented by the role IS_AUTHENTICATED_ANONYMOUSLY)

14