Decoding and validating AWS Cognito JWTs with PHP

sam152

Sam

Posted on April 8, 2022

Decoding and validating AWS Cognito JWTs with PHP

Skip to the working sample repository or read on for explanation...


At Unearthed, we use the AWS service Cognito to issue JWTs to clients during authentication. From there, the JWT is exchanged with whichever services the user is interacting with in order to validate their identity. This is helpful when building out a service graph, since each JWT can describe an authenticated users session without any direct dependency on a user service.

If you are decoding Cognito JWTs from PHP, you'll need a few key pieces of information to start:

  • The region your Cognito user pool is based in (us-east-2 in our case).
  • The User Pool ID the token was issued from.
  • The Client ID used to issue the token.

To decode the tokens we'll be using web-token/jwt-framework and a few other dependencies that can be installed with:

composer require web-token/jwt-checker web-token/jwt-signature-algorithm-rsa guzzlehttp/guzzle
Enter fullscreen mode Exit fullscreen mode

The process of decoding and validating a token is:

  1. Download the public keys used to sign the token.
  2. Decode the token and validate it against the public keys.
  3. Verify the claims in the token.

Downloading the keys

Our Cognito configuration can be represented as a simple value object:

<?php

namespace Sam\JwtBlogPost;

class CognitoConfiguration {
    public function __construct(
        public readonly string $region,
        public readonly string $poolId,
        public readonly string $clientId,
    ) {
    }

    public function getIssuer(): string {
        return sprintf('https://cognito-idp.%s.amazonaws.com/%s_%s', $this->region, $this->region, $this->poolId);
    }

    public function getPublicKeysUrl(): string {
        return sprintf('https://cognito-idp.%s.amazonaws.com/%s_%s/.well-known/jwks.json', $this->region, $this->region, $this->poolId);
    }
}
Enter fullscreen mode Exit fullscreen mode

Using this configuration and a HTTP client we can implement a key manager to download the keys:

<?php

declare(strict_types=1);

namespace Sam\JwtBlogPost;

use GuzzleHttp\ClientInterface;
use Jose\Component\Core\JWKSet;

class CognitoKeyManager {
    public function __construct(private ClientInterface $client, private CognitoConfiguration $configuration) {
    }

    public function getKeySet(): JWKSet {
        return JWKSet::createFromJson($this->retrieveKeys());
    }

    private function retrieveKeys(): string {
        // @todo These keys can be cached.
        return (string) $this->client->request('GET', $this->configuration->getPublicKeysUrl())->getBody();
    }
}
Enter fullscreen mode Exit fullscreen mode

Decoding and Verifying Claims

From there, the token needs to be decoded, validated against the public keys and the claims in the token need to be validated, to ensure they are valid and originated from your specific Cognito user pool.

Using the key manager and our configuration, here is a working decoder based on Cognito's documentation about how tokens should be decoded and verified.

Key things to note are:

  • ID tokens and access tokens have slightly different means of validation (the aud and client_id claims need to be validated in each, respectively).
  • The token_use claim should be validated as either id or access respectively.
  • Other standard claims like iat, nbf, exp and iss should be validated in both. Cognito's Issuer convention is encapsulated in the CognitoConfiguration object.
<?php

namespace Sam\JwtBlogPost;

use Jose\Component\Checker\AlgorithmChecker;
use Jose\Component\Checker\AudienceChecker;
use Jose\Component\Checker\ClaimCheckerManager;
use Jose\Component\Checker\ExpirationTimeChecker;
use Jose\Component\Checker\HeaderCheckerManager;
use Jose\Component\Checker\IssuedAtChecker;
use Jose\Component\Checker\IssuerChecker;
use Jose\Component\Checker\NotBeforeChecker;
use Jose\Component\Core\AlgorithmManager;
use Jose\Component\Signature\Algorithm\RS256;
use Jose\Component\Signature\JWS;
use Jose\Component\Signature\JWSLoader;
use Jose\Component\Signature\JWSTokenSupport;
use Jose\Component\Signature\JWSVerifier;
use Jose\Component\Signature\Serializer\CompactSerializer;
use Jose\Component\Signature\Serializer\JWSSerializerManager;
use Sam\JwtBlogPost\Checkers\ClientIdChecker;
use Sam\JwtBlogPost\Checkers\TokenUseChecker;

/**
 * Load and verify Cognito tokens.
 *
 * Rules for verifying tokens are:
 *  - Verify that the token is not expired.
 *  - The aud claim in an ID token and the client_id claim in an access token should match the app client ID that was created in the Amazon Cognito user pool.
 *  - The issuer (iss) claim should match your user pool. For example, a user pool created in the us-east-1 Region will have the following iss value: https://cognito-idp.us-east-1.amazonaws.com/<userpoolID>.
 *  - Check the token_use claim.
 *    - If you are only accepting the access token in your web API operations, its value must be access.
 *    - If you are only using the ID token, its value must be id.
 *    - If you are using both ID and access tokens, the token_use claim must be either id or access.
 *
 * @see https://web-token.spomky-labs.com/advanced-topics-1/security-recommendations#loading-process
 * @see https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-verifying-a-jwt.html
 */
class CognitoJwtDecoder {
    public function __construct(private CognitoKeyManager $keyManager, private CognitoConfiguration $configuration) {
    }

    public function decodeIdToken(string $token): JWS {
        return $this->decodeAndValidate($token, [
            new AudienceChecker($this->configuration->clientId),
            new TokenUseChecker('id'),
        ], ['iss', 'aud', 'token_use']);
    }

    public function decodeAccessToken(string $token): JWS {
        return $this->decodeAndValidate($token, [
            new ClientIdChecker($this->configuration->clientId),
            new TokenUseChecker('access'),
        ], ['iss', 'client_id', 'token_use']);
    }

    /**
     * @throws \Jose\Component\Checker\InvalidClaimException
     * @throws \Jose\Component\Checker\MissingMandatoryClaimException
     * @throws \Exception
     */
    private function decodeAndValidate(string $token, array $claimChecks, array $mandatoryClaims): JWS {
        $headerChecker = new HeaderCheckerManager([new AlgorithmChecker(['RS256'])], [new JWSTokenSupport()]);
        $claimChecker = new ClaimCheckerManager(
            array_merge([
                new IssuedAtChecker(),
                new NotBeforeChecker(),
                new ExpirationTimeChecker(),
                new IssuerChecker([$this->configuration->getIssuer()]),
            ], $claimChecks)
        );

        $loader = new JWSLoader(new JWSSerializerManager([new CompactSerializer()]), new JWSVerifier(new AlgorithmManager([new RS256()])), $headerChecker);
        $jws = $loader->loadAndVerifyWithKeySet($token, $this->keyManager->getKeySet($token), $signature);

        $claims = json_decode($jws->getPayload(), true);
        $claimChecker->check($claims, $mandatoryClaims);

        return $jws;
    }

}
Enter fullscreen mode Exit fullscreen mode

Pulling it all together

With all of these components in place, it's possible to pull together a proof of concept that can validate and decode Cognito JWTs:

<?php

require_once 'vendor/autoload.php';

[, $region, $poolId, $clientId, $type, $token] = $argv;

$config = new \Sam\JwtBlogPost\CognitoConfiguration($region, $poolId, $clientId);
$keyManager = new \Sam\JwtBlogPost\CognitoKeyManager(
    new \GuzzleHttp\Client(),
    $config,
);
$decoder = new \Sam\JwtBlogPost\CognitoJwtDecoder($keyManager, $config);

var_export($type === 'access' ? $decoder->decodeAccessToken($token) : $decoder->decodeIdToken($token));
Enter fullscreen mode Exit fullscreen mode

Which can be invoked with:

php run.php us-east-2 POOL_ID CLIENT_ID TOKEN_TYPE TOKEN
Enter fullscreen mode Exit fullscreen mode

Yielding a decoded token:

<?php
Jose\Component\Signature\JWS::__set_state(array(
'payload' => '{"sub":"420cd5cc-f537-4eab-a338-dc72f0b048e0","aud"...
Enter fullscreen mode Exit fullscreen mode

It's worth mentioning, if you are using Symfony, there is a Symfony Bundle which will make some of the factories and services used in this blog post available from the container. In our application we decided instantiating these dependencies directly was preferable.

💖 💪 🙅 🚩
sam152
Sam

Posted on April 8, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related