Cognito based authentication for CloudFront protected resources

By Gerald Mücke | September 21, 2017

Cognito based authentication for CloudFront protected resources

Cognito is a relatively new offering proving Identity Management for Apps and Services, including profile management and multi-factor authentication. CloudFront is the Content Delivery Network service provided by Amazon Web Services. CloudFront offers publicly accessible content as well as private content. Private content can be access using either signed URLs or Signed Cookies. Cognito however generates OAuth access tokens. This article describes how to build a service for creating Signed Cookies for Cloudfroint using access control provided by Cognito.

Setting up Cognito Client

First, let’s connect to Cognito. Amazon provides a feature richt SDK for it’s services, including Cognito. For Cognito we need the following Maven dependency in our project:

<dependency>
    <groupId>com.amazonaws</groupId>
    <artifactId>aws-java-sdk-cognitoidp</artifactId>
    <version>1.11.176</version>
</dependency>

Further we need the poolId and the clientId and optionally the secret hash if your pool is configured to use one. All information are obtainable from the Cognito service.

Then we create a Cognito client instance using the builder

AWSCognitoIdentityProviderClientBuilder builder = AWSCognitoIdentityProviderClientBuilder.standard();

If you haven’t configured the AWS CLI for your local system and therefore have no region configured - which is usually the case for servers - you have to provide the region of your Cognito pool.

cognitoBuilder.setRegion("us-east-1");

Now create the client:

AWSCognitoIdentityProvider client = builder.build();

One-Step Authentication

All authentication workflows are initiated the same way. Depending on whether the pool or user requires multi-factor-authentication, an additional step is required, described in the next section. If the user logs in for the first time, there is always a challenge to set a new password. But once the password is set, the initial auth step is the final step for one-step authentication.

To initiate authentication, the user must populate a map containing the authentication parameters and create an AdminInitiateAuthRequest. We use the admin auth flow because the code is supposed to run on a secure backend server 1 and performs the authentication on behalf of the actual user in order to provide the service of creating CloudFront signed cookies later on. An app that uses Cognito would perform a “normal” initiate auth step.

final String username = ...;
final String password = ...;
final String secretHash = ...; //optional

final Map<String, String> authParams = new HashMap<>();
authParams.put("USERNAME", username);
authParams.put("PASSWORD", password);

if (secretHash != null) {
  authParams.put("SECRET_HASH", secretHash);
}

final AdminInitiateAuthRequest authRequest = new AdminInitiateAuthRequest();
authRequest.withAuthFlow(AuthFlowType.ADMIN_NO_SRP_AUTH)
           .withClientId(clientId)
           .withUserPoolId(poolId)
           .withAuthParameters(authParams);

Once we have created the request, we could submit it to the client and receive an AuthenticationResult

AdminInitiateAuthResult result = client.adminInitiateAuth(authRequest);

The result may either indicate a success or a further challenge for the application to respond to. In case the user logs in for the first time - as described above - Cognito will respond with a new-password-challenge.

if("NEW_PASSWORD_REQUIRED".equals(result.getChallengeName()){

//we still need the username
String username = ...; 

final Map<String, String> challengeResponses = new HashMap<>();
challengeResponses.put("USERNAME", username);
challengeResponses.put("PASSWORD", credentials.getString("password"));

//add the new password to the params map
challengeResponses.put("NEW_PASSWORD", credentials.getString("password_new"));

//populate the challenge response
final AdminRespondToAuthChallengeRequest request = new AdminRespondToAuthChallengeRequest();
request.withChallengeName(ChallengeNameType.NEW_PASSWORD_REQUIRED)
       .withChallengeResponses(challengeResponses)
       .withClientId(clientId)
       .withUserPoolId(poolId)
       .withSession(credentials.getString("session"));

Important here is, that during the new-password-challenge missing but required user attributes need to be passed as well. The response to the init-auth-request contains challenge parameters.

Map<String,String> challengeParmas = result.getChallengeParameters()

This map contains at least two entries:

  • requiredAttributes : a string-encoded JsonArray with the user attributes that are required
  • userAttributes : a string-encoded JsonObject with the current user attributes

For example:

"requiredAttributes" : "[\"userAttributes.family_name\",\"userAttributes.given_name\"]",
"userAttributes" : "{\"email_verified\":\"true\",\"phone_number_verified\":\"true\",\"phone_number\":\"+12345\",\"given_name\":\"\",\"family_name\":\"\",\"email\":\"me@you.us\"}"

To set the required parameters, the user attributes must be set in the params map using the same attribute name as in the requiredAttributes list, that is, including the userAttributes. prefix.

challengeResponses.put("userAttributes.myAttributed", "attrValue");

Once you got the parameters for new password and required user attributes ready, you may send the response to Cognito

AdminRespondToAuthChallengeResult result = client.adminRespondToAuthChallenge(request)

If the result of any challenge response or the initial-auth request does not contain a challenge name but a AuthenticationResultType instead, the authentication was successful. The result of the successful authentication contains the OAuth access tokens and the user information. You may keep those in a session or cookie or just discard it because we now want to create the signed cookies for CloudFront and therefore only need the information that the user is who she is supposed to be.

But before we jump to the CloudFront part, let’s look - for the sake of completeness - on the

Two-Step Authentication

At this point we either have initiated authentication successfully or optionally have already set a new password. The user is still not authenticated, and the result of both of the operations contains a challenge of name SMS_MFA. This indicates the user received a one-time token via SMS (or email) which she is supposed to enter.

The procedure is basically the same as for the new-password-challenge, we enter the additional details:

if("SMS_MFS".equals(result.getChallengeName()){

final String username = ...; //we stil need the user name
final String mfaCode = ...; //the user has to enter this code

final Map<String, String> authParams = new HashMap<>();
authParams.put("USERNAME", username);
authParams.put("SMS_MFA_CODE", mfaCode);

final AdminRespondToAuthChallengeRequest mfaRequest = new AdminRespondToAuthChallengeRequest();
mfaRequest.withChallengeName(ChallengeNameType.SMS_MFA)
          .withChallengeResponses(authParams)
          .withClientId(clientId)
          .withUserPoolId(poolId)
          .withSession(credentials.getString("session"));

And send the challenge response-request to Cognito

AdminRespondToAuthChallengeResult result = client.adminRespondToAuthChallenge(request)

Now the user is hopefully authenticated so we could continue with

Create Signed Cookies

Before we begin, for all signatures you require a private key. For AWS you can generate your keypair for CloudFront in the IAM console. Under “My Security Credentials” you find your “CloudFront KeyPairs” where you could generate a new keypair or upload an existing one. In any case you need to note down the keypairId which need later.

The received keys are in PEM format, which is not that easy to read in Java. So you need to convert it to another format, i.e. DER. This can be done with openssl:

openssl pkcs8 -topk8 -nocrypt -in <keyfile.pem> -inform PEM -out <keyfile.der> -outform DER

Now we have our key. Reading the key from the file into a RSAPrivateKey requires just four lines:

final byte[] privKeyByteArray = Files.readAllBytes(Paths.get("path/to/keyfile.der"));
final PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(privKeyByteArray);
final KeyFactory keyFactory = KeyFactory.getInstance("RSA");
final PrivateKey privateKey = keyFactory.generatePrivate(keySpec);

In addition to the keypairId and the key itself we need to know the distribution domain of your CloudFront distribution, that is the domain name of your distribution including the optional subdomain, without any protocol, port or path. For example test.mydomain.com. This must match your distribution’s CNAME.

Before we start creating the cookies, we must add the CloudFront SDK api to our maven dependencies:

<dependency>
    <groupId>com.amazonaws</groupId>
    <artifactId>aws-java-sdk-cloudfront</artifactId>
    <version>1.11.176</version>
</dependency>

Creating the signed cookie is actually creating three cookies in one step:

  • the policy describing what you’re allowed to access
  • the signature
  • the keypairId to verify the signature

All three cookies are contained in object, the CookiesForCustomPolicy

//you have to provide these three parameters
final String distributionDomain = ...;
final String keypairId = ...;
final PrivateKey privateKey = ...;
 
//the protocol to access your CloudFront distribution, https is recommended
final Protocol protocol = Protocol.https;

//for which resource do you want to create the signed cookies, use * for all
final String resourcePath = "*";

//the date when the cookie expires, use the following string for 30-day time-to-live
final Date expireDate = Date.from(LocalDate.now().plus(Period.ofDays(30)).atStartOfDay(ZoneId.systemDefault()).toInstant())

//the CIDR ip range for accessing client, leave null for no limitation
final String ipRange = null;

CookiesForCustomPolicy cookies = CloudFrontCookieSigner.getCookiesForCustomPolicy(protocol,
                                                                                  distributionDomain,
                                                                                  privateKey,
                                                                                  resourcePath,
                                                                                  keypairId,
                                                                                  expireDate,
                                                                                  ipRange);

Now you have all your cookies in one file, now you could extract the cookies via

cookies.getPolicy();
cookies.getSignature();
cookies.getKeyPairId();

All of these can be set as domain cookies with a web framework of your choice to grant access to restricted CloudFront pages.

comments powered by Disqus