Serverless Auth Self-Serve Platform

We cover how we can create an internal platform for managing external clients with M2M flows in Amazon Cognito, with full examples written in TypeScript and the AWS CDK.

Serverless Advocate
17 min readMar 10, 2024

--

Preface

✔️ We cover what Amazon Cognito machine-to-machine flows are.
✔️ We walk through the need for a centralised auth platform.
✔️ We walk through the basic example front-end UI.
✔️ We talk through the overall AWS architecture.
✔️ We walk through the TypeScript and AWS CDK code.

Introduction 👋🏽

There are almost always times in an organisation where many different teams have to onboard new external systems and 3rd parties to their APIs in a secure way, which typically culminates in each team needing to create their own authorization server and having to work out how OAuth 2.0 Client Credentials flows work.

“We almost always need one or more services to communicate with each other synchronously using REST APIs, and as these are typically backend services, they need to authenticate as ‘machines’ and not ‘users’.”

In this article, we will cover how we can create a central company auth service backed by Amazon Cognito with its developer self-serve portal, allowing all teams in the company to quickly create the access token configurations for their 3rd party consumers in one place.

To make the content easier to digest we will be creating a full solution code example in TypeScript and the AWS CDK for a fictitious company called ‘LJ Health Food’, which is a health food delivery company, but also allows some 3rd party integrations to place orders (think Uber Eats, or Just Eat placing orders to our company API directly).

The full solution code example can be found here:

In this example, a 3rd party service has a meeting with our leadership team to discuss placing orders through their own app. They agree, and the engineering teams get the integration under way.

To do so, we need to set them up as a trusted client, and give them the credentials to only access parts of our API i.e. the part allowing other services to place orders.

Let’s walk through what this trust looks like using a particular authorization flow.

👇 Before we go any further — please connect with me on LinkedIn for future blog posts and Serverless news https://www.linkedin.com/in/lee-james-gilmore/

Why do we need Centralised Authorization? 🔐

Before we go any further, why would we need to centralise authorization within our company with a self-serve-style portal? If we don’t, we typically find:

  • High cognitive load on teams having to set up their authorization server for access tokens for every project/service.
  • High cognitive load on understanding how OAuth 2.0 Client Credentials flows work (more on this later).
  • The UI for Amazon Cognito is super complicated, with many features that we won’t need. We can minimise this noise by removing it from our own UI (and just show the functionality that we need).
  • More touch points for our security team to deal with.
Our self-serve central authentication platform makes it quick to onboard new clients.

So what does centralising the authorization give us?

✔️ Very quick to onboard new 3rd party clients and services as we can do this in a self-serve portal.

✔️ We don’t have one team performing all of the administering of clients and tokens, as we certainly don’t want bottlenecks in any organisation (think Team Topologies) — this way our teams can log in via AD an administer their own configurations.

✔️ Our security team can validate the design of one solution and easily monitor it alongside the team.

✔️ We could use a service like Auth0, Tyk etc; but they are much more expensive than Amazon Cognito, and we typically don’t need all of the features they provide. Proxying Amazon Cognito also allows us to make the implementation specific to our own use case too as we can tailor the UI for our needs.

What is a Client Credentials flow? 🔐

Now that we have discussed the need for our central auth platform, what is an OAuth 2.0 Client Credentials flow that we have mentioned various times so far?

Let’s talk through a quick glossary of terms:

  • Client — A Client (not to be confused with a client web application) is a backend service, daemon or machine that wants to access a resource on an API (Resource Server).
  • Resource Server — A resource server is an API that one or more clients want to send authenticated requests to. A resource server validates the access token from the client against the authorization service that granted the token to the client.
  • Authorization Service — An authorization service is the way that the client generates access tokens for a specific resource server, where the tokens can contain scopes.
  • Scopes — A scope is a claim on the access token that a client receives from the authorization service, detailing which functionality is exposed to the client from the resource server.

“The Client Credentials Flow (defined in OAuth 2.0 RFC 6749, section 4.4) involves an application exchanging its application credentials, such as client ID and client secret, for an access token.

This flow is best suited for Machine-to-Machine (M2M) applications, such as CLIs, daemons, or backend services, because the system must authenticate and authorize the application instead of a user.” — https://auth0.com/docs/get-started/authentication-and-authorization-flow/client-credentials-flow

We almost always need one or more services to communicate with each other synchronously using REST APIs, and as these are typically backend services, they need to authenticate as ‘machines’ and not ‘users’.

💡 Note — this is why this flow is typically called a ‘machine to machine’ flow or ‘m2m’ flow.

When we have this requirement we perform a ‘m2m’ flow using an industry-standard OAuth 2.0 authentication flow called a ‘Client Credentials flow’ which is shown below:

We can see from the diagram above that:

  1. Our 3rd party service requests an access token from the authorization server specifically for calling our resource server (API) for placing orders.
  2. If the client credentials (i.e. client ID and client secret) are correct, the authorization server returns a new valid access token.
  3. The 3rd Party service now calls the resource server API with the access token in the ‘Authorization’ header in the request.
  4. The resource server now validates the access token against the authorization server to ensure it a.) is a valid token and b.) has the correct scopes for the endpoint on the API which is being called.
  5. The response from the resource server API is sent back to the client.

Now that we have talked about this at a high level, let’s look at what our self-serve portal looks like for administering these details to allow our 3rd party mobile app to place orders with our Orders service.

A 3rd Party app placing an order against our Orders Service

What does our Client App look like? 🎨

Now let’s have a look at what our central authorization platform application looks like in this POC style implementation for this article.

Just remember please that this is very much a POC and not production-ready!

We start with the main page which lists any existing resource servers and clients as shown below.

Our very basic one-page UI for creating resource servers, clients and scopes.

We can click on the ‘Create New Resource Server’ button which brings up a modal to add the relevant details to add a new resource server. In our example, our resource server has two scopes, ‘place-order and ‘list-orders’.

💡 Note — The Identifier is typically the API URL for the resource server, but in our example to keep this basic we will set it as ‘lj-health-food’.

This modal allows us to create a new resource server and add the various available scopes.

Now that we have set up our resource server, we can click on the ‘Create New Client’ button, which brings up a modal to add the relevant client details, as shown below. We set the client name in this example and set the scope as ‘lj-health-food/place-order’ as we only allow the client to place new orders with our resource server, and not list orders for this example.

💡 Note — When we create a new client we will have a ‘Client ID’ and ‘Client Secret’ returned. If you remember what we covered earlier, it is the ID and Secret that are used to generate a new access token with the auth server.

This modal allows us to add a new client and add the resource server scopes it can request on the access token.

To show the relevant details when we have created a new client we can click on the View button against the newly created client in the list.

The view client modal allows us to see the details of the client, including the attached scopes.

Now that we have covered the need for a central self-serve authorization service, what a client credentials flow is, and what our central self-serve platform frontend looks like; now let’s talk through the overall architecture.

What are we building? 🛠️

Taking into consideration what we have covered above, we are going to build a thin slice of an internal platform which is used to authenticate our 3rd party services, whilst allowing the team to quickly onboard new customers (clients), set their available scopes, and set up new resource servers. In essence, we have three microservices.

Simplified view of what we are building

We can see from the diagram above that:

  1. The internal team log into the UI via Entra ID (Active Directory) to ensure only certain staff have access to the portal through AD groups.
  2. The Internal UI calls API Gateway as a BFF (Backend-for-Frontend) which uses various Lambda functions to interact with Amazon Cognito through the AWS SDK to set up the clients, resource servers and scopes.
  3. Any BFF configuration is stored in Amazon DynamoDB; although we don’t set this up in our basic example with it being so simple.
  4. A 3rd Party service which is set in our Cognito User Pool (Authorization Service) creates a machine-to-machine token (client-credentials flow) for our resource server. It subsequently calls the resource server i.e. API Gateway with the generated access token in its ‘Authorization’ header.
  5. The resource server validates the token against the Cognito User Pool through the use of a Lambda Authorizer attached to API Gateway and processes the requests.

💡 Note — In this POC style thin slice we do not put the Entra ID user authentication into our frontend app as this would be overkill to talk through a POC concept.

Now let’s go ahead and talk through key code.

Talking through key code 👨‍💻

Our project on GitHub is split between three main solutions:

  • Our central Auth Service and UI in the ‘shared-central-auth’ folder.
  • The Resource Server in the ‘resource-server-service’ folder.
  • Client (3rd Party Service) in the ‘client-service’ folder.

Now let’s talk through the key code in each of them.

✔️ Shared Central Auth

We start by creating our ‘stateless’ stack in our CDK app where we create our Amazon Cognito User Pool:

import * as cdk from 'aws-cdk-lib';
import * as cognito from 'aws-cdk-lib/aws-cognito';

import { Construct } from 'constructs';

export class SharedCentralAuthStatefulStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);

// create the shared cognito user pool for m2m auth i.e client credentials flow
const authUserPool: cognito.UserPool = new cognito.UserPool(
this,
'SharedAuthUserPool',
{
userPoolName: 'SharedAuthUserPool',
removalPolicy: cdk.RemovalPolicy.DESTROY,
}
);

// create a user pool domain in cognito
// (this will allow external services to request tokens from it)
const authUserPoolDomain: cognito.UserPoolDomain =
new cognito.UserPoolDomain(this, 'SharedAuthUserPoolDomain', {
userPool: authUserPool,
cognitoDomain: {
domainPrefix: 'lj-health-food-auth',
},
});

...
}
}

We then create an Amazon API Gateway that our UI will use to interact with our user pool as shown below:

// create our experience layer api
const api: apigw.RestApi = new apigw.RestApi(this, 'CentralAuthApi', {
description: 'LJ Food Delivery - Central Auth Service',
deploy: true,
defaultCorsPreflightOptions: {
allowOrigins: apigw.Cors.ALL_ORIGINS,
},
deployOptions: {
stageName: 'prod',
loggingLevel: apigw.MethodLoggingLevel.INFO,
},
});

we then add resources for both ‘clients’ and ‘resource-servers’.

// create our resources on the api
const resourceServers: apigw.Resource =
api.root.addResource('resource-servers');
const clients: apigw.Resource = api.root.addResource('clients');
const client: apigw.Resource = clients.addResource('{id}');
const resourceServer: apigw.Resource = resourceServers.addResource('{id}');

We have multiple Lambda functions which we integrate with our API Gateway endpoints for functionality such as listing clients, creating resource servers, deleting clients etc. Let’s take a look at one example function:

// create the lambda function for adding a new resource server
const createResourceServer: nodeLambda.NodejsFunction =
new nodeLambda.NodejsFunction(this, 'CreateResourceServer', {
functionName: 'create-resource-server',
runtime: lambda.Runtime.NODEJS_20_X,
entry: path.join(
__dirname,
'src/adapters/primary/create-resource-server/create-resource-server.adapter.ts'
),
memorySize: 1024,
handler: 'handler',
tracing: Tracing.ACTIVE,
bundling: {
minify: true,
},
environment: {
...lambdaPowerToolsConfig,
USER_POOL_ID: userPoolId,
},
});

As the Lambda functions utilise the AWS SDK v3 to administer our Cognito user pool, we need to give each of them the relevant permissions they need. For the ‘CreateResourceServer’ lambda function above, this would be:

// give the lambda functions access to the user pool
createResourceServer.addToRolePolicy(
new iam.PolicyStatement({
actions: ['cognito-idp:CreateResourceServer'],
resources: [userPool.userPoolArn],
})
);

We continue this approach for all of our Lambda functions with the correct permissions.

Our Lambda function handler use cases (business logic) then utilise secondary adapters to communicate with Amazon Cognito, with an example below for creating a resource server using the AWS SDK:

export async function createCognitoResourceServer(
resourceServerName: string,
identifier: string,
scopes: ResourceServerScopeType[]
): Promise<ResourceServerType> {
const params: CreateResourceServerCommandInput = {
UserPoolId: userPoolId,
Identifier: identifier,
Name: resourceServerName,
Scopes: scopes,
};

try {
const command = new CreateResourceServerCommand(params);
const response: CreateResourceServerCommandOutput = await client.send(
command
);

logger.info(
'resource server created: ',
JSON.stringify(response.ResourceServer)
);
const resourceServer = response.ResourceServer;

if (!resourceServer) {
throw new Error('resource server could not be created');
}

logger.info('resource server details: ', JSON.stringify(resourceServer));
return resourceServer;
} catch (error) {
logger.error('error creating resource server: ', JSON.stringify(error));
throw error;
}
}

We create the relevant Lambda function use cases and secondary adapter functions for all of the other endpoints too.

At this point we have an Amazon API Gateway federating multiple Lambda functions which allow for administering clients and resource servers on our user pool using the AWS SDK v3, which can they be used through our UI!

💡 Note — We won’t cover the React app that is our front-end UI, but you can find it here alongside the auth service: shared-central-auth/client/src/App.tsx.

✔️ Resource Server

At this point we can go into the UI and create a resource server for our orders API which we want clients to consume.

We won’t cover the actual Amazon API Gateway, Lambda functions and DynamoDB table for the microservice here as it is very basic, and we have covered the majority of the code examples above. What we will cover here however is the Lambda Authorizer on the API Gateway, and the contents of the Lambda handler.

// create a request based lambda authoriser to validate access tokens
const authoriser = new apigw.RequestAuthorizer(
this,
'api-request-authoriser',
{
handler: authLambda,
identitySources: [apigw.IdentitySource.header('Authorization')],
resultsCacheTtl: cdk.Duration.seconds(0),
}
);

We can see above that we create a RequestAuthorizer which will be used to check the Authorization header which contains our access token from any clients. Next, we add this to our API Gateway as shown below:

// ensure that our lambda function is invoked through the api
// and we have a request based lambda authoriser to validate the token
orders.addMethod(
'POST',
new apigw.LambdaIntegration(createOrder, {
proxy: true,
}),
{
authorizer: authoriser, // add our lambda authoriser
authorizationType: apigw.AuthorizationType.CUSTOM,
}
);

This now means that anytime our API endpoints are hit we first invoke a Lambda Authorizer which validates the access token.

We can then see the contents of the Lambda handler for the authoriser below which validates our access token in the Authorization header on the event:

import { APIGatewayAuthorizerResult } from 'aws-lambda/trigger/api-gateway-authorizer';
import { CognitoJwtVerifier } from 'aws-jwt-verify';
import { PolicyDocument } from 'aws-lambda';
import { config } from '@config';
import { logger } from '@shared';

const cognitoJwtVerifier = CognitoJwtVerifier.create({
userPoolId: config.get('userPoolId'),
clientId: [config.get('clientId')], // array of valid client ids
scope: [config.get('scopes')], // allowed scopes
tokenUse: 'access',
});

export const handler = async function (
event: any
): Promise<APIGatewayAuthorizerResult> {
try {
// grab the auth token from the request which the client has got through
// using their clientId and client secret (with scopes) and attaching the token
// to the request in the auth header.
const authToken = event.headers['Authorization'] || '';

// lets put this in the logs for the demo only
logger.info(`Auth token: ${authToken}`);

// verify the token
const decodedJWT = await cognitoJwtVerifier.verify(authToken);

// create an allow policy for the methodArn
const policyDocument: PolicyDocument = {
Version: '2012-10-17',
Statement: [
{
Action: 'execute-api:Invoke',
Effect: 'Allow',
Resource: event['methodArn'],
},
],
};

// pass the clientId through on the context
const context = {
clientId: decodedJWT.sub,
};

const response: APIGatewayAuthorizerResult = {
principalId: decodedJWT.sub,
policyDocument,
context,
};

return response;
} catch (err) {
console.error('invalid auth token: ', err);
throw new Error('unauthorized');
}
};

We can see from the above code that the validation is done to check:

  • The access token has not expired.
  • It has been generated for one of our known clients.
  • It contains the correct scopes that we are expecting.
  • The access token has been signed by our Cognito UserPool.

All of the above is done using the 'aws-jwt-verify' package.

✔️ Client Service

OK, so now we have a resource server service (for creating new orders), and all of the configuration setup for it in our central auth platform. Now we need to create our client (3rd party service) that will consume our resource server API.

💡 Note — We wont go into detail on the microservice here made up of Amazon API Gateway, Lambda functions and DynamoDB, as we have covered similar code above.

We start by looking at the use case (business logic) for the creation of a new order in our 3rd party service, which is shown below.

import { CreateOrder, Order } from '@dto/order';
import { logger, schemaValidator } from '@shared';

import { createOrder } from '@adapters/secondary/https-adapter';
import { saveOrder } from '@adapters/secondary/database-adapter';
import { schema } from '@schemas/order';

export async function createOrderUseCase(order: CreateOrder): Promise<Order> {
logger.info(`order being placed: ${JSON.stringify(order)}`);

// create the order by calling our other service via https with an auth token
const createdOrder = await createOrder(order);

// ensure the order created response on the api is the correct shape
schemaValidator(schema, createdOrder);

// persist the order returned from the order service into dynamodb
await saveOrder(createdOrder);

logger.info(`order placed with id: ${JSON.stringify(createdOrder.id)}`);

return createdOrder;
}

We can see that we create an order through a secondary adapter which calls our resource server API with a payload and valid access token; and then we save the returned orders details in our own DynamoDB table.

The http-handler deals with generating our access token and sending the orders request to the resource server API:

import { CreateOrder, Order } from '@dto/order';
import { generateAccessToken, logger } from '@shared';

import axios from 'axios';
import { config } from '@config';
import { decode } from 'jsonwebtoken';

export async function createOrder(order: CreateOrder): Promise<Order> {
// get the configurable details from config
const clientId = config.get('clientId');
const clientSecret = config.get('clientSecret');
const url = config.get('authUrl');
const resourceServerUrl = config.get('resourceServerUrl');

// the scope for placing an order with the orders service
const scopes: string[] = ['lj-health-food/place-order'];

// generate the access token for the orders service
// by calling our central auth service with our client creds
const accessToken = await generateAccessToken(
clientId,
clientSecret,
url,
scopes
);

// Note: we should NEVER log the access token
// but for this example lets look at the contents of it decoded
const decoded = decode(accessToken, { complete: true });
logger.info(`decoded token : ${JSON.stringify(decoded)}`);

// make a call to the orders api (resource server) to create an order
// passing our access token in the headers
const { data }: { data: Order } = await axios.request({
url: 'orders',
method: 'post',
baseURL: resourceServerUrl,
headers: {
Authorization: accessToken,
},
data: order,
});

return data;
}

Finally, let’s look at the code for actually generating the access token using our generateAccessToken function:

import axios, { AxiosRequestConfig, AxiosResponse } from 'axios';

import { logger } from '@shared';
import { stringify } from 'querystring';

export async function generateAccessToken(
clientId: string,
clientSecret: string,
url: string,
scopes: string[] = []
): Promise<string> {
try {
const payload = {
grant_type: 'client_credentials',
scope: scopes.length ? scopes.join(' ') : undefined,
};

const options: AxiosRequestConfig = {
method: 'post',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
auth: {
username: clientId,
password: clientSecret,
},
data: stringify(payload),
url: '/oauth2/token',
baseURL: url,
};

const { data }: AxiosResponse<any> = await axios.request(options);

logger.info(`access token response: ${data}`);

return data?.access_token as string;
} catch (error) {
throw error;
}
}

We can see from the code above that this is performing a POST request to the token endpoint of our central auth service (Cognito User Pool) using our client ID, secret and scopes, which if valid, returns our access token. The access token looks similar to this:

{
"sub": "<your-client_id>",
"token_use": "access",
"scope": "lj-health-food/place-order",
"auth_time": 1709980199,
"iss": "https://cognito-idp.eu-west-1.amazonaws.com/eu-west-1_tyReqcsgR",
"exp": 1709983799,
"iat": 1709980199,
"version": 2,
"jti": "x9d6ggd3-8752-4b4f-1423-12x01bsd5b89",
"client_id": "<your-client_id>"
}

The key properties of the token are:

  1. sub: This represents the subject of the token, which typically refers to the client ID.
  2. token_use: Indicates the purpose of the token. In this case, it’s an “access” token, meaning it grants access to certain resources or services.
  3. scope: Describes the specific permissions granted by the token. In this example, the token grants access to the “lj-health-food/place-order” scope, indicating the ability to place an order within the “lj-health-food” service.
  4. auth_time: Represents the time at which the authentication occurred, measured in seconds since the Unix epoch (January 1, 1970, 00:00:00 UTC).
  5. iss: Stands for “issuer” and specifies the issuer of the token. It’s the URL of the identity provider that issued the token. In our case, it’s an Amazon Cognito Identity Provider.
  6. exp: Indicates the expiration time of the token, after which it should not be considered valid. Like auth_time, it’s measured in seconds since the Unix epoch.
  7. jti: Stands for “JWT ID” and is a unique identifier for the token. It helps to prevent token replay attacks.
  8. client_id: Refers to the ID of the client application that requested the token.

Now go checkout the code, deploy it, play around with the UI, and adapt it to your needs!

Conclusion

Thanks for reading through this far, and as a final recap we have covered:

✔️ What Amazon Cognito machine-to-machine flows are.
✔️ We walked through the need for a centralised auth platform.
✔️ We walked through the basic example front-end UI.
✔️ We talked through the overall AWS architecture.
✔️ We walked through the TypeScript and AWS CDK code.

Wrapping up 👋🏽

I hope you enjoyed this article, and if you did then please feel free to share and feedback!

Please go and subscribe to my YouTube channel for similar content!

I would love to connect with you also on any of the following:

https://www.linkedin.com/in/lee-james-gilmore/
https://twitter.com/LeeJamesGilmore

If you enjoyed the posts please follow my profile Lee James Gilmore for further posts/series, and don’t forget to connect and say Hi 👋

Please also use the ‘clap’ feature at the bottom of the post if you enjoyed it! (You can clap more than once!!)

About me

Hi, I’m Lee, an AWS Community Builder, Blogger, AWS certified cloud architect, and Global Head of Technology & Architecture based in the UK; currently working for City Electrical Factors (UK) & City Electric Supply (US), having worked primarily in full-stack JavaScript on AWS for the past 6 years.

I consider myself a serverless advocate with a love of all things AWS, innovation, software architecture, and technology.

*** The information provided are my own personal views and I accept no responsibility for the use of the information. ***

You may also be interested in the following:

--

--

Global Head of Technology & Architecture | Serverless Advocate | Mentor | Blogger | AWS x 7 Certified 🚀