Security stands as a foundational element in software development, often taking center stage in architecture decisions and assessments. The approach to security, both in mindset and execution, can differ depending on factors like the intended usage scenario and the specific layers of the system, whether being public-facing, private, isolated, or serving as gateways. Additionally, a significant challenge arises in ensuring security while navigating the complexities of data ownership in multi-tenant software serving diverse customers. Prioritizing data isolation emerges as a crucial compromise, essential for the efficacy of the software solution.
However, in straightforward software systems, the notion of security can be distinct into two phases: Authentication and Authorization. Authentication facilitates the identification of users seeking interaction with the available services. At the same time, Authorization determines granular access permissions, enabling the system to either permit or deny access to the resources owned by the user.
At a high level, this diagram illustrates the functionality of Authentication (AuthN) and Authorization (AuthZ)
This diagram illustrates the public entry points to the internal ecosystem: the Web App, Mobile App, and Gateway. The initial interaction step involves authentication or signing in for users. Users can be real individuals or machines seeking access to services within the internal ecosystem. In the case of mobile or web apps, users typically sign in using account credentials, employing a username and password mechanism to obtain an access token. Gateways use credential grant standards such as authorization code, implicit, client credentials, or resource password (defined by IETF Authorization grant).
The diagram below illustrates the flow of the Authorization Protocol Flow.
Json Web Token ( JWT )
The JWT is often used as a security measure to fulfil security needs. However, for the sake of clarity, let's assume that ' JWT is not inherently Secure'.
The JWT consists of three main parts: the header, payload, and signature.
Header : Provides information about the token, including the algorithm used.
Payload : Contains the claims, such as email, expiration time, and user unique identifier (sub).
Signature : Uses a private key owned by the server to ensure the token's
JWT Attacks:
Signature Stripping: JWTs are signed and contain a signature as the third part of the token. Attackers attempt to recreate an unsigned token to gain unauthorized access. To counter this, the authorization server must validate the token signature as part of the validation process.
CSRF (Cross-Site Request Forgery): Attackers obtain a signed-in user token and attempt to submit requests to the server from another site. To mitigate this attack, it is advisable to avoid using persisted tokens like cookies unnecessarily. If session persistence is necessary, using short-lived tokens can help. Additionally, incorporating extra meta information such as a unique header in requests, generated previously by the server, adds an extra layer of security.
XSS (Cross-Site Scripting): This occurs when injected scripts reside in the browser and attempt to exploit and steal tokens from some legitimate storage, Often injecting using query strings or textboxes and sending requests using the user's logged-in session cookies or local storage. To mitigate XSS attacks, validating and sanitizing the received data on the server side is essential.
Learn about JWT and all related types like JWK, JWE here
OAuth and OIDC
Historically, OAuth 1 was introduced to apply a security mechanism to give access to parties for using resources on behalf of the resource owner without credential sharing, OAuth2 replaces the first protocol version and tries to safeguard server-side resources, either on behalf of the owner by initiating an access approval workflow or allowing the third party software gain access on behalf of the owner. However, a significant issue with OAuth was the sole responsibility of authorization and access control being with the authorization server. This resulted in a lack of fine-grained control on the application side, as the access token did not furnish adequate information for software to validate resource access and ownership.
OIDC, serving as an extended layer, sought to address this deficiency by introducing the ID token. Generated by the server, the ID token provides software with additional user information and metadata, thereby enhancing control and insight into user identities.
This is just a small part of history, learn more in this ebook by Okta: here
Amazon Cognito
Amazon Cognito stands out as an Identity and Access Management service offered by AWS, allowing you to incorporate a managed layer of security into your software. Amazon Cognito User Pools serve as the cornerstone for managing application-level security and adhering to the OAuth standard.
A user pool serves as a repository for application users (e.g., eidivandi@live.com as a user), with pricing based on Monthly Active Users (MAU). Within a user pool, one or more App Clients can be established. An app client represents an isolated client integration, not only ensuring application isolation but also managing Authentication (AuthN) and Authorization (AuthZ) flow isolation.
Cognito also introduces the concept of triggers, where specific actions within Cognito can invoke custom Lambda code, enhancing its extensibility. The diagram below outlines key actions and their corresponding triggers for any given action.
What we gonna build
In this article, our focus is on configuring an authorization server and implementing the impersonation feature to enable access to sub-accounts or customer resources for an already logged-in user. We'll walk through the setup process, following the structure outlined in the accompanying diagram.
The User authentication will be based on Email/Password credentials, allowing establishing a user session, and the impersonation will allow access to multiple tenant resources. This example will delve into the Password authentication flow and Custom authentication flow, demonstrating how they can work together synergistically. Additionally, it aims to offer insights into understanding Custom Authentication flows within Amazon Cognito more effectively.
Source Code
This article source code can be found on GitHub in the following link.
https://github.com/XaaXaaX/aws-cognito-impersonation
User Pool and App Client
Establishing a user pool and app client is straightforward, especially with the assistance of AWS CDK. The advantage of leveraging Cognito user pools lies in their simplicity for standard authentication and authorization procedures.
Cognito user pools offer various authentication flows, including Password, SRP, Admin Password, and Custom. In this article, we will use both the password flow for the Sign-In process and custom flow for impersonation and accessing resources on behalf of a tenant.
Below is a snippet demonstrating the CDK code to create the User pool, along with both password and custom flow based user pool clients:
this.userPool = new UserPool(this, 'MultiTenantUserPool', {
removalPolicy: RemovalPolicy.DESTROY,
accountRecovery: AccountRecovery.NONE,
email: UserPoolEmail.withCognito('eidivandi@live.com'),
selfSignUpEnabled: false,
signInAliases: { email: true },
autoVerify: { email: true },
lambdaTriggers:{
defineAuthChallenge: props.triggers.defineAuthChallenge,
preTokenGeneration: props.triggers.preTokenGeneration,
verifyAuthChallengeResponse: props.triggers.verifyAuthChallengeResponse,
},
customAttributes: {
tenants: new StringAttribute({ mutable: true }),
}
});
this.passwordAuthClient = new UserPoolClient(this, 'MultiTenantPasswordAuthClient', {
userPool: this.userPool,
generateSecret: false,
idTokenValidity: Duration.minutes(5),
accessTokenValidity: Duration.minutes(5),
refreshTokenValidity: Duration.days(1),
authFlows: { userPassword: true }
});
this.secureAuthClient = new UserPoolClient(this, 'MultiTenantSecureAuthClient', {
userPool: this.userPool,
generateSecret: false,
accessTokenValidity: Duration.minutes(5),
refreshTokenValidity: Duration.hours(1),
idTokenValidity: Duration.minutes(5),
authFlows: { custom: true }
});
The UserPool is a source of authentication process and the users source of trust. The two UserPoolClient are the isolated boundaries for the different required auth flows. Having two separate UserPoolClient is just a preference but a single app client can manage different auth flows.
authFlows: { userPassword: true, custom: true}
Sign Up / Sign In
The sign up is managed by a lambda function using AdminCreateUser command. The function creates a user by generating a temporary password and user attributes including some default and reserved ones but also a custom attribute presenting a list of allowed tenants this user can interact with ( use of custom attribute is for article simplicity and can be done using any other type of storage). The function also forces the verification of email, this is done just for the sake of simplicity and to avoid changing the password behind the first signin while testing.
const UserAttributes = [{
Name: 'family_name',
Value: lastName
},
{
Name: 'given_name',
Value: firstName
},
{
Name: 'email',
Value: email
},
{
Name: 'email_verified',
Value: 'true'
},
{
Name: 'name',
Value: `${lastName} ${firstName}`
},
{
Name: 'custom:tenants',
Value: tenants?.join(',')
}];
const adminCreateUserParams = {
UserPoolId: process.env.COGNITO_USER_POOL_ID,
Username: email,
TemporaryPassword: generator.generate({ length: 10, numbers: true, symbols: true, strict: true, exclude: '&%#?+:/;', }),
DesiredDeliveryMediums: ['EMAIL'],
UserAttributes,
ClientMetadata: { step: 'SignUp_CreateUser', }
} satisfies AdminCreateUserCommandInput;
After signing up, an email will be sent with the temporary password, we use this email to signins, the signin function will use InitiateAuth command to authenticate and force the change password challenge by keeping the temporary password ( This step is just for demo and often in production the user will change the password, so the NEW-PASSWORD_REQUIRED challenge will be used to force the user to change the password )
const signInParams: InitiateAuthCommandInput = {
AuthFlow: AuthFlowType.USER_PASSWORD_AUTH,
ClientId: process.env.COGNITO_USER_POOL_CLIENT_ID,
AuthParameters: { USERNAME: username, PASSWORD: password, },
ClientMetadata: { step: 'Signin_InitAuth' }
};
const signinResponse = await client.send(new InitiateAuthCommand(signInParams));
The InitiateAuth response has a session that must be used to trigger the challenge as following snippet demonstrates.
if( signinResponse.ChallengeName === 'NEW_PASSWORD_REQUIRED' ) {
const challengeParams: RespondToAuthChallengeCommandInput = {
ChallengeName: 'NEW_PASSWORD_REQUIRED',
ClientId: process.env.COGNITO_USER_POOL_CLIENT_ID,
ChallengeResponses: {
USERNAME: username,
NEW_PASSWORD: password,
},
Session: signinResponse.Session,
ClientMetadata: { step: 'Signin_Respond_Challenge' }
};
const challengeResponse = await client.send(new RespondToAuthChallengeCommand(challengeParams));
authenticationResult = challengeResponse.AuthenticationResult;
}
The challenge result will include the IdToken, AccessToken and RefreshToken and session that will be returned as the response.
Impersonation
The impersonation step is a custom Auth process including to step process, InitiateAuth and RespondToAuthChallenge command. before looking at how that behaves it is important to see how this custom flow works.
The user pool has 3 lambda trigger:
DefineAuthChallenge
VerifyAuthChallenge
PreTokenGeneration
Triggering the challenge can be done as per AWS Documentation here , when using a Custom auth flow, both the InitiateAuth and RespondToChallenge will trigger the DefineAuthChallenge with VerifyAuthChallengeResponse triggered between two invocations after the challenge verification passes and if the second DefineAuthChallenge invocation response indicates the generation of token the PreTokenGenration trigger will be invoked.
const initiateAuthCommandInput = new InitiateAuthCommand({
AuthFlow: AuthFlowType.CUSTOM_AUTH,
ClientId: clientId,
AuthParameters: { USERNAME: email, },
ClientMetadata: { step: 'Impersonation_InitAuth', tenant, }
});
const response = await client.send(initiateAuthCommandInput);
const challengeCommandInput = new RespondToAuthChallengeCommand({
ChallengeName: 'CUSTOM_CHALLENGE',
ClientId: clientId,
ChallengeResponses: {
USERNAME: response.ChallengeParameters?.USERNAME || '',
ANSWER: 'impersonation',
},
ClientMetadata: {
authFlow: 'impersonation',
step: 'Impersonation_RespondToChallenge',
tenant
},
Session: response.Session,
});
const challengeResponses = await client.send(challengeCommandInput);
Initiate Auth : DefineAuthChallenge
In the first step the DefineAuthChallenge trigger will receive an invocation with an event payload including the user attributes and an empty array of sessions. the sessions array represents the previous steps in auth challenge, for this first invocation the array is empty.This first invocation is triggered by InitiateAuth command.
{
...
"triggerSource": "DefineAuthChallenge_Authentication",
"request": {
"userAttributes": {
"sub": "e29524e4-f0a1-7018-7650-4bee0ca85f4b",
"email_verified": "true",
"cognito:user_status": "CONFIRMED",
"name": "Hills Samir",
"given_name": "Samir",
"custom:tenants": "CUS-01,CUS-02",
"family_name": "Hills",
"email": "eidivandi@live.com"
},
"session": []
},
...
}
The Lambda Function will orchestrates this steps as below and change the response elements.
if ( event.request.session.length === 0 ) {
event.response.issueTokens = false;
event.response.failAuthentication = false;
event.response.challengeName = 'IMPERSONATE_CHALLENGE';
} else if (...) {
....
} else {
event.response.issueTokens = false;
event.response.failAuthentication = true;
}
return event;
Respond Auth Challenge: VerifyAuthChallenge
The RespondToAuthChallenge Command will initiate the rest of flow, and as first step to verify the previously initiated challenge by invoking the VerifyAuthChallenge trigger, this is where the validation verifies if the ChallengeAnswer corresponds to the response provided. As part of the validation, the verification of tenant parameters with the user's custom tenants attributes validates if interacting with this tenant resources is authorized for this user.
if (
event.request.clientMetadata?.tenant &&
event.request.userAttributes?.['custom:tenants']?.split(',')?.includes(event.request.clientMetadata?.tenant)
) {
event.response.answerCorrect = 'impersonation' === event.request.challengeAnswer;
}
return event;
Respond Auth Challenge: DefineAuthChallenge
In the next step the DefineAuthChallenge will be invoked for a second time but the event will be partially different, including more information about previous session initiated and some metadata.
{
...
"triggerSource": "DefineAuthChallenge_Authentication",
"request": {
"userAttributes": {
"sub": "e29524e4-f0a1-7018-7650-4bee0ca85f4b",
"email_verified": "true",
"cognito:user_status": "CONFIRMED",
"name": "Hills Samir",
"given_name": "Samir",
"custom:tenants": "CUS-01,CUS-02",
"family_name": "Hills",
"email": "eidivandi@live.com"
},
"session": [{
"challengeName": "CUSTOM_CHALLENGE",
"challengeResult": true,
"challengeMetadata": null
}],
"clientMetadata": {
"step": "Impersonation_RespondToChallenge",
"authFlow": "impersonation",
"tenant": "CUS-01"
}
},
...
}
Here the ClientMetadata is the only available option to pass the information between different triggers in the same challenge. this helps to share an ephemeral state between invocations. The function handler code will verify the session length and the challenge result has a truthy value.
if ( event.request.session.length === 0 ) {
...
} else if ( event.request.session.length === 1 && event.request.session[0].challengeResult === true ) {
event.response.issueTokens = true;
event.response.failAuthentication = false;
} else { ... }
return event;
As part of the event response, the issueTokens and failAuthentication indicate if a token can be generated or not, or even failing the authentication.
Respond Auth Challenge: PreTokenGeneration
The next step will be the PreTokenGeneration to apply customization of token claims. The default token customization can be applied only to the idToken but advanced security options if activated will give the possibility of access token customization. ( Advanced Security Options applies extra cost )
let tenant;
if( event.triggerSource == 'TokenGeneration_Authentication' && event.request.clientMetadata?.step == 'Impersonation_RespondToChallenge' ) {
tenant = event.request.clientMetadata?.tenant;
};
event.response = {
claimsOverrideDetails: {
claimsToAddOrOverride: {
tenant
},
claimsToSuppress: [],
groupOverrideDetails: {
groupsToOverride: [],
iamRolesToOverride: [],
preferredRole: "",
},
}
};
return event;
The function verifies the trigger source and custom metadata provided in impersonation handler and injects the tenant claim in the token.
Authorizer
As per the diagram shown in What we gonna build section, the solution needs to have two layers of authorization. The first one is to validate the user sessions and authorize them to access protected endpoints, and the second one is to validate and authorize the impersonation token when trying to access the downstream services.
User Session
The first Authorizer is a CognitoAuthorizer attached to protected endpoints like impersonate endpoint. This can be achieved using aws CDK as below
const cognitoPasswordAuthorizer = new HttpUserPoolAuthorizer('CognitoPasswordUserPoolAuthorizer', props.cognito.userPool, {
userPoolClients: [props.cognito.passwordAuthClient]
});
...
api.addRoutes({
path: '/user/impersonate',
methods: [HttpMethod.POST],
integration: new HttpLambdaIntegration('ImpersonassionAuthFunctionIntegration', impersonassionAuthFunction),
authorizer: cognitoPasswordAuthorizer
});
This authorizer will validate the signin token generated behind email and password authentication, to prevent unauthorized access to impersonation endpoint.
Impersonation
The impersonation authorier is a Lambda CustomAuthorizer integrating with ApiGateway and validates that the authorization token received is allowed to access the tenant resources. For this article's simplicity we use a single apigatway shared between downstream and authorization service but add a Lambda Authorizer attached at the route level for the downstream endpoint.
const customAuthorizer = new LambdaFunction(this, 'CustomAuthorizer', {
entry: resolve(join(__dirname, '../../src/authorizer/handler.ts')),
bundling: {
banner: `import { createRequire } from 'module';const require = createRequire(import.meta.url);`,
},
environment: {
COGNITO_USER_POOL_CLIENT_ID: props.cognito.secureAuthClient.userPoolClientId,
COGNITO_USER_POOL_ID: props.cognito.userPool.userPoolId,
TABLE_NAME: props.table.tableName
}
});
const cognitoImpersonationAuthorizer = new HttpLambdaAuthorizer('CognitoImpersonationAuthorizer', customAuthorizer , {});
The authorizer validates the Token against cognito userpool and client app.
const token = event.authorizationToken!.replace('Bearer ', '');
const decodedToken = decodeAndGetToken(token);
const user = await verifyToken(token, decodedToken.token_use, process.env.COGNITO_USER_POOL_CLIENT_ID!);
The verifyToken method uses the ' aws-jwt-verify' to validate the token using cognito userpool client.
const verifierParams = {
userPoolId: process.env.COGNITO_USER_POOL_ID!,
tokenUse: use as CognitoVerifyProperties['tokenUse'],
clientId: process.env.COGNITO_USER_POOL_CLIENT_ID!,
};
const verifier = CognitoJwtVerifier.create(verifierParams); return verifier.verify(token, verifierParams);
The authorizer returns a policy allowing or denying access to invoke api gateway
return {
principalId: 'user',
policyDocument: {
Version: '2012-10-17',
Statement: [{
Action: 'execute-api:Invoke',
Effect: 'Allow',
Resource: event.methodArn,
}],
},
};
The authorizer simply validates the sanity of the token and if it was originally owned by authorization server, but the fact of allowing that token to access tenant resources must be under the responsibility of downstream.
Lets say, that omid@gmail.com signed in and asked to impersonate the tenant CUS-01 now the generated token will be validated even if the requested resource to downstream is owned by CUS-02, this is the responsibility of the downstream service to validate if the authorized token corresponds well the CUS-02 tenant or not.
In the following section, we will see this in practice and deploy the sample solution.
Running the authorization server
https://github.com/XaaXaaX/aws-cognito-impersonation
The source code provides a brief README to follow and deploy the solution, and a postman collection and environment are also provided to simplify the testing purpose.
To start and test the solution lets start by signing up, Amazon Cognito will send an email containing a temporary password.
After account creation, an email will be received containing the temporary password
Using this password the sign-in endpoint allows to establish a session using the password auth flow. Passing the received IdToken from the sign-in step as an authorization header lets the cognito authorizer identify the session and validate the access to impersonate api route.
The Impersonation token will be generated by custom flow and must be passed along the call to the downstream, the custom lambda authorizer will validate the generated token, the responsibility of validating the resource is by downstream service to check the asked resource and validate it against the authorizer context provided.
In this example the downstream verifies the requested resource against authorization token context.
try {
const eventBody = JSON.parse(event.body || '{}');
if( eventBody.tenant !== event.requestContext.authorizer?.lambda.tenant ) {
throw new Error('Tenant does not match');
}
return ActionResults.Success(eventBody);
} catch (e) {
console.error(e);
return ActionResults.InternalServerError({ message: e.message || e.name });
}
Conclusion
Security being part of our daily software development is not always simple such as providing an Api key or a JWT token, and when it gets complicated, this is important to think about shared responsibility. Understanding the Security foundation like AuthN, AuthZ and different components such as Client, Authorization server, and Resource Server will help to better give responsibility and scopes to each part of the communication flow.
Applying standards is not always free of effort and needs a higher level of deep understanding so looking at some resources like IETF helps to achieve a deeper vision and perspective.
Here some resources:
JWT ( rfc7519 ) : https://datatracker.ietf.org/doc/html/rfc7519#page-9
OAuth2 ( rfc6749 ) : https://datatracker.ietf.org/doc/html/rfc6749#page-7
Hope this article will be useful.