Source Allies Logo

Sharing Our Passion for Technology

& Continuous Learning

<   Back to Blog

Node Reference - Cognito Setup

Teammates sitting together and looking at code

Cognito setup

In order to secure our application we are going to leverage OpenID Connect{:target="_blank"}. Each request to our application from either another service or a logged in human user will contain a JSON Web Token (a.k.a. JWT){:target="_blank"} as a "Bearer" token in the Authorization{:target="_blank"} header{:target="_blank"}. This token not only proves who the client is, but it also holds information about that client (e.g. its Claims) that include email address and roles.

Great application security is difficult. This is why we believe in following a best practice of offloading as much security related design to industry standards and as much implementation as possible to community reviewed and trusted third party libraries.

In order to support OpenID Connect we need an Identity Provider. This is a server that both the client and our application / service trust. The client sends credentials (i.e. Client ID and Secret pair) in order to obtain a JWT{:target="_blank"} and the application / service uses the public key of the Identity Provider to verify the signature of the JWT{:target="_blank"}. If your organization already has an OpenID Connect compatible identity provider in place, then reach out to the responsible team to inquire about using it as the provider for your application. This will allow single sign-on for users that already have accounts at that Identity Provider. If not, an AWS Cognito User Pool{:target="_blank"} is OpenID compatible. Unfortunately, the Cloudformation support for User Pools is lacking the ability to configure the resources we need so we will have to do this configuration via a combination of CloudFormation and the AWS CLI.

Start by placing the following in a cognito.template.yaml file. This will specify your CloudFormation stack for the following AWS resources:

  • a Cognito UserPool
  • a Cognito UserPoolClient
AWSTemplateFormatVersion: '2010-09-09'
Description: Pipeline for Organization's Cognito UserPool
Parameters:
  AuthName:
    Type: String
    Description: Unique Auth Name for Cognito Resources
Resources:
  UserPool:
    Type: 'AWS::Cognito::UserPool'
    Properties:
      UserPoolName: !Sub ${AuthName}-user-pool
      MfaConfiguration: 'OFF'
      AutoVerifiedAttributes:
        - email
      Schema:
        - Name: sub
          AttributeDataType: String
          Mutable: true
          Required: true
          StringAttributeConstraints:
            MinLength: '1'
            MaxLength: '2048'
          DeveloperOnlyAttribute: false
        - Name: name
          AttributeDataType: String
          Mutable: true
          Required: false
          StringAttributeConstraints:
            MinLength: '0'
            MaxLength: '2048'
          DeveloperOnlyAttribute: false
        - Name: given_name
          AttributeDataType: String
          Mutable: true
          Required: false
          StringAttributeConstraints:
            MinLength: '0'
            MaxLength: '2048'
          DeveloperOnlyAttribute: false
        - Name: family_name
          AttributeDataType: String
          Mutable: true
          Required: false
          StringAttributeConstraints:
            MinLength: '0'
            MaxLength: '2048'
          DeveloperOnlyAttribute: false
        - Name: middle_name
          AttributeDataType: String
          Mutable: true
          Required: false
          StringAttributeConstraints:
            MinLength: '0'
            MaxLength: '2048'
          DeveloperOnlyAttribute: false
        - Name: nickname
          AttributeDataType: String
          Mutable: true
          Required: false
          StringAttributeConstraints:
            MinLength: '0'
            MaxLength: '2048'
          DeveloperOnlyAttribute: false
        - Name: preferred_username
          AttributeDataType: String
          Mutable: true
          Required: false
          StringAttributeConstraints:
            MinLength: '0'
            MaxLength: '2048'
          DeveloperOnlyAttribute: false
        - Name: profile
          AttributeDataType: String
          Mutable: true
          Required: false
          StringAttributeConstraints:
            MinLength: '0'
            MaxLength: '2048'
          DeveloperOnlyAttribute: false
        - Name: picture
          AttributeDataType: String
          Mutable: true
          Required: false
          StringAttributeConstraints:
            MinLength: '0'
            MaxLength: '2048'
          DeveloperOnlyAttribute: false
        - Name: website
          AttributeDataType: String
          Mutable: true
          Required: false
          StringAttributeConstraints:
            MinLength: '0'
            MaxLength: '2048'
          DeveloperOnlyAttribute: false
        - Name: email
          AttributeDataType: String
          Mutable: true
          Required: false
          StringAttributeConstraints:
            MinLength: '0'
            MaxLength: '2048'
          DeveloperOnlyAttribute: false
        - Name: email_verified
          AttributeDataType: Boolean
          Mutable: true
          Required: false
          DeveloperOnlyAttribute: false
        - Name: gender
          AttributeDataType: String
          Mutable: true
          Required: false
          StringAttributeConstraints:
            MinLength: '0'
            MaxLength: '2048'
          DeveloperOnlyAttribute: false
        - Name: birthdate
          AttributeDataType: String
          Mutable: true
          Required: false
          StringAttributeConstraints:
            MinLength: '10'
            MaxLength: '10'
          DeveloperOnlyAttribute: false
        - Name: zoneinfo
          AttributeDataType: String
          Mutable: true
          Required: false
          StringAttributeConstraints:
            MinLength: '0'
            MaxLength: '2048'
          DeveloperOnlyAttribute: false
        - Name: locale
          AttributeDataType: String
          Mutable: true
          Required: false
          StringAttributeConstraints:
            MinLength: '0'
            MaxLength: '2048'
          DeveloperOnlyAttribute: false
        - Name: phone_number
          AttributeDataType: String
          Mutable: true
          Required: false
          StringAttributeConstraints:
            MinLength: '0'
            MaxLength: '2048'
          DeveloperOnlyAttribute: false
        - Name: address
          AttributeDataType: String
          Mutable: true
          Required: false
          StringAttributeConstraints:
            MinLength: '0'
            MaxLength: '2048'
          DeveloperOnlyAttribute: false
        - Name: updated_at
          AttributeDataType: Number
          Mutable: true
          Required: false
          NumberAttributeConstraints:
            MinValue: '0'
          DeveloperOnlyAttribute: false
      Policies:
        PasswordPolicy:
          RequireLowercase: true
          RequireSymbols: true
          RequireNumbers: true
          MinimumLength: '8'
          RequireUppercase: true
  # All attributes are readable and writable by default because
  # no "ReadAttributes" or "WriteAttributes" are specified.
Outputs:
  UserPoolId:
    Value: !Ref UserPool
    Export:
      Name: 'UserPool::Id'

Then deploy your cognito.template.yaml stack via the AWS CLI{:target="_blank"}:

AUTH_NAME="theproducts"

aws cloudformation deploy \
 --stack-name=Cognito \
 --template-file=cognito.template.yml \
 --parameter-overrides AuthName="$AUTH_NAME" \
 --capabilities CAPABILITY_IAM

Next, you can specify the domain of your User Pool{:target="_blank"} via the AWS CLI, since CloudFormation does not support this property at this time.

# This looks up our userpool id.
# You can also simply copy the output variable from the cognito stack via the AWS Console
USER_POOL_ID=$(aws cloudformation describe-stacks \
    --stack-name Cognito \
    --query 'Stacks[0].Outputs[?OutputKey==`UserPoolId`].OutputValue' \
    --output text)

# Because cloudformation does not yet support domains, we can create it via the CLI
 aws cognito-idp create-user-pool-domain --domain "$AUTH_NAME" --user-pool-id "$USER_POOL_ID"

The Cognito user pool is the shared component (this is why it is deployed as its own template). Each service we build will need its own Resource Server{:target="_blank"}. This is the container for the scopes (or roles) that our service is secured by.

We also need a service account for our service so that the acceptance tests have access to call our APIs. UserPool Client{:target="_blank"} is used for this purpose. Resource servers cannot yet be created via Cloudformation so we will have to use the CLI:

# unique identifier for our resource server. The fully qualified domain name is a good choice
SERVICE_IDENTIFIER="products.example.com"

# Create a resource server
aws cognito-idp create-resource-server \
    --user-pool-id "$USER_POOL_ID" \
    --identifier $SERVICE_IDENTIFIER \
    --name "Product Service" \
    --scopes \
        "ScopeName=products:read,ScopeDescription=Product Read Access" \
        "ScopeName=products:write,ScopeDescription=Product Write Access"

# Create a client for our acceptance tests
USER_POOL_CLIENT_ID=$(aws cognito-idp create-user-pool-client \
    --user-pool-id "$USER_POOL_ID" \
    --client-name "product-acceptance-tests" \
    --generate-secret \
    --allowed-o-auth-flows-user-pool-client \
    --supported-identity-providers "COGNITO" \
    --allowed-o-auth-flows "client_credentials" \
    --allowed-o-auth-scopes \
        "$SERVICE_IDENTIFIER/products:read" \
        "$SERVICE_IDENTIFIER/products:write" \
    --query 'UserPoolClient.ClientId' --output text)
echo "Created User Pool Client: $USER_POOL_CLIENT_ID"

To test your new identity provider, you can use cURL{:target="_blank"} to attempt to obtain a token by executing the following commands:

CLIENT_SECRET=$(aws cognito-idp describe-user-pool-client --user-pool-id "$USER_POOL_ID" --client-id "$USER_POOL_CLIENT_ID" --query 'UserPoolClient.ClientSecret' --output text)

curl -X POST \
  https://${USER_POOL_CLIENT_ID}:${CLIENT_SECRET}@${AUTH_NAME}.auth.us-east-1.amazoncognito.com/oauth2/token \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d grant_type=client_credentials

You should receive a JSON response that contains an "access_token" field. This token is the JWT{:target="_blank"} that is sent to the server. It will look something like shown below:

{
  "access_token": "eyJraWQiOiJvO......93t0Ei-hr9Ww",
  "expires_in": 3600,
  "token_type": "Bearer"
}

If you are curious what the token contains, it can be copy/pasted into jwt.io{:target="_blank"} to view its contents. It will look something like this:

{
  "sub": "3op....11f",
  "token_use": "access",
  "scope": "products.example.com/products:read products.example.com/products:write",
  "auth_time": 1528675981,
  "iss": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_LJ****",
  "exp": 1528679581,
  "iat": 1528675981,
  "version": 2,
  "jti": "79b6....15",
  "client_id": "3op....11f"
}

To verify the signature of our token we need to obtain the public key for our identity provider. Most identity providers rotate this key-pair on a frequent (ex. hourly) basis. To always obtain up to date public keys we can leverage the JSON Web Key Set or JWKS{:target="_blank"} standard. This specification provides a standard way to encode a set of public keys in a JSON document and then make it available at a URL. We can fetch the current JWKS document for our identity provider by executing this command with the correct cognito_user_pool_id value (found as Pool Id in the AWS Console in the User Pool's General Settings):

curl https://cognito-idp.us-east-1.amazonaws.com/${USER_POOL_ID}/.well-known/jwks.json

We now have a fully managed, security standards-compliant identity service available for use. See the changes we made here{:target="_blank}.

Table of Contents

If you have questions or feedback on this series, contact the authors at nodereference@sourceallies.com.