Distributing restricted static content through CloudFront using signed cookies.
Tags: devops, aws, cloudfront
Published: 2022-11-21 14:47:55

Many times it's a business requirement that the content you share over the internet through your application including documents, business data, or media streams is intended to be consumed by selected users, for example, users who have paid a fee. Suppose that you have a similar situation and you are using an AWS CloudFront distribution (CDN) to distribute the static content including documents, images, and videos. But you want a restriction that this content is accessible through your app only to a set of selected users. Then we can use **signed URLs** and **signed cookies**.
CloudFront signed URLs and signed cookies provide the same basic functionality. They allow you to control who can access your content but both are beneficial in entirely different situations based on different requirements. 
E.g.
- Using signed URLs is beneficial when you want to restrict access to individual files, for example, an installation download for your application.
- Using signed cookies is beneficial when you want to provide access to multiple restricted files, and don't want to change your current URLs.

In this blog, we will discuss about using signed cookies to grant access to your private content to the relevant user using CloudFront.
Signed URLs take precedence over signed cookies if you use both signed URLs and signed cookies to control access to your content.

**How signed cookies works?**
Here is an overview of how signed cookies work.
1. In your CloudFront distribution, specify one or more trusted key groups, which contain the public keys that CloudFront can use to verify the URL signature. You use the corresponding private keys to sign the URLs.
Since you use private keys to sign the URLs you must store them somewhere and we will store them in **AWS Secrets Manager**.
To create a KeyPair and to add a signer (trusted key group) in CloudFront distribution see the procedure [here](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-trusted-signers.html).
2. You develop your application to determine whether a user should have access to your content and if so, to send **three Set-Cookie headers** to the viewer. (Each Set-Cookie header can contain only one name-value pair, and a CloudFront signed cookie requires three name-value pairs.) You must send the Set-Cookie headers to the viewer before the viewer requests your private content. 
Typically, your CloudFront distribution will have at least two cache behaviors, one that doesn't require authentication and one that does. The error page for the secure portion of the site includes a redirector or a link to a login page. If you configure your distribution to cache files based on cookies, CloudFront doesn't cache separate files based on the attributes in signed cookies.
3. A user signs in to your website and either pays for content or meets some other requirement for access.
4. Your application returns the Set-Cookie headers in the response, and the viewer stores the name-value pairs.
5. The user requests a file. The user's browser or other viewer gets the name-value pairs from step 4 and adds them to the request in a Cookie header. This is the signed cookie.
6. CloudFront uses the public key to validate the signature in the signed cookie and to confirm that the cookie hasn't been tampered with. If the signature is invalid, the request is rejected. If the signature in the cookie is valid, CloudFront looks at the policy statement in the cookie (or constructs one if you're using a canned policy) to confirm that the request is still valid. If the request meets the requirements in the policy statement, CloudFront serves your content as it does for content that isn't restricted: it determines whether the file is already in the edge cache, forwards the request to the origin if necessary, and returns the file to the user.

![CloudFront Sign cookies solution flow](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ckxycdb0cqiuh5jdem0f.png)

There are two kinds of policies used for signed cookies
- Canned policies 
- Custom policies

When you create a signed cookie, you write a policy statement in JSON format that specifies the restrictions on the signed cookie, for example, how long the cookie is valid. You can use canned policies or custom policies. 
Canned policies allow you to specify an **expiration date** only. Custom policies allow more complex restrictions.

**Canned policy example**:
```json
{
    "Statement": [
        {
            "Resource": "base URL or stream name",
            "Condition": {
                "DateLessThan": {
                    "AWS:EpochTime": ending date and time in Unix time format and UTC
                }
            }
        }
    ]
}
```

**Custom policy example**:
```json
{
    "Statement": [
        {
            "Resource": "URL of the file",
            "Condition": {
                "DateLessThan": {
                    "AWS:EpochTime":required ending date and time in Unix time format and UTC
                },
                "DateGreaterThan": {
                    "AWS:EpochTime":optional beginning date and time in Unix time format and UTC
                },
                "IpAddress": {
                    "AWS:SourceIp": "optional IP address"
                }
            }
        }
    ]
}
```
Since we want to give access to all of the files located on a certain path of our CloudFront URL using wildcard `*` therefore we will use a custom policy.
For example, we want to give access to all of the files located on this URL: `https://content.mysite.com/private-content/*`
Here is the simple code in PHP for creating signed cookies using custom policy.

```php
<?php

require 'vendor/autoload.php';

use Aws\Credentials\Credentials;
use Aws\SecretsManager\SecretsManagerClient;
use Aws\CloudFront\CloudFrontClient;
use Aws\Exception\AwsException;

// Check if user is login or check if user is paid the fee


// Initialize AWS credentials from IAM role
$credentials_uri = getenv('AWS_CONTAINER_CREDENTIALS_RELATIVE_URI');
$credentials_url = 'http://169.254.170.2' . $credentials_uri;
$get_credentials = file_get_contents($credentials_url);
$credentials_array = json_decode($get_credentials, true);
$credentials = new Credentials($credentials_array["AccessKeyId"], $credentials_array["SecretAccessKey"], $credentials_array["Token"], strtotime($credentials_array["Expiration"]));

// Create a Secrets Manager Client
$secretManagerClient = new SecretsManagerClient([
    'credentials' => $credentials,
    'version' => '2017-10-17',
    'region' => 'us-east-1',
]);

$secretName = 'private-content-access-key';

try {
    $secretResult = $secretManagerClient->getSecretValue([
        'SecretId' => $secretName,
    ]);

}
catch (AwsException $e) {
    // output error message if fails
    echo $e->getAwsErrorMessage();
    echo "\n";
}


function signCookie($cloudFrontClient, $policy, $privateKey, $keyPairId)
{
    try {
        $result = $cloudFrontClient->getSignedCookie([
            'policy' => $policy,
            'private_key' => $privateKey,
            'key_pair_id' => $keyPairId
        ]);

        return $result;

    }
    catch (AwsException $e) {
        return ['Error' => $e->getAwsErrorMessage()];
    }
}

function signACookie($privateKey)
{
    $resourcePath = 'https://content.mysite.com/private-content/*';
    $expires = time() + 3600; // 60 minutes (60 * 60 seconds) from now.
    $keyPairId = 'ABCDWXYZ';
    $policy =
    '{'.
        '"Statement":['.
            '{'.
                '"Resource":"'. $resourcePath . '",'.
                '"Condition":{'.
                    '"DateLessThan":{"AWS:EpochTime":' . $expires . '}'.
                '}'.
            '}'.
        ']' .
    '}';

    $cloudFrontClient = new CloudFrontClient([
        'credentials' => $credentials,
        'version' => '2014-11-06',
        'region' => 'us-east-1'
    ]);

    $result = signCookie(
        $cloudFrontClient,
        $policy,
        $privateKey,
        $keyPairId
    );

    $cookies = array();

    foreach ($result as $key => $value) {
        array_push(
            $cookies,
            array(
            'name' => $key,
            'value' => $value,
            'expires' => $expires,
            'path' => '/',
            'domain' => ".mysite.com",
            'secure' => true,
            'httpOnly' => true
        )
        );

    }

    foreach ($cookies as $cookie) {
        setcookie
            (
            $cookie['name'],
            $cookie['value'],
            $cookie['expires'],
            $cookie['path'],
            $cookie['domain'],
            $cookie['secure'],
            $cookie['httpOnly']
        );
    }
}

if (isset($secretResult['SecretString'])) {
    $privateKey = $secretResult['SecretString'];
    signACookie($privateKey);
}

// Give access to the content e.g https://content.mysite.com/private-content/doc.pdf



```

You can easily write similar code in Node.js (Javascript).
The most important function to consider from the CloudFront client is **getSignedCookie()** which takes three parameters: policy, private key, and key pair id (this is not key group Id).
This function returns a response with the following values.
- CloudFront-Policy=base64 encoded version of the policy statement;
- CloudFront-Signature=hashed and signed version of the policy statement; 
- CloudFront-Key-Pair-Id=public key ID for the CloudFront public key whose corresponding private key you're using to generate the signature;

Finally, we will set the cookies by using values returned in the response.