Skip to main content

Achieving Seamless Navigation through AWS Cognito

Seamless Navigation with AWS Cognito

Cognito is an identity and access management solution of the AWS ecosystem. It allows you to secure your applications and manage your users credentials and helps you control access management. Cognito provides capabilities to secure applications with SAML, OIDC and OAUTH2 protocols.

You can find more information on Cognito on Amazon's web site here: https://aws.amazon.com/cognito/. Especially checkout the developer documentation here: https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-identity-pools.html.

Background

There is a lot to cover in AWS Cognito. This article will be focusing on how to achieve seamless navigation between two different client applications. You can consider this scenario as a federated scenario suing SAML or OIDC federation. However, I wanted to cover a scenario where web profile isn't available in one of those applications so you don't have an IDP Session to help you login to your secondary application.

To explain further, let's just assume that we have a native iOS or ANDROID application. Let's also assume this mobile application is integrated with AWS Cognito through iOS or ANDROID SDK. You can check out how to integrate AWS SDKs with your mobile applications here https://docs.aws.amazon.com/sdk-for-ios/index.html or https://docs.aws.amazon.com/sdk-for-android/?icmpid=docs_homepage_fewebmobile.

When you establish an authentication for your mobile applications in AWS Cognito, you can configure how long your OAUTH2 related id, access and refresh tokens will be valid. Using these tokens you can gain access to AWS resources through your mobile applications including calling AWS API Gateway endpoints.

Let's also assume in this article, we want to use the mobile application to launch a browser based application which would log the user in automatically. In this case, since mobile application doesn't store any of the credentials, we won't be able to perform re-authentication. You can consider setting up a federated single sign on but since the application is native and not browser based there will be challenges of maintaining an IDP session which would allow you to login between federated applications. Another challenge with the mobile application is that users can stay logged in for a very long time due to the OAUTH2 token expiration configurations. If you have a refresh_token that expires every 60 days, AWS IOS/ANDROID SDK would be using refresh_tokens to get new access_tokens as resources are accessed. This would prove challenging because IDP Sessions that allows you federate usually is kept very short for security reasons and would ask the users to login.

In summary, we have
  • A Mobile Application that is AWS Cognito integrated with AWS Amplify SDKs
  • A Web Application that is AWS Cognito integrated with AWS Amplify SDKs
  • The Mobile Application has a long refresh_token so user can stay logged in for months
  • The Mobile Application can initiate a page view in native browser of the established web application.
  • AWS Cognito Custom Authentication Flow
  • In order to achieve the above, we are going to look into how we might be able to use AWS Cognito's custom authentication flows.
Following AWS document talks about how to achieve Custom Authentication with AWS Cognito https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-authentication-flow.html#amazon-cognito-user-pools-custom-authentication-flow.

Following snippet is from AWS documentation and really explains well what would be happening with the custom authentication flow.

"The custom authentication flow makes possible customized challenge and response cycles to meet different requirements. The flow starts with a call to the InitiateAuth API operation that indicates the type of authentication to use and provides any initial authentication parameters. Amazon Cognito responds to the InitiateAuth call with one of the following types of information:
A challenge for the user, along with a session and parameters.
An error if the user fails to authenticate.
ID, access, and refresh tokens if the supplied parameters in the InitiateAuth call are sufficient to sign the user in. (Typically the user or app must first answer a challenge, but your custom code must determine this.)

If Amazon Cognito responds to the InitiateAuth call with a challenge, the app gathers more input and calls the RespondToAuthChallenge operation. This call provides the challenge responses and passes it back the session. Amazon Cognito responds to the RespondToAuthChallenge call similarly to the InitiateAuth call. If the user has signed in, Amazon Cognito provides tokens, or if the user isn't signed in, Amazon Cognito provides another challenge, or an error. If Amazon Cognito returns another challenge, the sequence repeats and the app calls RespondToAuthChallenge until the user successfully signs in or an error is returned. For more details about the InitiateAuth and RespondToAuthChallenge API operations, see the API documentation.
"


Above diagram is taken from AWS documentation that explains how these challenge sequences are executed.

Architecture

Here is an example architecture that you can implement to achieve this type of authentication.



In this architecture you have got
  • Mobile Application that has been integrated with Cognito and AWS API Gateway
  • token-generator is an application that is exposed through API Gateway with OAUTH2 tokens
  • token-db is used to store temp tokens
  • 3 lambda functions that allows us to implement Cognito's custom authentication flows
Here is a sequence of events that would be happening in this set up.

Step 1

Once a user logged into the mobile application, Mobile Application makes a request through API Gateway to generate a new token. This token would be generated by token-generator service. This is exposed through API Gateway which checks against access_token verification and validation. Meaning only authenticated Mobile Application users can request a new token.

Step 2

token-generator once receives the request can use the access_token to load user attributes from Cognito's UserInfo endpoint. This can be useful if you wanted to use any of these in your own JWT code generation. 
GET https://your-user-pool-domain/oauth2/userInfo
 Authorization: Bearer access_token
HTTP/1.1 200 OK Content-Type: application/json;charset=UTF-8 { "sub": "248289761001", "name": "Jane Doe", "given_name": "Jane", "family_name": "Doe", "preferred_username": "j.doe", "email": "janedoe@example.com" }
You can use simple NodeJS libraries to be able to create a JWT token in your token-generator module.
  • const jwt = require("jwt-simple");
  • const { v4: uuidv4 } = require("uuid");
Here is a simple generateToken function that can use these libraries to generate a JWT.
//Generates a JWT for the user, sign it with our private RSA256 key. The token is persisted to database so we can verify it in the future.
exports.generateToken = async (sub) => {
    logger.info(
        "Generating JWT for user: sub=%s, impersonatorSub=%s",
    sub
    );

    const now = new Date();

    const payload = {
        iss: DEFAULT_ISS,
        sub,
        aud: DEFAULT_ISS,
        exp: Math.trunc(addMinutes(now, EXPIRY_MINUTES).getTime() / 1000),
        iat: Math.trunc(now.getTime() / 1000),
        jti: uuidv4(),
    };
    logger.debug("Token payload = %j", payload);
    const privateKey = await getTokenSigningPrivateKey();
    const token = jwt.encode(payload, privateKey, "RS256");
    logger.debug("Generated Token: %s", token);

    // Persist the token so it can be used during validation
    await saveToken(payload, token);

    return token;
};
You can read all about the JWT specification here: https://www.rfc-editor.org/rfc/rfc7519. In this case, we are using the basic and required attributes to generate a JWT. We are also signing this JWT so that in can be trusted by our applications. You will need to keep your private key in a secure place to load and encode the JWT with it. You will also be using it to verify the signature of the signed JWT when its submitted by the Web Application in the following steps.

Recommendation here would be to keep EXPIRY_MINUTES to a minimum since this token will be used by the mobile application to log the user into the Web Application at the time of URL visit.

Step 3

Once this newly generated JWT token is returned to the mobile application, mobile application would invoke a URL in your web application in the devices native browser. This URL could look something like

https://WEB_APP_URL/app?jwtToken=<generatedJWTToken>

Step 4

This step will involve the Web Application to initiate a custom authentication when the above URL is invoked by the Mobile Application.

You can invoke Custom Authentication with Cognito by calling the initiateAuth API with out password. This is what the Web Application must do to kick off the authentication process using this JWT Token.

In this step, multiple lambda trigger functions will be executed by AWS Cognito to accomplish and complete the workflow as explained above. I will share the following code snippets with you so you can actually see how this works.

DefineAuthChallenge

exports.handler = async event => {
    if (!event.request.session || event.request.session.length === 0) {
        // If we don't have a session or it is empty then send a CUSTOM_CHALLENGE
        event.response.challengeName = "CUSTOM_CHALLENGE"
        event.response.failAuthentication = false
        event.response.issueTokens = false
    } else if (
        event.request.session.length === 1 &&
        event.request.session[0].challengeResult === true
    ) {
        // If we passed the CUSTOM_CHALLENGE then issue token
        event.response.failAuthentication = false
        event.response.issueTokens = true
    } else {
        // Something is wrong. Fail authentication
        event.response.failAuthentication = true
        event.response.issueTokens = false
    }
    return event
}

CreateAuthChallenge

exports.handler = async event => {
    if (!event.request.session || event.request.session.length === 0) {
        event.response.publicChallengeParameters = {
            jwtRequired: "true"
        };
        event.response.challengeMetadata = "CUSTOM_CHALLENGE";
    }
    return event;
};

VerifyAuthChallenge

//we would have to go out to a service to verify this JWT value.
//That access would be through api gateway.
function isJwtValid(jwtValue) {
    return jwtValue === 'jwtValue';
}

exports.handler = async (event, context) => {

    if (isJwtValid(event.request.challengeAnswer)) {
        event.response.answerCorrect = true
        //delete the token here so that it can't be used anymore
    } else {
        event.response.answerCorrect = false
    }

    return event
}
At the end of this process if the given answer is correct, AWS Cognito would be creating a new id, access and refresh tokens for the Web Application.

Here is an example client code in JAVA that you can easily test this set up with AWS Cognito even if you don't have access to a WEB Application.

//Username is required for CUSTOM_AUTH flow.
//Note: userPoolId is only required by AdminInitiateAuth, it would not be provided by web to InitiateAuth
Map < String, String > authParameters = new HashMap < > ();
authParameters.put("USERNAME", "john.doe");

//This execution makes the call. This will get a challenge issued per our configuration of Cognito Pool and Triggers.
AdminInitiateAuthRequest adminInitiateAuthRequest = AdminInitiateAuthRequest.builder()
    .authFlow(AuthFlowType.CUSTOM_AUTH).clientId(clientId).userPoolId(userPoolId)
    .authParameters(authParameters).build();


AdminInitiateAuthResponse response = cognitoIdentityProviderClient.adminInitiateAuth(adminInitiateAuthRequest);
Map < String, String > challengeParameters = response.challengeParameters();
String session = response.session();
CognitoIdentityProviderResponseMetadata responseMetadata = response.responseMetadata();

//We must use the Respond To Auth Challenge Request (which is available also on the browser) to respond to this challenge.
//This is where we would be sending the JWT value to Cognito. This is what Web Application would be doing.
Map < String, String > responses = new HashMap < > ();
responses.put("ANSWER", "jwtValue");
responses.put("USERNAME", "john.doe");

//Since Lamda triggers are part of a state-machine, session must be used to respond to a challenge.
AdminRespondToAuthChallengeRequest adminRespondToAuthChallengeRequest = AdminRespondToAuthChallengeRequest
    .builder().challengeResponses(responses).clientId(clientId).userPoolId(userPoolId).session(session)
    .challengeName(
        ChallengeNameType.CUSTOM_CHALLENGE)
    .build();
AdminRespondToAuthChallengeResponse finalResponse = cognitoIdentityProviderClient
    .adminRespondToAuthChallenge(adminRespondToAuthChallengeRequest);

//Once this all works - you will get an access token created for the WEB Application.
System.out.print(finalResponse.authenticationResult().accessToken());

Conclusion

With this approach, we generated a short lived, one time use JWT token to log a user from Mobile Application to a Web Application using Cognito's Custom Authentication workflow.

Comments