Creating Verifiable JSON Web Tokens (JWTs) with AWS CDK

Creating Verifiable JSON Web Tokens (JWTs) with AWS CDK

Finally you can trust your grocery list

ยท

13 min read

JSON Web Tokens (JWTs, pronounced "jots") are pretty neat. You can use JWTs to share information in a way that you can verify it wasn't altered.

Let's say the Martz family has a grocery list in JSON format (because that's normal... RIGHT?!?). Whitney has put a bunch of healthy food on there for me to buy... but how can we tell Veronica didn't add a bunch of cookies and candy to the list? If we store it as a JWT, Whitney can use her private key to sign the grocery list so that when I go to the store, I can check the list using Whitney's public key. After I publish this article I bet everyone starts doing this.

hipster

This is just one contrived example... there are a LOT of applications for using JWTs.

So let's set up a quick stack in CDK that can do all of this.

Here's the repo

This project was created using projen. It has 3 lambda functions, 1 S3 bucket, 1 RestAPI and 1 Secret in Secrets Manager. All created using CDK. It also has 100% unit test coverage ๐Ÿ˜.

Create the JSON Web Key Set (JWKS)

First we need to create the JSON Web Key Set (JWKS) which are the private and public keys used to create the JWT. We're going to store the private key in Secrets Manager and the public key in an S3 bucket and then serve that via an AWS Integration with the RestAPI so that it is publicly accessible.

A custom resource that is run whenever we bump a version will generate the JWKS and store them in the appropriate places.

The Stack

The stack code for this is located here in src/main.ts

We create the secret:

const SECRET_ID = 'BlogCdkSecret';
const secret = new Secret(this, SECRET_ID);

Then create the PRIVATE S3 bucket and give permission for the API Gateway service to read it:

const BUCKET_ID = 'BlogCdkJwksBucket';
const jwksBucket = new Bucket(this, BUCKET_ID, {
  accessControl: BucketAccessControl.BUCKET_OWNER_FULL_CONTROL,
  bucketName: `blog-cdk-jwks-bucket-${this.account}`,
  blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
  removalPolicy: RemovalPolicy.DESTROY,
  autoDeleteObjects: true,
});
const bucketRole = new Role(this, 'BlogCdkJwksBucketRole', {
  roleName: 'BlogCdkJwksBucketRole',
  assumedBy: new ServicePrincipal('apigateway.amazonaws.com'),
});
jwksBucket.grantRead(bucketRole);

And we create the lambda that will generate the actual JWKS:

const jwksGenerator = new NodejsFunction(this, 'BlogCdkJwksGenerator', {
  ...lambdaProps,
  timeout: Duration.seconds(30),
  entry: join(__dirname, './jwks-generator.ts'),
});
secret.grantWrite(jwksGenerator);
jwksBucket.grantWrite(jwksGenerator);

const jwksGeneratorProvider = new Provider(this, 'BlogCdkJwksGenerateProvider', {
  onEventHandler: jwksGenerator,
});

new CustomResource(this, 'BlogCdkJwksGenerateResource', {
  serviceToken: jwksGeneratorProvider.serviceToken,
  properties: {
    // Bump to force an update
    Version: '2',
  },
});

We also give the Lambda write permission to both the Secret and the S3 Bucket.

The Generator Lambda

The lambda itself is actually REALLY boring.

It creates the key in the key store

const keyStore = JWK.createKeyStore();
const key = await keyStore.generate('RSA', 2048, {
  alg: 'RS256',
  use: 'sig',
});

And then stores the private one in the Secret:

const secret = key.toJSON(true);
await sm
  .putSecretValue({
    SecretId: `${process.env.SECRET_ID}`,
    SecretString: JSON.stringify(secret),
  })
  .promise();

And the public one in S3

const publicKey = key.toJSON();
await s3
  .putObject({
    Bucket: `${process.env.BUCKET_ID}`,
    Key: 'jwks.json',
    Body: JSON.stringify({ keys: [publicKey] }),
    ACL: 'bucket-owner-full-control',
  })
  .promise();

Note: The JWKS should be stored as a LIST of keys, not just the key itself. Meaning it should be stored as { keys: [key] } not { ...key }. Some services like auth0 include the current key and the next key they'll use in that key list.

The API Integration

The API Integration is also pretty simple.

const restApi = new RestApi(this, 'BlogCdkJwksApi');

const jwksIntegration = new AwsIntegration({
  service: 's3',
  integrationHttpMethod: 'GET',
  path: `${jwksBucket.bucketName}/jwks.json`,
  options: {
    credentialsRole: bucketRole,
    // integration responses are required!
    integrationResponses: [
      {
        'statusCode': '200',
        'method.response.header.Content-Type': 'integration.response.header.Content-Type',
        'method.response.header.Content-Disposition': 'integration.response.header.Content-Disposition',
      } as IntegrationResponse,
      { statusCode: '400' },
    ],
  },
});

restApi.root
  .addResource('.well-known')
  .addResource('jwks.json')
  .addMethod('GET', jwksIntegration, {
    methodResponses: [{ statusCode: '200' }],
  });

The AwsIntegration can have an endpoint point to an AWS Service (like S3) and specify the file it should load. Our jwks.json file is located at the root of our S3 bucket, so we just need to have the .well-known/jwks.json endpoint point to it.

This integration uses the bucketRole we created earlier that gave the apigateway.amazonaws.com service permissions to read. When using an AwsIntegration you MUST use integrationResponses. If you don't even a good request will return a 500. I ran into this issue when setting it up the first time and discovered it doing an API Test via the API Gateway Console. The output for that looks like this:

Sat Mar 06 19:15:42 UTC 2021 : Execution failed due to configuration error: No match for output mapping and no default output mapping configured. Endpoint Response Status Code: 200
Sat Mar 06 19:15:42 UTC 2021 : Method completed with status: 500

If you deploy only those parts of the stack you'll get a shiny endpoint that hosts up the jwks.json file stored in S3 but accessible via the API.

Screen Shot 2021-03-06 at 2.18.55 PM.png

But that's not super useful on its own if you can't actually create a JWT to use with it. So let's do that!

Creating a JWT

For creating the JWT we use the private key that was stored in secrets manager. Normally this endpoint would be behind some sort of authentication but for the sake of this demo I skipped that. It somewhat defeats the purpose if anyone can create a valid JWT, afterall.

All our shiny "encoder-ring" Lambda does is get the private key from secrets manager

const secret = await sm.getSecretValue({ SecretId: `${process.env.SECRET_ID}` }).promise();
const jwks = JSON.parse(`${secret.SecretString}`);
const token = await signToken(tokenPayload, jwks);

And put whatever was in the post body into the payload

export const signToken = async (tokenPayload: any, keyBuffer: Buffer) => {
  const key = await JWK.asKey(keyBuffer);
  const signOptions = { compact: true, fields: { typ: 'jwt' } };
  const currentTime = Math.floor(Date.now() / 1000);
  const payload = {
    exp: currentTime + 86400, // 1 Day from now
    iat: currentTime,
    sub: tokenPayload.userId,
    ...tokenPayload,
  };
  return (await JWS.createSign(signOptions, key).update(JSON.stringify(payload)).final()).toString();
};

encoder

Stack-wise it's just a matter of creating the lambda and adding it to an endpoint

const encoderFunction = new NodejsFunction(this, 'BlogCdkJwtEncoderFn', {
  ...lambdaProps,
  entry: join(__dirname, './encoder-ring.ts'),
});
secret.grantRead(encoderFunction);

restApi.root.addResource('encode').addMethod('POST', new LambdaIntegration(encoderFunction));

Trying out our shiny new encoder

If we deploy those portions of the stack we could make a POST request like:

curl --location --request POST 'https://l8hzfirmh5.execute-api.us-east-1.amazonaws.com/prod/encode' \
--header 'Content-Type: application/json' \
--data-raw '{
    "groceryList": ["milk", "eggs", "healty stuff"]
}'
{"token":"eyJ0eXAiOiJqd3QiLCJhbGciOiJSUzI1NiIsImtpZCI6InZSRVlZVFg1TERFTGdGbHRzSmNWYmZDQUFCUTFITklmTG9HTDNHQXJDRTAifQ.eyJleHAiOjE2MTUxNDUzOTQsImlhdCI6MTYxNTA1ODk5NCwiZ3JvY2VyeUxpc3QiOlsibWlsayIsImVnZ3MiLCJoZWFsdHkgc3R1ZmYiXX0.BXRWgNj7VR3cQwZJVOQGOzLcJhF3IIvEa624TlIDclafS63ITXLN2CZYq0vSFJtBHfPDxrkGRN5XPusfHeZnLHeX3HrVcYzxSL4jibH3bfq1ur_K23e4_5LVIKCkmWDduKewJ2cxgaXs5_7wRXQbCBdpPDFoXamxBKZXK1a_uWhZ4DjX4zFqfl2AZpF-yiuFIuMmKpzrxZ88rdaYjbtdsnX7CWc6I0TNf3i_3pGxzCZjY3UK8jKiHNmm4Lk2HBfRBT_8zvlVq2AhPOjjRHEUjqXKSp63rC16CcaM9sSYpM6YbtyucGKTltX9GrrJ_zBX6fSTDbgptq50hlyKeGY3tQ"}

But if you take the JWT and pop it into something like jwt.io you'd see this:

Screen Shot 2021-03-06 at 2.32.13 PM.png

That's our list alright, but notice at the bottom the "Invalid Signature" part ๐Ÿง

If you use a JWT editor you could sneaky edit it to look like: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE2MTUwNjI5OTUsImlhdCI6MTYxNTA1ODk2NSwiZ3JvY2VyeUxpc3QiOlsibWlsayIsImVnZ3MiLCJjb29raWVzIiwiY2FuZHkiXSwianRpIjoiZjFlYjRiMTktYjQwYy00NWQ3LWIyZjgtYWNlMjY1NWNhZDliIn0.-B-8-m8hwmOWKSUI1B3wOmYaPWyegihVIi6U17D5H1M which replaces "healthy stuff" with cookies and candy... and is just as invalid-looking in jwt.io

Screen Shot 2021-03-06 at 2.37.00 PM.png

The Secret Decoder Ring

So let's verify it. The encoder lambda had access to the private key... our decoder lambda doesn't. It doesn't have access to anything except what's publicly available.

Using the jwks-rsa library we create a client that points to our publicly available jwks.json url and verify and decode the token.

export const verifyToken = async (client: JwksClient, token: string) => {
  const decoded = decode(token, { complete: true, json: true });
  if (!decoded || !decoded.header.kid) {
    console.error('No Key ID');
    throw new Error('Token has no Key ID');
  }

  const key = await getSigningKey(client, decoded.header.kid);
  const signingKey = key.getPublicKey();

  if (!signingKey) {
    console.error('No public or rsa key');
    throw new Error('Unauthorized');
  }

  return verify(token, signingKey);
};

It decodes the JWT and then gets the correct public key to verify it with. The response is then the decoded payload (verify does both).

decoder ring

The decoder portion of the stack is then simply:

const decoderFunction = new NodejsFunction(this, 'BlogCdkJwtDecoderFn', {
  ...lambdaProps,
  entry: join(__dirname, './decoder-ring.ts'),
});

restApi.root.addResource('decode').addMethod('POST', new LambdaIntegration(decoderFunction));

So now we can take our good token and see:

curl --location --request POST 'https://l8hzfirmh5.execute-api.us-east-1.amazonaws.com/prod/decode' \
--header 'Content-Type: application/json' \
--data-raw '{
    "jwksUri": "https://l8hzfirmh5.execute-api.us-east-1.amazonaws.com/prod/.well-known/jwks.json",
    "token": "eyJ0eXAiOiJqd3QiLCJhbGciOiJSUzI1NiIsImtpZCI6InZSRVlZVFg1TERFTGdGbHRzSmNWYmZDQUFCUTFITklmTG9HTDNHQXJDRTAifQ.eyJleHAiOjE2MTUxNDUzOTQsImlhdCI6MTYxNTA1ODk5NCwiZ3JvY2VyeUxpc3QiOlsibWlsayIsImVnZ3MiLCJoZWFsdHkgc3R1ZmYiXX0.BXRWgNj7VR3cQwZJVOQGOzLcJhF3IIvEa624TlIDclafS63ITXLN2CZYq0vSFJtBHfPDxrkGRN5XPusfHeZnLHeX3HrVcYzxSL4jibH3bfq1ur_K23e4_5LVIKCkmWDduKewJ2cxgaXs5_7wRXQbCBdpPDFoXamxBKZXK1a_uWhZ4DjX4zFqfl2AZpF-yiuFIuMmKpzrxZ88rdaYjbtdsnX7CWc6I0TNf3i_3pGxzCZjY3UK8jKiHNmm4Lk2HBfRBT_8zvlVq2AhPOjjRHEUjqXKSp63rC16CcaM9sSYpM6YbtyucGKTltX9GrrJ_zBX6fSTDbgptq50hlyKeGY3tQ"
}'
{"exp":1615145394,"iat":1615058994,"groceryList":["milk","eggs","healty stuff"]}

And our bad token and see:

curl --location --request POST 'https://l8hzfirmh5.execute-api.us-east-1.amazonaws.com/prod/decode' \
--header 'Content-Type: application/json' \
--data-raw '{
    "jwksUri": "https://l8hzfirmh5.execute-api.us-east-1.amazonaws.com/prod/.well-known/jwks.json",
    "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE2MTUwNjI5OTUsImlhdCI6MTYxNTA1ODk2NSwiZ3JvY2VyeUxpc3QiOlsibWlsayIsImVnZ3MiLCJjb29raWVzIiwiY2FuZHkiXSwianRpIjoiZjFlYjRiMTktYjQwYy00NWQ3LWIyZjgtYWNlMjY1NWNhZDliIn0.-B-8-m8hwmOWKSUI1B3wOmYaPWyegihVIi6U17D5H1M"
}'
Token has no Key ID

Which implies we don't know what signed this so DON'T TRUST IT.

denied

Secret Manager Costs

At the time of this writing, Secrets Manager isn't super cheap ๐Ÿ’ธ

PER SECRET PER MONTH
$0.40 per secret per month. A replica secret is considered a distinct secret and will also be billed at $0.40 per replica per month. For secrets that are stored for less than a month, the price is prorated (based on the number of hours.)

PER 10,000 API CALLS
$0.05 per 10,000 API calls.

It's a recurring bill if you keep stuff around, so if you played with this make sure you destroy it afterwards and check via console to make sure you don't have anything else lingering around.

MORE IMPORTANTLY: If it's a frequently invoked lambda you should evaluate some sort of caching strategy for the secret so you don't have to request it every time. If the same container gets used 100 times and you cache the secret... you just saved a nice chunk of change. ๐Ÿค”

ย