AWS Cognito is AWS's authentication managed service that integrates natively with API Gateway & Application Load Balancer. Users can sign up directly with Cognito, Sign In & Recover password. When chasing high revenue customers in the enterprise world, AWS Cognito allow us to answer the question "How do you guarantee you're not going to leak our users' passwords?" with "By not storing it.". AWS stores users credentials and don't allow us to retrieve it.
Unfortunately for us, Cognito native integration didn't cover enough grounds. We needed API Gateway to get automatic validation, but we use Application Load Balancer in front of our APIs instead of API Gateway. Most of our services run on Fargate and getting API Gateway in front of a Fargate Container requires a Network Load Balancer, which wasn't something we wanted to pursue. With Application Load Balancer, Cognito can only authenticate stateful services in a session-based authentication. ALB does not offer API authentication with Bearer tokens. We ended up writing our own validation for Cognito Tokens and in this post I want to show a simplified way of achieving that.
Understanding Laravel authentication system
Laravel comes with a great out-of-the-box authentication process. It is composed of a few key concepts: an Authenticatable object, a User Provider and a Guard.
An Authenticatable object should implement the Illuminate\Contracts\Auth\Authenticatable
contract and is basically a Value Object representing the authenticated
user. In other words, An Authenticatable is someone who is authenticated or can
authenticate on your application.
A User Provider should implement the Illuminate\Contracts\Auth\UserProvider
contract
and is responsible for validating credentials and returning an Authenticatable
object. It provides/factories users based on credentials that are passed to them.
Lastly, a Guard is responsible for collecting the necessary information from
the user and passing it to a User Provider. If a valid Authenticatable comes
back, the Guard understands that as the credentials has been successfully
validated, otherwise the access is denied.
Guards
Laravel comes with two main guards: Session & Token. The Session Guard
provides stateful authentication. It first checks if the class itself
already have a valid user and if not, it tries to fetch the id of the
user from a session storage so that it can ask the User Provider to
load the User using the provided Id. It can also attempt
to login
using credentials that are sent to the User Provider for validation.
The Token Guard is used for stateless authentication and tries to
fetch a user from the class itself in case it has been validated
within the request itself, otherwise it tries to get a token from
the Request and pass it to the User Provider. For Cognito Authentication,
we'll be using the Token one.
public function getTokenForRequest()
{
$token = $this->request->query($this->inputKey);
if (empty($token)) {
$token = $this->request->input($this->inputKey);
}
if (empty($token)) {
$token = $this->request->bearerToken();
}
if (empty($token)) {
$token = $this->request->getPassword();
}
return $token;
}
Source: https://github.com/laravel/framework/blob/7.x/src/Illuminate/Auth/TokenGuard.php#L97
As we can see, the TokenGuard will try to fetch a token from the
Query Parameters, then from the body of the request and only as a third
option it will try to load a Bearer token from the Header of the request.
For me, this was not an issue. The field in which Laravel will be looking
is called api_token
by default. I don't expect that field to be filled
and I accept the limitation that if that field is present, authentication
will not work as expected. The Auth configuration can be set as follows:
'guards' => [
'cognito-token' => [
'driver' => 'token',
'provider' => 'cognito-provider',
'storage_key' => 'cognito_token',
'hash' => false,
],
],
'providers' => [
'cognito-provider' => [
'driver' => \CustomerGauge\Cognito\CognitoUserProvider::class,
],
],
Since we'll be using a standard Guard provided by Laravel, we can now focus on writing the custom User Provider as well as the Autheticatable object.
Authenticatable
Below you'll find a very slim down implementation of an authenticatable object. We're using at least some sort of ID here to identify the user. We are free to put any attribute we want in this object. Some common cases involve name, email address, role, etc.
Some methods from the Authenticatable interface will be left empty
as we're not going to be making use of them. After all, we're using
a stateless authentication mechanism for APIs that will not
use any sort of cookie or remember process. I also like to include
a static fake
method as a factory for PHPUnit so that we can
write some tests using this object as authenticated. When extending
Laravel Test Case class, we can call $this->actingAs(User::fake());
.
<?php
namespace App\Library\Packages\Authentication;
use Illuminate\Contracts\Auth\Authenticatable;
class User implements Authenticatable
{
private int $id;
public function __construct(int $id)
{
$this->id = $id;
}
public static function fake(): self
{
return new self(1);
}
public static function fakeWith(array $attributes = []): self
{
return new self(1);
}
public function id(): int
{
return $this->id;
}
public function getAuthIdentifierName()
{
return '';
}
public function getAuthIdentifier()
{
return $this->id;
}
public function getAuthPassword()
{
return '';
}
public function setRememberToken($value)
{
}
/** @phpstan ignore */
public function getRememberToken()
{
}
/** @phpstan ignore */
public function getRememberTokenName()
{
}
}
User Provider
The User provider is where the heart of this process will be. We can write a custom User Provider that will receive a Cognito Token, validate it and then return an authenticatable object. In order to do that we'll need a few files. These files are available as a package here.
The authentication process starts with auth.php
(described above).
There, we configure the Guards as well as the Providers. We can combine
the TokenGuard with the CognitoUserProvider for a stateless authentication.
Once the Laravel request starts, the Authenticate
middleware (auth
)
will use the TokenGuard to retrieve the token from the Request object
(Bearer Token) and pass it through the CognitoUserProvider, where we
can try to parse the token into a payload and try to make an Authenticatable
user out of it. If successful, the authentication finishes, otherwise
the middleware throws an AuthenticationException.
Writing PHPUnit Tests for the Token
This is my favorite part of the process. Once I finished the code,
I had a working authentication process that could validate and
parse AWS Cognito tokens and make them an Authenticatable user
for me. However, I wanted to be able to write tests for this code
using my own private key / public key pair and have the code
validate it the same way. I stumbled upon https://web-token.spomky-labs.com/
which apparently had the ability to generate key pairs for me
following a similar structure to AWS Cognito. I used my .well-known/jwks.json
file to figure out the alg
, use
and kty
values. I then used
the PHAR application
to generate my own keys and put it to the test.
Here is how I built a token
and here is how I mocked the file_get_contents
for the .well-known
file.
After that, I was able to run an automation test against the code
using my own set of keys to get the code validated.
Conclusion
I had to dig a bit to understand JWT and JWKS to figure out how to make a token and validate it against my code without knowing Cognito's private key. I always had in the back of my mind a backup plan which relied on provisioning an AWS Cognito Pool only for testing purpose, where I could ask for a token and then validate it. However, I wanted to try to make my test independent of an actual AWS resource. If AWS is generating a JWT Token and my code is validating it against the public key, all I had to do is figure out the set of configurations the AWS private key had in order to generate my own.
I iterated over Laravel's Auth component with this a couple of times until I had a pleasent implementation. I first wanted to put all the code in this post, but it ended up being too much code that could make the reading extensive. This is why I decided to publish it on GitHub. It's not fully meant to be a package, but rather a demonstration of how all of it works.
Hope you enjoyed the reading. If you have any questions, send them my way on Twitter.
Cheers.