Table of contents generated with markdown-toc
npm install -g aws-cdk # install latest version
npm install -g [email protected] # install specific version
cdk bootstrap
npm run deploy
aws cognito-idp admin-create-user \
--user-pool-id [user pool id here] \
--username [email here] \
--user-attributes Name="custom:tenantId",Value="sith-inc-100" Name="custom:org",Value="galactic empire" Name="given_name",Value="Anakin" Name="family_name",Value="Skywalker"
aws cognito-idp admin-confirm-sign-up \
--user-pool-id [user pool id here] \
--username [email here] \
aws cognito-idp admin-set-user-password \
--user-pool-id [user pool id here] \
--username [email here] \
--password [password here] \
--permanent
export region="xxxxx"
export userPoolId="xxxxxxx"
export COGNITO_USER_NAME='xxxxxxx'
export COGNITO_USER_PASSWORD='xxxxxxx'
export COGNITO_CLIENT_ID='xxxxxx'
export cartTable='xxxxxxx'
export executeApiArn="xxxxxxxx"
export AWS_ACCESS_KEY_ID="xxxxxxx"
export AWS_SECRET_ACCESS_KEY="xxxxx"
export AWS_SESSION_TOKEN="xxxxx"
For new developers, it is not immediately obvious that there is a difference between the terms authentication and authorisation. Although related, these terms actually refer to two different concepts. Authentication verifies a user's identity whereas authorisation verifies what a user is allowed to access once they have been authenticated. In short, authentication is about who is allowed in and authorisation is about what they are allowed to access once they are in. This article will focus on how API Gateway
uses lambda, Lamdba Authoriser
, for user authorisation.
JWT is an open standard that is widely used to securely share authentication information (claims) between client and server. The standard is defined in the RFC7519 specification developed by the Internet Engineering Taskforce (IETF).
Valid JWTs contains three sections that are encoded as base64url strings separated by dot characters as shown below.
1. Header
2. Payload
3. Signature
<Header>.<Payload>.<Signature>
The header consists of two parts, a key id kid
and the algorithm alg
used to sign the token.
{
"kid": "abcdefghijklmnopqrsexample=",
"alg": "RS256"
}
The payload contains information about the user as well as other information necessary for token verification/authorisation. Collectively, The information contained in the payload is usually referred to as token claims
. When a token is verified for authorisation it is this information that must be checked/validated.
{
"sub": "aaaaaaaa-bbbb-cccc-dddd-example",
"aud": "xxxxxxxxxxxxexample",
"email_verified": true,
"token_use": "id",
"auth_time": 1500009400,
"iss": "https://cognito-idp.ap-southeast-2.amazonaws.com/ap-southeast-2_example",
"cognito:username": "anaya",
"exp": 1500013000,
"given_name": "Anaya",
"iat": 1500009400,
"email": "[email protected]"
}
The signature section is a security feature that makes it virtually impossible for bad actors to tamper with tokens. This section is the hashed and encrypted combination of both the the header and the payload sections. During token authorisation, the hash is decrypted and compared with the hash of the header and payload sections. If the two do not match, the token is considered invalid and thus unauthorized.
AWS has adopted and adapted the RFC7519 standard for use with the Cognito service. When a user authenticates with cognito, three JWTs are issued:
id token
access token
refresh token
One or all of these tokens can be passed to a custom lambda function for authorisation purposes. In this article we'll look at all three tokens and how they relate to user authorisation.
The tokens created by cognito can be used to grant access to server-side resources such as API Gateway resource paths, data stored in DynamoDb and/or S3 buckets.
An ID token is a JWT that contains claims related to the identity of the authenticated user i.e email, phone number and custom attributes. When used to authenticate users of a web app, the signature of the token must be verified before the claims stored in the token can be trusted.
An access token is a JWT that contains claims related to the authenticated user's groups and scopes. Access tokens are similar to id tokens with very few exceptions. For example ID tokens allow the use of custom attributes whereas access tokens do not. To get a full understanding of what access tokens are and how they differ from id tokens refer to the the following resources.
A refresh token is used to retrieve new access tokens. Refresh tokens have a default expiration of 30 days after a user signs into the designated userpool. This can be manually configured while creating an app for the userpool.
When a refresh token expires, the user must re-authenticate by signing in again.
A token based lambda authoriser receives the caller's identity in the form of a bearer token included in the request's header section while a request based lambda authoriser receives the caller's identity in a combination of headers and query string parameters.
When a request is received by an API gateway instance that is configured to use a TOKEN
lambda authoriser for authorisation purposes, the bearer token
contained in the request header is forwarded to the lambda authoriser for verification. The forwarded payload is a JSON object that assumes a structure similar to the one shown in the Input Sample
code block shown below .
{
"type":"TOKEN",
"authorizationToken":"{caller-supplied-token}",
"methodArn":"arn:aws:execute-api:{regionId}:{accountId}:{apiId}/{stage}/{httpVerb}/[{resource}/[{child-resources}]]"
}
Once the token is verified, the Lambda authoriser should return an output that assumes a structure such as the one provided below.
{
"principalId": "yyyyyyyy", // The principal user identification associated with the token sent by the client.
"policyDocument": {
"Version": "2012-10-17",
"Statement": [
{
"Action": "execute-api:Invoke",
"Effect": "<Allow|Deny>",
"Resource": "<aws resource>"
}
]
},
"context": {
"key": "value",
},
"usageIdentifierKey": "{api-key}"
}
- The principalId is the user id associated with the token sent by the client.
- If the API uses a usage plan and the apiKeySource is set to authoriser, the lambda authoriser output must include the usage
plan's API keys as the
usageIdentifierKey
property value- The principalId is the user id associated with the token sent by the client. - If the API uses a usage plan and the apiKeySource is set to authoriser, the lambda authoriser output must include the usage plan's API keys as the
usageIdentifierKey
property value
Token verification is done in 3 steps.
- Verify structure of token
- Verify signature
- Verify the claims
Confirm that the token contains three dot separated base64url strings. If the token does not conform to this structure then it is invalid. The first string is a header string followed by a payload string and then finally the signature string as shown below.
<Header>.<Payload>.<Signature>
To validate the JWT signature, the token must first be decoded.
i) Download and store your JWT's corresponding JWK
(JSON Web Key) using the following url
https://cognito-idp.{region}.amazonaws.com/{userPoolId}/.well-known/jwks.json
substitute region and userPoolId with your user pool's region and user pool ID respectively
ii) Search the downloaded jwks.json
for a kid
that matches the kid
of your JWT
{
"keys": [{
"kid": "1234example=",
"alg": "RS256",
"kty": "RSA",
"e": "AQAB",
"n": "1234567890",
"use": "sig"
}, {
"kid": "5678example=",
"alg": "RS256",
"kty": "RSA",
"e": "AQAB",
"n": "987654321",
"use": "sig"
}]
}
The signature of the issuer is derived from the JWK with a kid that matches the kid of the JWT.
Each JWK
contains an n
parameter that contains the modulus value
of the RSA public key
.
This is the value that'll be used to derive the issuer's signature
.
The JWK will need to be converted to PEM format
before that can happen.
-
Verify that the token is not expired.
-
The aud claim in an ID token and the client_id claim in an access token should match the app client ID that was created in the Amazon Cognito user pool.
-
The issuer (iss) claim should match your user pool. For example, a user pool created in the us-east-1 Region will have the following iss value:
https://cognito-idp.us-east-1.amazonaws.com/<userpoolID>
-
Check the
token_use
claim conforms to the type of token you intend to use,id
for id tokens andaccess
for access tokens
Consider a scenario where we'd like to build an e-commerce web application. To keep things simple let's contextualize the scenario so that we only have one micro service that uses a multi tenant dynamoDb table to store/retrieve customer shopping carts. The persistence layer will consist of 4 lambdas that perform DELETE
, PUT
, QUERY
and UPDATE
actions. An architectural diagram for this scenario has been provided below.
- Users authenticate with a username and password, the web app passes these to amazon cognito for validation.
- If the supplied credentials (username and password) are valid, cognito creates a session and subsequently issues three (3) JWTs. The aforementioned tokens are id token, access token and a refresh token. The authenticated user can now send requests to api gateway along with with the id token in the headers section.
- API gateway sends the received id token to a lambda function called an authoriser.
- The authoriser function verifies the claims attached to the id token.
- The authoriser returns a policy and context.
- API Gateway evaluates the policy and forwards the request to a lambda function along with the authoriser generated context.
- The lambda function writes/reads data to/from the database using the
tenantId
- A response is returned by the lambda function.
To create a secure multi-tenant
environment we require tenant resource isolation
. In essence, tenant A
should not be able to access the resources of tenant B
and vice versa. We can achieve resource isolation by assigning a unique identifier, tenantID
, to each user/customer. The tenantID
is a composite string that is generated and then attached to the calling user as a custom attribute
during registration.
After successful authentication the tenantID
custom attribute becomes available as a parameter within the ID token
as a parameter with the following key custom:tenantID
. It is important to note that access tokens do not carry any of the user's custom attributes, only id tokens have this capability. This application only uses id tokens for authentication/authorisation purposes.
In order to make use of the idea of a tenantID
it is important to realise that there needs to be some way of retrieving the correct tenantID
parameter and propagating it to the running lambda function's execution scope. This is where the idea of Lambda context objects comes into play.
Context objects can be modified to include custom parameters that can then be accessed during run time. With this in mind, it should be relatively straight forward to use the application's Lambda authoriser to forward the retrieved tenantID
as a custom parameter within the JSON output of the lambda authoriser.
When downstream lambdas are invoked they cam access the context object as a key within the event object. Take for example the following sample lambda event object.
Remember to remove calls to console.log() if you are logging sensitive information
{
resourceId: 'xxxxxxxx',
authoriser: {
firstName: 'Anakin',
lastName: 'Skywalker',
org: 'galactic empire',
tenantId: 'sith-inc-100',
principalId: 'xxxx-xxx-xxxx-xx-xxxxx',
integrationLatency: 0
},
resourcePath: '/cart/{itemId}',
httpMethod: 'PATCH',
extendedRequestId: 'xxxxxxxxxx',
requestTime: '20/May/2022:08:09:57 +0000',
path: '/multiTenantStack/cart/1653034194717',
accountId: 'xxxxxxxxx',
protocol: 'HTTP/1.1',
stage: 'multiTenantStack',
domainPrefix: 'htieqffa0l',
requestTimeEpoch: 1653034197210,
requestId: 'xxx-f7f3-4641-xxx-xxxxx,
identity: {
cognitoIdentityPoolId: null,
accountId: null,
cognitoIdentityId: null,
caller: null,
sourceIp: 'xxx.xxx.xx.xxx',
principalOrgId: null,
accessKey: null,
cognitoAuthenticationType: null,
cognitoAuthenticationProvider: null,
userArn: null,
userAgent: 'axios/0.21.4',
user: null
},
domainName: 'apiId.execute-api.ap-southeast-2.amazonaws.com',
apiId: 'apiId'
}
In the context of dynamoDb, the tenant ID will essentially be the partition key that'll be used to group together/ partition customer data. When a customer needs to write/retrieve data to/from the purchase history database they'll use their unique tenant ID to only access data that belongs to them. In essence the tenant ID can be thought of as being analogous to a key that can only open a single door.
The table provided below is a representation of how the data stored in the purchase history will be partitioned. The parameter tenantId
is used as the partition key
while the itemId
parameter is used as the sort key
.
tenantID | itemID | price | qty | description |
---|---|---|---|---|
Customer1-xcv9 |
timestamp |
number |
1 |
string |
Customer2-dgf1 |
timestamp |
number |
2 |
string |
Customer1-xcv9 |
timestamp |
number |
2 |
string |
Customer2-dgf1 |
timestamp |
number |
2 |
string |
When the Lambda authoriser is invoked it is expected to return a JSON object that not only includes a context object that contains the tenantID
parameter but also a resource policy as detailed here. The policy should allow/deny access to the API and dynamoDB table depending on the outcome of the Lambda authoriser's token claims verification.
All Allow-Policies generated will allow the authenticated user to only access data that has a partition key which matches their tenant id. This is achieved by making use of DynamoDB's dynamodb:LeadingKeys
condition key for fine grained access control.
In essence, the dynamodb:LeadingKeys
condition key is a mechanism for row level access control within DynamodDB tables. Employing the use of this mechanism guarantees a reliable and secure multitenant environment as well as the added benefit of a simplified data persistence layer.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Deny",
"Action": "*",
"Resource": "*"
},
]
}
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "execute-api:Invoke",
"Resource": executeApiArn,
},
{
Effect: 'Allow',
Action: ['dynamodb:UpdateItem', 'dynamodb:PutItem', 'dynamodb:DeleteItem', 'dynamodb:Query'],
Resource: this.cartTable,
Condition: {
'ForAllValues:StringLike': {
'dynamodb:LeadingKeys': tenantId,
},
},
},
]
}
Congratulations, you have reached the end of the tutorial. If you have any questions or if you think we can help speed up your development journey, please don't hesitate to get in touch with us here at Mechanical Rock.