Amazon Cognito is a managed service that provides federated identity, access controls, and user management with multi-factor authentication for web and mobile applications. The service is very rich - any application developer can set up the signup and login process with a few clicks in Amazon Cognito Console by federating with identity providers such as Google, Facebook, Twitter, etc. One of the best features of Cognito is Lambda integration (Triggers), which allows Lambda invocation on events like pre-signup, pre and post authentication, etc.

cognito

In this post I will walk through a not so fancy, yet very useful Cognito feature - which is server to server authentication. This is one of the most common scenarios in a microservices world, where services need to talk to other services securely, and using an established standard such as OAuth2. This is also known as client_credentials Grant, or 2-legged OAuth. Amazon Cognito provides a simple and cost effective option to implement it.

Cognito Terminology

For this post, I am assuming familiarity with OAuth2, so I will not be describing terms like Grants, Scopes, JWT and JWK in detail.

  • User Pools : A user pool is collections of users. The users can be federated, can be manually set up, or imported. User Pools are the foundational entity in Cognito. You may compare this to a typical AD or LDAP directory.

  • Identity Pools : An identity pool allows access to AWS services via federated or custom identity. For example, at Marqeta, we have our dev AWS account federated with our Google Suite. This way we do not have to manage a separate directory of users who need to access the dev AWS account.

  • Domain : A Domain is tied to a user pool in a 1:1 relationship, and is used to host the signup/login/challenge pages for the auth experience for the application.

  • App Client : A User Pool can have multiple app clients. App Clients are also where we set up OAuth2 grant types. This is similar to OAuth2 clients that can access resources using various grant types. The app client also has a list of associated scopes that it may allow requests for. These scopes are declared by the Resource Server(s) in the User Pool.

  • Resource Server : A resource server is where the users’ data resides, and is protected by the configured User Pool. There can be multiple resource servers associated with a single User Pool. Think of a Resource Server as a microservice which handles authenticated requests. By the time the request makes it to the Resource Server, it has an access token which contains information about the authenticated user, and the session. The resource server(s) verify the authenticity and validity of the access token they receive. A resource server has an identifier (usually the URL of the service), and a list of scopes. Scopes are the granular level levels of access - like read, write, admin, etc.

  • JWT : Cognito access tokens are JWT, which are signed with JWK. The JWT contains standard claims, but can also be extended to contain custom claims.

In a typical set up, a User Pool will have a collection of users that have a means of authentication which is either custom or federated, Resource Server(s) which have protected data for those users, and Application Client(s) which will need to access that data on those users’ behalf. Managing this identity and access is self-contained in Cognito.

Client Credentials Flow

While mentioning the terminology, I did not talk about server to server, or service to service identity much. This is where OAuth2 Client Credentials Flow comes in, and there is no user, or identity associated with the access request. The calling service obtains an access token, and the target service asserts that token to be valid before granting access to the protected data.

In this scenario, Cognito’s User Pool is merely a placeholder, as we will have no users. The only user will be the app client. Before you think that we do not need a Domain as we will not be hosting any login pages, but we do. Instead of login pages, this domain will host the OAuth2 endpoint, /oauth2/token.

We will use the AWS CLI and not use the Console, but feel free to perform the same actions via the Console. Alternatively, you may want to just check the console after each step to learn more about the constructs.

AWS CLI

If you have seen my previous posts, I prefer aws-shell over the plain CLI. But you can use the CLI by prefixing every command with aws. Please ensure the credentials being used have policies allowing Cognito operations.

Create a Cognito User Pool

aws> cognito-idp create-user-pool --pool-name myblog

This will produce a large JSON output. We can verify by listing the user pools, like so.

aws> cognito-idp list-user-pools --max-results 10

  {
      "UserPools": [
          {
              "Id": "us-east-1_0Pe*****",
              "Name": "myblog",
              "LambdaConfig": {},
              "LastModifiedDate": 1527786443.052,
              "CreationDate": 1527786443.052
          }
      ]
  }

The Id of the user pool is very important, as everything we create from now on will need it.

Create a Resource Server

The resource server acts as a placeholder for scopes. Imagine a service to manage transactions, which will have scopes like post and get transactions.

aws> cognito-idp create-resource-server --name transactions --identifier transactions --user-pool-id us-east-1_0Pe***** --scopes ScopeName=get,ScopeDescription=get_tx ScopeName=post,ScopeDescription=post_tx

{
    "ResourceServer": {
        "UserPoolId": "us-east-1_0Pe*****",
        "Identifier": "transactions",
        "Name": "transactions",
        "Scopes": [
            {
                "ScopeName": "post",
                "ScopeDescription": "post_tx"
            },
            {
                "ScopeName": "get",
                "ScopeDescription": "get_tx"
            }
        ]
    }
}

Create a Client App

Let us create a client who can only post transactions by calling the transactions service. As you can see below, we configure this client to use client_credentials grant, and restrict it to using only transactions/post scope. The scopes are defined in $resource_server_identifier/$scope_name format. Also notice the --generate-secret argument, which will create a secret that we can use for our client credentials access token request.

aws> cognito-idp create-user-pool-client --user-pool-id us-east-1_0Pe***** --allowed-o-auth-flows client_credentials --client-name test --generate-secret --allowed-o-auth-scopes transactions/post --allowed-o-auth-flows-user-pool-client
{
    "UserPoolClient": {
        "UserPoolId": "us-east-1_0Pe*****",
        "ClientName": "test",
        "ClientId": "14aq5ll5b1it6f62uefe******",
        "ClientSecret": "j22a2ha9httcbord******e4k29ra7s8026agrc89nhjg******",
        "LastModifiedDate": 1527806667.264,
        "CreationDate": 1527806667.264,
        "RefreshTokenValidity": 30,
        "AllowedOAuthFlows": [
            "client_credentials"
        ],
        "AllowedOAuthScopes": [
            "transactions/post"
        ],
        "AllowedOAuthFlowsUserPoolClient": true
    }
}

Note the ClientId and ClientSecret in the response, as we’d need this to request an access token.

Add a Domain

We will need to add a domain to this pool, so we can get a URL for /oauth2/token endpoint. Notice that I used a fairly odd name, because these domain names are global (yes, as they generate a public URL), so a generic name like test is probably already taken. Please use a name that is probably unique.

aws> cognito-idp create-user-pool-domain  --domain lobster1234 --user-pool-id us-east-1_0Pe*****
{
    "DomainDescription": {
        "UserPoolId": "us-east-1_0Pe*****",
        "AWSAccountId": "***431494***",
        "Domain": "lobster1234",
        "S3Bucket": "aws-cognito-prod-iad-assets",
        "CloudFrontDistribution": "d3oia8etllorh5.cloudfront.net",
        "Version": "20180531225618",
        "Status": "ACTIVE"
    }
}

The DNS may take some time to propagate, so the URL may not work for a while (Took me 20 mins).

This would translate into the OAuth2 URL as https://lobster1234.auth.us-east-1.amazoncognito.com/oauth2/token

Get an Access Token

Notice that I am using HTTP Basic to send the client_id and client_secret. This is base64(ClientId:ClientSecret).

If you have openssl installed, you can use it, like so, replacing x with ClientId and y with ClientSecret.

$ echo -n 'x:y' | openssl base64

Once you have the base64 encoded HTTP Basic Authorization header, you can request the access token.

$ curl -X POST \
  https://lobster1234.auth.us-east-1.amazoncognito.com/oauth2/token \
  -H 'authorization: Basic ***********iMWl0NmY2MnVlZmVtNmFxaG46ajIyYTJ******HRjYm9yZGtucHR0Z2U0azI5cmE3czgwMjZhZ3J***************' \
  -H 'content-type: application/x-www-form-urlencoded' \
  -d 'grant_type=client_credentials&scope=transactions%2Fpost'

This should return an access_token, which is a JWT.

{
    "access_token": "******************.eyJzdWIiOiIxNGFxNWxsNWIxaXQ2ZjYydWVmZW02YXFobiIsInRva2VuX3VzZSI6ImFjY2VzcyIsInNjb3BlIjoidGVzdFwvZm9vIiwiYXV0aF90aW1lIjoxNTI3ODE3MzY2LCJpc3MiOiJodHRwczpcL1wvY29nbml0by1pZHAudXMtZWFzdC0xLmFtYXpvbmF3cy5jb21cL3VzLWVhc3QtMV83RThJc3hDR0MiLCJleHAiOjE1Mjc4MjA5NjYsImlhdCI6MTUyNzgxNzM2NiwidmVyc2lvbiI6MiwianRpIjoiYWVlZWY1MGEtYjNiNS00MjAxLTlhOGYtOGI1ZjYzYTBlYmNjIiwiY2xpZW50X2lkIjoiMTRhcTVsbDViMWl0NmY2MnVlZmVtNmFxaG4ifQ.LaWN4NEUrR_2gGANnDx8zINMZteR7-E_moskq__zai5BLNpiCBnVtoLHwVH3FvDFVVesMCBmD02dRhZqXkttxEMUmetFybDtEkH2KWbalOmKvibl5JuPyQEqZ5S4DN9ZUZAqv3X48F2e0Eshck-*******************-0aDBMaMtJU-QMfeFJkN2UgKQhtzi2dbLBB06dQEd6gcxh-*****************",
    "expires_in": 3600,
    "token_type": "Bearer"
}

Validate the Access Token

This section would need familiarity with JWT, JWK, and a bit of encryption standards.

When a service calls the transactions service with this Bearer token in the Authorization header, the token would need to be validated. There are several libraries available to do so. The JWT is base64 encoded, and signed. It may be encrypted as well, as is done in a lot of financial/sensitive use cases.

To peek in the access token, you may paste it in jtw.io. You’ll see the following as the token is decoded.

Header

{
  "kid": "Lkek/Dt3d7ibTkq4Pz2LKpmIKTu5mR6A++DRfvmu5Vw=",
  "alg": "RS256"
}

Payload

{
  "sub": "14aq5ll5b1it6f62uefe******",
  "token_use": "access",
  "scope": "transactions/post",
  "auth_time": 1527809373,
  "iss": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_0Pe*****",
  "exp": 1527812973,
  "iat": 1527809373,
  "version": 2,
  "jti": "864ec9c6-f3c9-4e3c-b9e1-dfe21e167e1e",
  "client_id": "14aq5ll5b1it6f62uefe******"
}

If you need to perform signature validation, there is some extra effort.

Cognito uses JWK to sign the token. In order to see the keys, visit https://cognito-idp.us-east-1.amazonaws.com/us-east-1_0Pe*****/.well-known/jwks.json.

This has the list of keys that Cognito would use to sign the access tokens. It is also known as JWKS - JSON Web Key Set.

To verify the signature, you’d need to convert the JWK to PEM. I used node to do so. From that URL, copy the key identified by the kid in the access token’s header.

$ npm install jsonwebtoken
$ npm install jwk-to-pem
$ node
> var jwkToPem = require('jwk-to-pem'), jwt = require('jsonwebtoken');
undefined
> var jwk = {"alg":"RS256","e":"AQAB","kid":"Lkek/Dt3d7ibTkq4Pz2LKpmIKTu5mR6A++DRfvmu5Vw=","kty":"RSA","n":"lyZWsratUxICSfYTCH2gblgUvCpBmYUacNXfQ_3Ygk8mnKaDtkXfb8uVrWwj3Eqdv_hjDYPsYLzYiinjYrLGpFgzxwZbUYXFC49bxQOal28J2emDTiWOAYKC0a_glzcwKf74AWPeBZ8PRNOR6OPLwxnoKQ6PoKGcjonoJdydx-****************************-HJVSW1oS7_uJY-6qLQN4IPzXbaHzy9iJgTDbnd6f-htneHegoLHlSmEfYgnJ_jJBsXwXFAYVm9JLiDyhdZOl-*************************","use":"sig"}
undefined
> pem = jwkToPem(jwk)
'-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlyZWsratUxICSfYTCH2g\nblgUvCpBmYUacNXfQ/3Ygk8mnKaDtkXfb8uVrWwj3Eqdv/hjDYPsYLzYiinjYrLG\npFgzxwZbUYXFC49bxQOal28J2emDTiWOAYKC0a/glzcwKf74AWPeBZ8PRNOR6OPL\nwxnoKQ6PoKGcjonoJdydx+YhROgpj92w4kABlxuP91eht+HJVSW1oS7/uJY+6qLQ\nN4IPzXbaHzy9iJgTDbnd6f+htneHegoLHlSmEfYgnJ/jJBsXwXFAYVm9JLiDyhdZ\nOl+aSpSPQjXprlHz3Ksln5D8/Ic7yiQLtPostlZEovc0dzqGND2Hr686B1CkbGqZ\nnwIDAQAB\n-----END PUBLIC KEY-----\n'
> console.log(pem)
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlyZWsratUxICSfYTCH2g
blgUvCpBmYUacNXfQ/3Ygk8mnKaDtkXfb8uVrWwj3Eqdv/hjDYPsYLzYiinjYrLG
pFgzxwZbUYXFC49bxQOal28J2emDTiWOAYKC0a/glzcwKf74AWPeBZ8PRNOR6OPL
wxnoKQ6PoKGcjonoJdydx+YhROgpj92w4kABlxuP91eht+HJVSW1oS7/uJY+6qLQ
N4IPzXbaHzy9iJgTDbnd6f+htneHegoLHlSmEfYgnJ/jJBsXwXFAYVm9JLiDyhdZ
Ol+aSpSPQjXprlHz3Ksln5D8/Ic7yiQLtPostlZEovc0dzqGND2Hr686B1CkbGqZ
nwIDAQAB
-----END PUBLIC KEY-----

Now copy the PEM, and paste it on jwt.io under “Verify Signature” section.

If everything goes well, you’ll notice a “Signature Verified” message show up instead of “Invalid Signature”.

This has to be done programmatically by the service that receives the access token along with an authenticated request for protected resources. Again, plenty of libraries available to do so. They’re listed on the jwt.io homepage.

Comments