Fastest Symfony authentication - AWS Cognito integration
Tags: engineeringmonday, aws, authentication, php
Published: 2022-02-07 09:35:26

If you either love AWS services already, or are looking for a good option to use with your multiplatform products, AWS Cognito seems to be a good candidate to adopt into your technical stack.

For me it was unknown, but once I started digging into it, I find it to solve some problems I was bored with solving.

## Setup Cognito on AWS
For starters, we should prepare our Cognito user pool.
We can do this via AWS UI. On the Cognito page we select "Create new user pool".
There are no really special settings you need to configure upon creation,
so choose either default settings or settings that fit your needs.

Only thing we need from user pool is to setup APP client.
Configure the APP client as you need.
For the callback URL we will target symfony route `/security/cognito/check` ie: `http://localhost:8000/security/cognito/check`.
If using local Symfony, be careful about using http or https domains. Spent some time to figure that one out because I was reckless :D.

## Symfony
At the time of writing this article, Symfony 6 was the newest version and used to test this code.

## Symfony installation
To be able to follow this post, you should either have an existing Symfony project or create a new one.
For ease of example, I will provide quick intro how to set up a new clean Symfony project.

We start with the new symfony project `symfony new --webapp .`
Please, see [Documentation](https://symfony.com/doc/current/setup.html) on installation and setup of Symfony project.

To verify we installed successfully, we can run server using `symfony server:start`.

## Install and configure packages for Cognito integration
Now that we have Symfony and Cognito ready, we can begin integration into our Symfony application.
We will use `knpuniversity` bundle that provides variety of built-in connectors, but it is missing Cognito one.
For that, we will install Cognito agent provided by another package.

`composer require knpuniversity/oauth2-client-bundle cakedc/oauth2-cognito`

Once installation is complete we begin to configure the bundle.
Update `config/packages/knpu_oauth2_client.yaml`:
```
knpu_oauth2_client:
    clients:
        # configure your clients as described here: https://github.com/knpuniversity/oauth2-client-bundle#configuration
        cognito: # name of our client
            type: 'generic' # type 
            provider_class: '\CakeDC\OAuth2\Client\Provider\Cognito' # class provided by agent package
            client_id: '<AWS_CLIENT_ID>' # Cognito app id
            client_secret: '<AWS_CLIENT_SECRET>' # Cognito app secret
            redirect_route: connect_cognito_check # name of the route where we wanna redirect callback, it mush be same as configued in the Cognito app
            provider_options:
                region: <AWS_REGION>
                cognitoDomain: <AWS_COGNITO_DOMAIN> # Cognito domain, just the domain, without region and aws suffix
                scope: 'email' #scopes configured in cognito
```

We also need some Symfony User. If you do not already have this, we can create it now.
If you already have user and user providers, you can skip this part and go to creation of connection controller.
Create user using maker bundle, `bin/console make:user` with settings:
- store user in the database: NO # this is for demo purpose. In the real world you would probably wanna store it into a database
- unique property: Email # this can be whatever you need
- Hash/check password: NO # again, if you need it otherwise you can use it

This will generate:
- `src/Security/User.php` Our User class
- `src/Security/UserProvider.php` User provider class used by Symfony security.
It will also update the security configuration `config/packages/security.yaml` with information about our new user provider.

If you are not familiar with these terms, I would urge you to read basics of Symfony security in [Symfony Security documentation](https://symfony.com/doc/current/security.html).

Next we need to create connection controller, that will provide routes and calls to oauth client bundle, ie:
```
<?php

namespace App\Controller;

use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;

class SecurityCognitoController extends AbstractController
{
    /**
     * Link to this controller to start the "connect" process
     */
    #[Route("/login", name:"connect_cognito_start")]
    public function connectAction(ClientRegistry $clientRegistry)
    {
        // will redirect to AWS Cognito!
        return $clientRegistry
            ->getClient('cognito') // key used in config/packages/knpu_oauth2_client.yaml
            ->redirect();
    }

    /**
     * After going to Cognito, you're redirected back here
     * because this is the "callback URL" you configured
     * in AWS Cognito APP settings
     */
    #[Route("/security/cognito/check", name:"connect_cognito_check")]
    public function connectCheckAction(Request $request, ClientRegistry $clientRegistry)
    {
        // ** if you want to *authenticate* the user, then
        // leave this method blank and create a Guard authenticator
    }

    #[Route("/logout", name:"security_logout")]
    public function logout(){}
}
```
Logout is currently not configured, and you can customize this to your needs later.

Last code class we need to create is custom Authenticator, by following KNP docs and guidelines:
```
<?php

namespace App\Security;

use CakeDC\OAuth2\Client\Provider\CognitoUser;
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use KnpU\OAuth2ClientBundle\Security\Authenticator\OAuth2Authenticator;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
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;

class CognitoAuthenticator extends OAuth2Authenticator
{
    private $clientRegistry;
    private $router;

    public function __construct(ClientRegistry $clientRegistry, RouterInterface $router)
    {
        $this->clientRegistry = $clientRegistry;
        $this->router = $router;
    }

    public function supports(Request $request): ?bool
    {
        // continue ONLY if the current ROUTE matches the check ROUTE
        return $request->attributes->get('_route') === 'connect_cognito_check';
    }

    public function authenticate(Request $request): Passport
    {
        $client = $this->clientRegistry->getClient('cognito');
        $accessToken = $this->fetchAccessToken($client);

        // NOTE: Here you can store token into session if you are using stateful authentication.

        return new SelfValidatingPassport(
            new UserBadge($accessToken->getToken(), function() use ($accessToken, $client) {
                /** @var CognitoUser $user */
                $cognitoUser = $client->fetchUserFromToken($accessToken);

                // NOTE: here you can load/save user from storage such as database

                $user = new User();
                $user->setEmail($cognitoUser->getEmail());
                return $user;
            })
        );
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    {
        // change "app_homepage" to some route in your app
        $targetUrl = $this->router->generate('app_homepage');

        return new RedirectResponse($targetUrl);

        // or, on success, let the request continue to be handled by the controller
        //return null;
    }

    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
    {
        $message = strtr($exception->getMessageKey(), $exception->getMessageData());

        return new Response($message, Response::HTTP_FORBIDDEN);
    }
}
```
Please note the `targetUrl` in the `onAuthenticationSuccess` method. Customize this to your needs.
I will also provide simple controller at the end of the article for convenience of testing.

Last thing is to update our `security.yaml` configuration to use our custom provider:
```
...
firewalls:
    ...
    main:
        ...
        custom_authenticator: App\Security\CognitoAuthenticator
```
This is the main subsection, not the entire contents of the file. For our configuration, we only needed to add `custom_authenticator` setting that is our authenticator class.

Once done and you try it, by opening `http://localhost:8000/login` you should be redirected to AWS hosted login page.
After you login you will be redirected back to your symfony site with error exception from `UserProvider::refreshUser`.
If you just wish to try it out, you can `return $user` in this method.
Important!! This is for testing only, and it is not good practice. You should fit this to your implementation.

If you wish to see try it out, here is a simple test controller:
```
<?php

namespace App\Controller;

use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Security;

final class DefaultController
{
    #[Route("/", name:"app_homepage")]
    #[IsGranted("ROLE_USER")]
    public function __invoke(Security $security): Response
    {
        // we return with html head and body tags as this is needed by Symfony profiler to attach to the page 
        return new Response(sprintf("<html><head></head><body>Welcome %s</body></html>", $security->getUser()->getUserIdentifier()));
    }
}
```
and you also need to update `services.yml` and add section load new controller:
```
    ...
    
    # controllers are imported separately to make sure services can be injected
    # as action arguments even if you don't extend any base controller class
    App\Controller\:
        resource: '../src/Controller/'
        tags: [ 'controller.service_arguments' ]
```

And there you have it. You can login into your Symfony application using AWS Cognito.

Feel free to clean up the code, improve the security of it and make it ready for the real world.

Security is important and sometimes hard topic. I would urge you to read [Symfony Security documentation](https://symfony.com/doc/current/security.html) to better understand how it works, what are good practices and how to avoid pitfalls.

Have fun coding, creating and learning.