Serverless User Identity and Access Management with AWS Cognito and DynamoDB

Serverless User Identity and Access Management with AWS Cognito and DynamoDB
Photo by FLY:D / Unsplash
This article will enable you to deploy a fully functional User API to your AWS account and freely use it for your own intents and purposes.

Source code on GitHub

A cornerstone in enterprise software is secure user management, authentication and authorisation. We need systems that can:

  • Register new users
  • Store user profile data
  • Authenticate (verify a user's identity)
  • Authorise (grant access to a resource)
  • Token management and validation (JWT Bearer tokens)
  • API support for common user flows: Account activation, password reset etc
  • Manage static assets: Avatars, icons, profile images (AWS S3)

In this build I'm using Cognito for authentication and DynamoDB to stash user profile data. I link the two using a foreign key scheme. I'm also using s3 to host a bunch of static assets (icons, badges and stuff included in the src).

User model sample

{
    "id": "6b09fb7c-518f-48df-9e2d-d406b5cc6965",
    "name": "cortney_gutkowski67",
    "handle": "@CortneyGutkowski67",
    "createdAt": "2022-11-24T15:11:53.829Z",
    "lastLoginAt": "2022-11-24T15:12:08.098Z",
    "email": "deion.gusikowski56@gmail.com",
    "role": "USER",
    "status": "CONFIRMED",
    "sourceSystem": "customer-website",
    "profile": {
        "profileData": {
            "lang": "en_us",
            "avatarUrl": "https://s3.ap-southeast-2.amazonaws.com/dev.xxxxxxxxx/avatars/x256/01.png",
            "currency": "USD"
        },
        "badges": [
            {
                "name": "NEW_MEMBER",
                "iconUrl": "https://s3.ap-southeast-2.amazonaws.com/dev.xxxxxxxxx/badges/new-member.svg"
            }
        ],
        "data": {}
    }
}

Service design

Key concepts:

  • Human users authenticate with time restricted Bearer tokens previously obtained via an Oauth2 client credentials flow
  • Machine users authenticate with API keys securely stored in AWS Secrets Manager (suitable for BFF pattern)
  • Public routes exposing user data are secured with Cognito Authorisers where JWT validation happens prior to the request being received in the Lambda handler

Serverless.yaml

Below, a stripped down configuration file, outlining the most important resources and a single Lambda function protected by a Cognito Authoriser.

service: ${file(./package.json):name}
frameworkVersion: "3"
provider:
  name: aws
  region: ap-southeast-2
  runtime: nodejs16.x
functions:
  getUser:
    handler: src/handlers/user/me.handler
    events:
      - http:
          path: public/user/me
          method: get
          cors: true
          authorizer:
            name: IdTokenAuthorizer
            type: COGNITO_USER_POOLS
            arn:
              Fn::GetAtt:
                - CognitoUserPool
                - Arn
            claims:
              - email
resources:
  Resources:
    StaticAssetsBucket:
      Type: AWS::S3::Bucket
    PublicBucketPolicy:
      Type: AWS::S3::BucketPolicy
    CognitoUserClient:
      Type: AWS::Cognito::UserPoolClient
    CognitoUserPool:
      Type: AWS::Cognito::UserPool
    UserTable:
      Type: AWS::DynamoDB::Table

This build combines the security of Cognito with the flexibility and low cost of Lambda and DynamoDB.

Route table

Numerous Lambda functions provide API access via API Gateway to common User API features.

It's not complete, but should get you started.

Method Path Usage Route type ACL
GET /public/user/me Return user profile data for a valid access token. Useful for rendering a user's account page while letting Cognito implicitly handle the authorization Public Bearer token
POST /auth/login Exchange credentials for an access token S2S API key
GET /auth/token/{tokenType}/verify/{token} Verify token. Types = access or id (derived from Cognito) S2S API key
POST /user Register new user S2S API key
GET /user/id Get user by ID
DELETE /user/id Delete user by ID (hard delete) S2S API key
GET /user/name/{name} Get user by name (GSI) S2S API key
GET /user/email/{email} Get user by email (GSI) S2S API key
GET /user/handle/{handle} Get user by handle e.g @SomePerson (GSI) S2S API key
GET /user/activate/{activationCode} Activate user (changes status to UserStatus.CONFIRMED (GSI) S2S API key
PATCH /user/{id}/status/{status} Update user status S2S API key
PUT /user/{id}/data/update Fully update arbitrary user data blob (replace) S2S API key
PATCH /user/{id}/data/update Partially update arbitrary user data blob (merge) S2S API key
POST /user/{id}/badge/{name} Issue badge to a user e.g CONVERSATION_STARTER S2S API key
DELETE /user/{id}/badge/{name} Revoke user badge S2S API key

Register new user

In below code snippet, I'm establishing a foreign key relationship between the Cognito user and the DynamoDB user document.

To accomplish this, I'm using the Cognito admin command from @AWS-SDK  (imports omitted for brevity):

export const registerUser = async (input: UserCreateInput): Promise<User> => {
  const cognitoUser: UserType = await registerCognitoUser(
    email: input.email,
    name: input.name,
    input.password,
  );

  const userId: string | undefined = getCognitoUserId(cognitoUser);

  const user: User = {
    id: userId,
    createdAt: new Date().toISOString(),
    email,
    name,
    handle: `@${toPascalCase(name)}`,
    activationCode: getRandomId(21),
    sourceIp: input.sourceIp,
    role: 'USER',
    status: 'UNCONFIRMED',
    badges: ['NEW_MEMBER'],
    bio: {},
    data: {},
  };

  await createDynamoDbUser(user);

  logger.info('User registration complete', {
    data: { user, cognitoUser },
  });

  return user;
};

// Create a new Cognito user and add to the `USER` group
export const registerCognitoUser = async (
  email: string,
  name: string,
  password: string,
  group: COGNITO_USER_GROUP = COGNITO_USER_GROUP.USER,
): Promise<UserType> => {
  const user: UserType = await createCognitoUser(email, name);
  await setPassword(email, password);
  await addUserToGroups(email, [group]);
  return user;
};

// Extract UUID from Cognito user and pin to DynamodDB user-record as PK
const getCognitoUserId = (user: UserType): string | undefined => {
  const { Attributes } = user;
  const sub: AttributeType | undefined = Attributes.find(
    (attr: AttributeType) => attr.Name === 'sub',
  );
  return sub?.Value;
};
Source

Authentication

Credentials verification and token exchange is handled by the Cognito server-side admin interface.

import logger from 'src/services/logger';
import {
  CognitoIdentityProviderClient,
  AdminInitiateAuthCommand,
  AdminInitiateAuthCommandInput,
  AdminInitiateAuthCommandOutput,
  AuthenticationResultType,
} from '@aws-sdk/client-cognito-identity-provider';
import { getConfig } from 'src/utils/env';
import { Config } from '@/constants';

const client: CognitoIdentityProviderClient = new CognitoIdentityProviderClient(
  {},
);

/**
 * Authenticate user via credentials and issue tokens
 */
export const auth = async (
  email: string,
  password: string,
): Promise<AuthenticationResultType> => {
  const input: AdminInitiateAuthCommandInput = {
    AuthFlow: 'ADMIN_NO_SRP_AUTH',
    UserPoolId: getConfig(Config.COGNITO_POOL_ID),
    ClientId: getConfig(Config.COGNITO_CLIENT_ID),
    AuthParameters: {
      USERNAME: email,
      PASSWORD: password,
    },
  };

  const command: AdminInitiateAuthCommand = new AdminInitiateAuthCommand(input);

  try {
    logger.debug('Cognito.AdminInitiateAuthCommand', { data: { email } });
    const output: AdminInitiateAuthCommandOutput = await client.send(command);
    const { AuthenticationResult } = output;
    return AuthenticationResult;
  } catch (error) {
    const { message, name } = error;
    logger.error(`Cognito.AdminInitiateAuthCommand: ${name}: ${message}`, {
      data: { email },
    });
    throw error;
  }
};
Source

Retrospective

Could I have built this in a better way? Perhaps.

I'm conscious that I'm combining security and access related features with management of user profile data, sign-up, retrieving profile data etc. The two concepts are related, but I could have taken the authentication functionality and moved into an Auth API and kept the user related functionality in a User API.

This would open up to other possibilities, where multiple User APIs can use a single authentication API, or the authentication API can be utilised in different contexts, now that it is no longer married to the User API.

For anything large and "enterprisy" I would break down these components even further and make parts of it event driven using DynamoDB Stream Events and AWS EventBridge.

Source code on GitHub
Pull requests are welcome.