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 requireduserAttributes
: 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.