DEV Community

Vinicius Lopes
Vinicius Lopes

Posted on

OAuth2, JWT, and JWKS: Using Amazon Cognito as IDP

The amount of interconnected applications on the web has grown significantly over the last decade. With it, the number of threats aimed at fraud or other malicious actions has also increased. OAuth, now in version 2.0, emerges as a protocol to define how these applications should communicate with each other without compromising the security of the transmitted information and ensuring its authenticity.

This article will discuss how this protocol works and how we can implement it modern and securely using JWT tokens and validating with JWKS. The content here can be used as a guide for beginners who have yet to become familiar with the mentioned concepts.


OAuth 2.0

OAuth2 is an authorization protocol that allows third-party applications to access a user's resources without directly exposing their credentials. Instead, OAuth2 uses access tokens, which are limited in scope and time.

✨ OAuth2 defines four actors:

✳️ Resource Owner: This entity has access rights to the resource, which grants the credentials. It is typically classified as a user.

✳️ Resource Server: This is the API exposed on the web that contains the user's data. Access to this data is granted through a token issued by the following actor, the Authorization Server.

✳️ Authorization Server: Responsible for authentication, it is the one who receives the user's credentials and returns an access token. The tokens issued can be rich, containing information about the Resource Owner, or opaque, provided only as a signed key. This token grants access to the Resource Server.

✳️ Client: This is the application that effectively interacts with the Resource Owner, such as a browser or another HTTP client.

This flow demonstrates, in a simplified way, the relationship between the actors involved.

oauth flow


Bearer Token & JWT (JSON Web Token):
The Language of Tokens

Bearer Token

In practical terms, a Bearer Token is the access token issued by the Authorization Server to be sent to the APIs via Header, granting access to the content. The most widely adopted implementation format is JWT. However, the OAuth 2.0 protocol does not mention JWT as a standard. RFC 6750 only specifies the nature of the token as an authentication string but is broad regarding formats and structures.

JWT

It is a token standard whose format allows it to be transmitted between two parties. It not only authorizes access to resources but also carries other relevant information through claims. It has a set of rules, both for issuance and validation, that must be followed to comply with the format. This format allows information to be transmitted securely over an insecure channel.

A JWT token follows this format:

🔴eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
🔵.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
🟢.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

This sequence is composed of three segments:
🔴Header, 🔵Payload, and 🟢Signature.

Header

It includes a small JSON that stores data about the token type and the encryption algorithm used.

Payload

Also in JSON, it carries the user's Claims. Claims contain data that the API uses to verify a user's access rights to the resource and, at times, share information with other services and applications.

Signature

The signature is formed by the Header and Payload encoded in Base64. Then, a password is combined using the encryption algorithm specified in the Header, thus forming the final key.

The signature protects the token against malicious changes, ensuring the authenticity of the information being transmitted. Attacks like man-in-the-middle are unable to forge the Claims content, as they do not have the password to generate a new signature.


JWKS (JSON Web Key Set):
Public Key Management

A JWKS is a set of public keys made available through an endpoint of the token issuer itself to validate JWT signatures. When using asymmetric cryptography, meaning a key pair to sign the token instead of a unique secret (symmetric cryptography), only the Authorization Server has access to the private key, and the public keys are shared so that applications can validate whether the token was indeed issued by the Authorization Server.

Take a look at an example:

{
    "keys": [
        {
            "kty": "RSA",
            "e": "AQAB",
            "use": "sig",
            "kid": "ca761cd3-8092-46be-926b-ef28465ff942",
            "n": "oXAP360uf_9_KXTCk6BiQOgwQJlqoycCbsukFtoUCmn57jM-9n2uqBBPT_8VnTIaYr4h8zxMy8HRkdX35HRmZANoqekhH03hhMc69mK4yEYZwBNyV9SteXrF5hfj4SWsK0t3CZ_G_U303XLj7ak5m-4w1UXCmvBERR_SwXjLOKwAAFlOQS_0sAB9yzvJkvsuvqd4lA3-vFFF_ZVbTHuJAznqB_avwCbCHJWfiWln2PN7LsieX08tE13bPP1TVEFid9mcUz5dwz0J9QKTYCd90fkyzqanzG638SFoyL84ddmD_9pef5x03oMWEU9-dxEI6PFfWEQmXN1eg7GfJI6bxQ"
        }
    ]
}

Enter fullscreen mode Exit fullscreen mode

One of the fields is kid, which is a key identifier, also present in the JWT header. It is through this identifier that we know which key in the JWKS set will be used to validate the signature of our token.


AWS Cognito: Identity Provider

Cognito is an AWS service that manages identity and access. It acts as a user directory, capable of storing and validating data, authenticating access, and securely providing identity. We can store fields such as email, name, phone, birthdate, nickname, gender, or any other information through custom attributes. In addition, Cogito offers multiple ways to log in to the same resource.

Demo with JWKS

Let's break down a TypeScript project that implements the OAuth 2.0 solution using Cognito as the identity provider. The goal here will be to retrieve an access token using the credentials provided by an HTTP Client and validate the token's signature against a JWKS keychain.

User Pool Creation

First, we need to configure our provider. Access the AWS Management Console and search for Cognito. Navigate to the service and look for the Create user pool option. There are a total of 6 steps, the last one being a review of the options selected before creating the user pool. I will display my final step, highlighting the selected options and emphasizing what is relevant to the demonstration.

Sign-in experience:

Sign-in experience

  • User name is enabled as a login option.
  • Email is enabled as a login option.

Security requirements:

Security requirements

  • I used Cognito's default policy for user password definition.
  • MFA is disabled.
  • Self-service account recovery option is disabled.

Sign-up experience:

Sign-up experience

  • Self-registration is disabled, users will be created directly in the console for demo purposes.
  • None of the default attributes are mandatory.

Message delivery:

Message delivery

  • I am not using SES for sending emails.
  • I am not using SNS to send any type of notification.
  • I selected the option Send email with Cognito.

Integrate your app:

Integrate your app

  • I added the following authentication flows: ALLOW_REFRESH_TOKEN_AUTH, ALLOW_USER_SRP_AUTH and ALLOW_USER_PASSWORD_AUTH
  • My User pool name will be byte-terrier-idp
  • My App type will be Public client
  • And finally, My App client name will be aws-cognito-w-jwks

With everything set up, access: cognito -> user pools -> “the-pool-created”
Now, you should see a screen similar to this:

idp
Take note of two items from the screen, as these values will be used later in the demo.

  • User pool ID, It is the identifier of your pool.
  • Token signing key URL, this is the JWKS endpoint where the set of public keys for validation will be available.

User Creation

Still in the created pool, look for the Create user option. A new page should appear. Once again, I have listed the options that should be selected.

User Creation

  • The user will be able to log in with either the Username or the Email.
  • The user will not receive any type of invitation.
  • The username will be Meyer (it's the name of my dog).
  • The email will be meyer@boston.com
  • A temporary password will be created.

Click on Create user. A new user will be created but with the status Force change password. Since the goal here is only to perform a demo and not an end-to-end implementation, I will force the user's password through CLI. I am assuming that your AWS credentials are correctly configured in the environment.

Check at: ~/.aws/credentials
If they are not, here’s how to set them up: Configuring AWS CLI Credentials

Run the following command, making the necessary replacements.

aws cognito-idp admin-set-user-password --user-pool-id '<your-user-pool-id>' --username '<your-username>' --password '<your-new-password>' --permanent
Enter fullscreen mode Exit fullscreen mode

Example:

aws cognito-idp admin-set-user-password --user-pool-id 'us-east-1_ErtWYLogU' --username 'meyer' --password 't!)l5T$C825l' --permanent
Enter fullscreen mode Exit fullscreen mode

This should change the user's status to Confirmed.

App Client

Finally, go to: App integration -> App client list
...and find the app's Client. This ID will be needed for integration between the AWS SDK and Cognito.

App client list


A look at the code

I created a simple HTTP API in TypeScript for us to dissect and analyze how the integration with Cognito works using the AWS SDK. For better tracking, you might want to clone the repository to your machine, which can be done using the following command

git clone git@github.com:ByteTerrier/aws-cognito-w-jwks.git
Enter fullscreen mode Exit fullscreen mode

Here is the directory structure:

/aws-cognito-w-jwks
├── .env.example
├── .gitignore
├── package-lock.json
├── package.json
├── request.http
└── src
    ├── app.ts
    ├── server.ts
    ├── http
    │ ├── _routes.ts
    │ ├── authenticate.controller.ts
    │ └── verify-jwt.controller.ts
    └── lib
        ├── cognito.ts
        └── jwks.ts
Enter fullscreen mode Exit fullscreen mode

Running the server

To start the project we must define the environment variables. Create a new file named .env based on .env.example and set the values according to what is shown in your user pool.

Here is a description of each one for better understanding:

  • COGNITO_CLIENT_ID - This is the Application's Client ID, which can be found in the App client list. It should be unique for each application, for better management.
  • AWS_REGION - This is the region where the user pool that was created is located.
  • JWKS_URI - This is the JWKS endpoint for querying public keys. It appears in the main dashboard of the User Pool.
  • DEBUG (optional) - When set to the value - jwks, it increases verbosity for the jwks-rsa dependency, which we use to validate the token signature.

Moving on… Now, we need to install the project dependencies. Run npm to download the packages.

npm i
Enter fullscreen mode Exit fullscreen mode

Once completed, we are ready to start our server, which will listen on port 3000. Run...

npm start
Enter fullscreen mode Exit fullscreen mode

If everything goes well, your output should be:

🍃 HTTTP Server Running.
Enter fullscreen mode Exit fullscreen mode

Analysis

There’s no reason to cover the entire code in this article; in short, it’s an API built with Fastify. Both controllers perform some processing and validation before actually executing their actions. Feel free to take a look at the other files, but here, we will focus on cognito.ts and jwks.ts under the lib folder.

cognito.ts

The /authenticate route is responsible for generating the JWT token. This is where the user credentials are passed via the POST method. If we open the request.http file, located at the root of the project, we will see…

POST http://localhost:3000/authenticate HTTP/1.1
content-type: application/json

{
"username": "meyer",
"password": "t!)l5T$C825l"
}
Enter fullscreen mode Exit fullscreen mode

This request should return the following response:

authenticate
In username field, we must pass the username that was registered. The interesting thing here is that, as mentioned previously, Cognito supports several login methods, and one of the methods we enabled was via email. Therefore, we could log in as the same user using a different credential, in this case, meyer@boston.com.

As for the password, we must pass the permanent password that we set for the user, the one we defined using the AWS CLI.

The code (on Github):

import {
  CognitoIdentityProviderClient,
  InitiateAuthCommand,
  InitiateAuthCommandInput,
} from '@aws-sdk/client-cognito-identity-provider'

const AWS_REGION = process.env.AWS_REGION || 'us-east-1'
const COGNITO_CLIENT_ID = process.env.COGNITO_CLIENT_ID || ''

const cognitoClient = new CognitoIdentityProviderClient({
  region: AWS_REGION,
})

/**
 * Get a JWT token from Cognito using the USER_PASSWORD_AUTH flow
 * @param {string} username - The login credential of the user
 * @param {string} password - The password credential of the user
 * @returns {Promise<string | undefined>} - The JWT Token
 */
export async function getJwtToken(
  username: string,
  password: string,
): Promise<string | undefined> {
  const params: InitiateAuthCommandInput = {
    AuthFlow: 'USER_PASSWORD_AUTH',
    ClientId: COGNITO_CLIENT_ID,
    AuthParameters: {
      USERNAME: username,
      PASSWORD: password,
    },
  }

  const command = new InitiateAuthCommand(params)
  const response = await cognitoClient.send(command)

  return response.AuthenticationResult?.IdToken
}
Enter fullscreen mode Exit fullscreen mode

When we make the request, the data is delivered to the getJwtToken function (line 20), which in turn initializes an AWS Client to interact with our IDP (Cognito). We tell the Client that the authentication flow to be used will be USER_PASSWORD_AUTH (line 25), meaning authentication via username and password. If the authentication is successful, the token is returned. Otherwise, the controller will set the HTTP status 401, which is unauthorized access.

jwks.ts

The /verify-jwt route (GET) is responsible for validating the signature of our token. We must add a header - Authorization - with the value Bearer + jwt. The controller will sanitize the token, removing the Bearer prefix before invoking the validateTokenSignature method in the jwks.ts file.

The full request looks like this:

GET http://localhost:3000/verify-jwt HTTP/1.1
content-type: application/json

Authorization: Bearer eyJra...
Enter fullscreen mode Exit fullscreen mode

verify jwt

The code (on Github):

import jwt from 'jsonwebtoken'
import jwksClient from 'jwks-rsa'

const JWKS_URI = process.env.JWKS_URI || ''

export class JWKSValidationError extends Error {
  constructor() {
    super('JWKSValidationError')
  }
}

/**
 * Validate the signature of a JWT Token against the JWKS URI
 * @param {string} token - The JWT Token to verify
 * @returns {Promise<void>} - Promise that resolves if the token is valid
 */
export async function validateTokenSignature(token: string): Promise<void> {
  const client = jwksClient({
    jwksUri: JWKS_URI,
  })

  const header = jwt.decode(token, {
    complete: true,
  })?.header
  if (!header) throw new JWKSValidationError()

  const signingKey = await client.getSigningKey(header.kid)
  if (!signingKey) throw new JWKSValidationError()

  await new Promise((resolve, reject) => {
    jwt.verify(
      token,
      signingKey.getPublicKey(),
      { algorithms: ['RS256'] },
      (err, decoded) => {
        if (err) reject(err)
        resolve(decoded)
      },
    )
  })
}
Enter fullscreen mode Exit fullscreen mode

In jwks.ts, on line 18, a JWKS Client is created to fetch the JSON containing the collection of public keys. On line 22, we decode the token to extract its header and locate the kid, which is the key ID we need to match. On line 27, we search for the key in the set. Finally, on line 30, we create a promise and use jwt.verify to verify the signature of the keys. The key will be decrypted using the RS256 algorithm and its content must be exactly a base64 of the Header + Payload.

ℹ️ This is why every JWT starts with ey, it is the encoding of {", the beginning of a JSON structure in base64.

If something goes wrong, anything at all, an exception will be thrown, and the controller will reject access to the resource, not displaying the expected response — HTTP 200 OK.


Final Considerations

This article covered concepts that promote greater care and security for our user's data. We applied these concepts to one of AWS's services, Cognito, which offers several other strategies beyond the one discussed - such as MFA or SSO. These practices should help prevent malicious attacks and minimize the risk of fraud, as well as being an efficient way to ensure granular access control for your users.

For more information about Cognito, see the official documentation.

References:

https://docs.aws.amazon.com/cognito/latest/developerguide/what-is-amazon-cognito.html
https://jwt.io/introduction/
https://datatracker.ietf.org/doc/html/rfc6750
https://datatracker.ietf.org/doc/html/rfc6749
https://datatracker.ietf.org/doc/html/rfc7519

Top comments (0)