Integrating Keycloak with API Platform

Integrating Keycloak with API Platform

Securing your API endpoints is very important to prevent unauthorized access to your systems. But how do you do this efficiently? Are you going to build a user management system from scratch? Let me introduce you to Keycloak and how to integrate it with API Platform.

What is Keycloak?

Keycloak is an open source server for identity and access management. It has a lot of awesome features which saves us, developers, from building user management tooling and login and registration systems.

I’ll list a few of the key features of Keycloak:

  • Single Sign On
    Log in once and access multiple applications.
  • Social Login
    Keycloak makes it very simple to log in using Google, Facebook, Twitter and many more providers.
  • SAML, OIDC (OpenID Connect) and OAuth 2 support
    Keycloak being built on these authorization and authentication standards makes it very easy to integrate Keycloak in your applications.
  • Account management portal
    The built in account management portal makes it fast and easy for your users to change their password or set up two factor authentication.

For the complete list of features check out https://www.keycloak.org/docs/latest/server_admin/.

Why use Keycloak with API Platform?

Now why would we want to use Keycloak with API Platform? Good question.

First of all, it saves us a lot of time, since we won’t have to worry about user management. Secondly, Keycloak is open source and built on top of a lot of standards for user management and identity brokering. It’s fully customizable, you can change the look and feel using custom themes for Keycloak. It’s very easy to set up – just run the docker container with a few parameters and you’re done!

This makes it the perfect candidate to use Keycloak with API Platform when you want to build something that requires your users to sign up.

Let’s start by installing and configuring Keycloak.

Configure Keycloak

Let’s add Keycloak to our development environment using the provided docker-compose.yml files of the API Platform distribution.

version: "3.4"

services:
  # Removed the default services for brevity
  
  keycloak_db:
    image: mariadb:latest
    environment:
      - MARIADB_ROOT_PASSWORD=YOURROOTPASSWORD
      - MARIADB_DATABASE=keycloak
    volumes:
      - keycloak_db_data:/var/lib/mysql:rw
  
  keycloak:
    image: quay.io/keycloak/keycloak:latest
    ports:
      - target: 8080
        published: 8080
        protocol: tcp
      - target: 8443
        published: 8443
        protocol: tcp
    environment:
      DB_VENDOR: mariadb
      DB_ADDR: keycloak_db
      DB_DATABASE: keycloak
      DB_USER: root
      DB_PASSWORD: YOURROOTPASSWORD
      KEYCLOAK_USER: admin
      KEYCLOAK_PASSWORD: YOURKEYCLOAKADMINPASSWORD
      PROXY_ADDRESS_FORWARDING: "true"
    depends_on:
      - keycloak_db

volumes:
  php_socket:
  caddy_data:
  caddy_config:
  keycloak_db_data:
  db_data:

Code language: YAML (yaml)

We’ve added 2 services. An extra instance of a database service for Keycloak, in our case MariaDB, and a service for Keycloak itself. We’ve configured Keycloak to connect to the database we’ve added previously.

Now when you run docker-compose up it should create the necessary containers and Keycloak should start.

Creating a realm

After confirming Keycloak is running you can navigate to http://localhost:8080 and you should be greeted by this screen.

Keycloak homepage

Click on ‘Administration Console’ and login using admin and the password you specified in the docker-compose.yaml file.

Now you should see the following screen.

Keycloak administration panel

Hover over the word ‘Master’ in the menu and click the ‘Add realm’ button that appears.

A realm manages a set of users, credentials, roles, and groups. A user belongs to and logs into a realm. Realms are isolated from one another and can only manage and authenticate the users that they control.

https://www.keycloak.org/docs/latest/server_admin/

Give your realm a name and click on ‘Create’. Congratulations, you’ve created your first realm in Keycloak!

Now you have the possibility to enable or disable user registration and much more. Check out the different tabs on the page.

Creating a client

Now we need to create a client for our webapp. I say for our webapp, because that’s where the user gets redirected to Keycloak if they aren’t logged in yet. Keycloak then issues an Access Token which we’ll need to validate in our API.

So let’s create the client. First click on ‘Clients’ in the menu. Then on the right side of the table header is a ‘Create’ button. Click it and you’ll see the form to add a client.

Integrating Keycloak with API Platform

Enter a descriptive Client ID, this will be the client_id your webapp will use, and leave the Client Protocol on openid-connect. Enter the URL to your webapp in the Root URL field.

You will be presented with a large form with a lot of settings. We won’t need to change them for this use case. Just leave them on the default settings.

Integrating Keycloak with API Platform

Creating our first user

Now let’s create our first user for the webapp. Click in the menu on ‘Users’ and in the table heading there should be a button ‘Add user’.

This brings you to the following form.

Integrating Keycloak with API Platform

Enter the required fields. Depending on your realm settings on the ‘Login’ tab you’ll need to provide a username. After entering the details, click ‘Save’. This will take you to the Edit user screen.

Integrating Keycloak with API Platform

Before we can log in using this account we’ll need to create some credentials, so click on the ‘Credentials’ tab.

Integrating Keycloak with API Platform

This is where you can set a password for this user. When you leave ‘Temporary’ set to ‘On’ the user will need to change his password after logging in.

Configure API Platform

Now it’s time to tell API Platform and Symfony it’s alright to accept the JWT tokens created by our Keycloak instance. Because our API links a lot of stuff to a user entity we’ll create a very simple user entity.

namespace App\Entity;

use App\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;
use Ramsey\Uuid\Doctrine\UuidType;
use Symfony\Component\Security\Core\User\UserInterface;

#[ORM\Entity(repositoryClass: UserRepository::class)]
class User implements UserInterface
{
    #[ORM\Id]
    #[ORM\Column(type: UuidType::NAME)]
    private string $id;

    /**
     * @param string $id
     */
    public function __construct(string $id)
    {
        $this->id = $id;
    }

    /**
     * @return string
     */
    public function getId(): string
    {
        return $this->id;
    }

    /**
     * @param string $id
     *
     * @return User
     */
    public function setId(string $id): User
    {
        $this->id = $id;

        return $this;
    }

    /**
     * @return array|string[]
     */
    public function getRoles(): array
    {
        return ['ROLE_USER'];
    }

    /**
     * @return void
     */
    public function eraseCredentials()
    {
        return;
    }

    /**
     * @return string
     */
    public function getUserIdentifier(): string
    {
        return $this->getId();
    }
}

Code language: PHP (php)

Of course you can store extra data in this User entity, that’s up to you. The id of this entity will be the same as the id of the user in Keycloak. This makes managing users a lot easier.

Create the authenticator

Now let’s create a custom Authenticator for Symfony. This authenticator will take our JWT and checks its validity and provides the Security component of Symfony with the correct User entity.

For validating and parsing the JWT token we’ll make use of a library called Firebase JWT. So let’s install it using composer.

composer require firebase/php-jwt
Code language: Bash (bash)

Now we’re ready to create our custom Authenticator in the src/Security folder. I’ll call the file KeycloakAuthenticator.php.

namespace App\Security;

use App\Entity\User;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
use Firebase\JWT\JWK;
use Firebase\JWT\JWT;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;

class KeycloakAuthenticator extends AbstractAuthenticator
{
    public function __construct(
        private EntityManagerInterface $entityManager,
        private ParameterBagInterface $parameterBag,
        private TagAwareCacheInterface $cacheApp,
        private UserRepository $userRepository
    ) {}

    /**
     * Called on every request to decide if this authenticator should be
     * used for the request. Returning `false` will cause this authenticator
     * to be skipped.
     */
    public function supports(Request $request): ?bool
    {
        return $request->headers->has('Authorization');
    }

    /**
     * @param Request $request
     *
     * @return Passport
     */
    public function authenticate(Request $request): Passport
    {
        // Get token from header
        $jwtToken = $request->headers->get('Authorization');
        if (false === str_starts_with($jwtToken, 'Bearer ')) {
            throw new AuthenticationException('Invalid token');
        }

        $jwtToken = str_replace('Bearer ', '', $jwtToken);

        // Decode the token
        $parts = explode('.', $jwtToken);
        if (count($parts) !== 3) {
            throw new AuthenticationException('Invalid token');
        }

        $header = json_decode(base64_decode($parts[0]), true);

        // Validate token
        try {
            $decodedToken = JWT::decode($jwtToken, $this->getJwks(), [$header['alg']]);
        } catch (Exception $e) {
            throw new AuthenticationException($e->getMessage());
        }

        return new SelfValidatingPassport(
            new UserBadge($decodedToken->sub, function (string $userId) {
                $user = $this->userRepository->find($userId);
                if (null === $user) {
                    $user = new User($userId);
                    $this->entityManager->persist($user);
                    $this->entityManager->flush();
                }

                return $user;
            })
        );;
    }

    /**
     * @param Request $request
     * @param TokenInterface $token
     * @param string $firewallName
     *
     * @return Response|null
     */
    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    {
        // on success, let the request continue
        return null;
    }

    /**
     * @param Request $request
     * @param AuthenticationException $exception
     *
     * @return Response|null
     */
    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
    {
        $data = [
            'error' => strtr($exception->getMessageKey(), $exception->getMessageData())
        ];

        return new JsonResponse($data, Response::HTTP_UNAUTHORIZED);
    }

    /**
     * @return array
     */
    private function getJwks(): array
    {
        $jwkData = $this->cacheApp->get('jwk_keys', function(ItemInterface $item) {
            $jwkData = json_decode(
                file_get_contents(sprintf(
                    '%s/realms/%s/protocol/openid-connect/certs',
                    trim($this->parameterBag->get('keycloak_url'), '/'),
                    $this->parameterBag->get('keycloak_realm')
                )),
                true
            );

            $item->expiresAfter(3600);
            $item->tag(['authentication']);

            return $jwkData;
        });

        return JWK::parseKeySet($jwkData);
    }
}

Code language: PHP (php)

First the authentication System calls the supports method which will check if the request contains an Authorization header. If not, our custom authenticator will be skipped.

The authenticate method is where the “magic” happens. First we’ll perform some sanity checks to see if we really got a JWT in the Authorization header. If not we’ll throw an exception.

Then we’ll use the Firebase JWT library to validate our token using the JWKs provided by Keycloak. For this we’ll need to fetch the JWKs from Keycloak using the url provided by Keycloak. Because the JWKs don’t change frequently, we’ll cache them in the cache.app pool for one hour. This is implemented in the getJwks method.

A JSON Web Key (JWK) is a JavaScript Object Notation (JSON) data structure that represents a cryptographic key.

https://self-issued.info/docs/draft-ietf-jose-json-web-key.html

After confirming that the JWT is valid, we’ll return a SelfValidatingPassport because we don’t need to validate the credentials of the user. All of the validation is done at this point. The UserBadge in this passport received a function which will provide the User entity to the Security component of Symfony. We’ll check if the user with the Keycloak id already exists, if not we’ll create the entity and return it.

We’ve used the parameter bag of the container to access keycloak_url and keycloak_realm to create the URL needed to get the JWKs. Let’s add those parameters to the container.

Create config/packages/keycloak.yaml with the following content.

parameters:
    keycloak_realm: '%env(resolve:KEYCLOAK_REALM)%'
    keycloak_url: '%env(resolve:KEYCLOAK_URL)%'
Code language: YAML (yaml)

Now add KEYCLOAK_REALM and KEYCLOAK_URL to your .env file to set the actual values.

KEYCLOAK_REALM=test_realm
KEYCLOAK_URL=http://localhost:8080
Code language: Bash (bash)

Our custom authenticator is now correctly configured and ready to be used!

Configuring Symfony security

Now it’s time to set up the Security component of Symfony. This can be done fairly easily by adding a few lines to config/packages/security.yaml.

security:
    enable_authenticator_manager: true
        
    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
            
        main:
            lazy: true
            custom_authenticators:
                - App\Security\KeycloakAuthenticator

    access_control:
        - { path: ^/docs, roles: IS_AUTHENTICATED_ANONYMOUSLY }}
        - { path: ^/, roles: ROLE_USER }
Code language: YAML (yaml)

To make use of our custom authenticator we’ll need to add it to the main firewall by adding it to the custom_authenticators section.

Next we need to update our access_control section to make sure only people with a valid JWT will have access to our API.

Final thoughts

I hope this guide helped you in setting up API Platform to accept JWTs from Keycloak. As you’ve read it is pretty easy to set up a basic integration, but a lot more is possible with Keycloak and API Platform.

11 comments

  1. I’m comming here from time to time to learn something new and as it always turns out also useful. Thank you for your work!

    1. Thanks for your comment!

      To answer your question we are in fact validating the tokens using the JSON Web Keys provided by Keycloak.
      In the file KeycloakAuthenticator.php we fetch the JWK’s from Keycloak and use those to validate the signature of the token. That signature can only be created using the private key of Keycloak. That’s our way of validating the token.

      If anything changes in the data of the token the signature changes, thus the token will fail the validation check and the JWT::decode call will throw an exception.

      I hope this cleared things up for you.

  2. hi Wouter
    Thanks a lot for this walk through… was able to adapt it easily to my particular use case…

    I’m wondering though how you deal with expiring tokens…
    What’s your setup in Keycloak ?
    Are you using the Refresh Token ?
    Do you think it is up to the client that uses the API to make sure the JWT is still valid and refresh it regularly ?
    I don’t think it would be a good idea to defer that responsibility to the API… but wondering what you think about it…

    Last but not least…
    The thing that keeps bothering me is that there is no “simple” way to assign an API-key to a user that would not expire… What are your thoughts on that ?

    I the consuming service is a website, it is probably ok to require the client to make sure the JWT is still valid…
    Yet, if the API is consumed by another server, a non changing API-key would seem more appropriate, don’t you think ?

    Thanks in advance for your feedback
    dGo

    1. Thanks for your comment, I’m glad you found it useful and could adapt it for your use case!

      I’m indeed using refresh tokens to get fresh access tokens for use in my applications. I use react-keycloak/web for my React applications which does the refreshing of almost expired tokens automatically. Which is pretty useful.

      Yes, I think it’s up to the consumer of your API to make sure their token is still valid, because only the consumer can determine if a token is still necessary or can be discarded.

      I don’t think it’s a good idea to use tokens that don’t expire, because that would basically be a password to your API and should be stored as such. The access- and refresh token mechanics are easily implemented for server to server communication. It’s pretty much the same as client <-> server communication using React for example. It’s just that the client is another server instead of a real user.

  3. Thanks for this great article!

    Noticed that this part is a bit out-of-the-date

    file_get_contents(sprintf(
    ‘%s/auth/realms/%s/protocol/openid-connect/certs’,
    trim($this->parameterBag->get(‘keycloak_url’), ‘/’),
    $this->parameterBag->get(‘keycloak_realm’)
    )),

    it should be

    file_get_contents(sprintf(
    ‘%s/realms/%s/protocol/openid-connect/certs’,
    trim($this->parameterBag->get(‘keycloak_url’), ‘/’),
    $this->parameterBag->get(‘keycloak_realm’)
    )),

    so there is just extra `auth` in that URL.

  4. Hi

    Thanks for this handy tutorial.
    Is it possible to authenticate the keycloak token without creating an entry in the user db?

    1. Hi Alain,

      Yes, this is possible. You’ll need to create a User class that implements the UserInterface of Symfony and have the KeycloakAuthenticator class return an instance of your User object. You can fill your User object using data that is present in the JWT.

      Hopefully this helps you!

Leave a Reply

Your email address will not be published. Required fields are marked *