Amazon CloudFront alongside media services and serverless technologies from AWS make it easy to protect your video stream from unauthorized viewing. Whether it’s to ensure audiences have the right subscription to view the content, or that sensitive information can only be seen by permissioned viewers, there are many reasons content providers need to secure their video streams.

This two-part blog post explains step by step how to set up Amazon Cognito with AWS Lambda and CloudFront to control access to a video application that uses signed cookies to authorize access to media manifest files and video segments served by AWS Elemental MediaPackage.

A foundational knowledge of Lambda, CloudFront, and AWS CloudFormation is recommended before starting this tutorial. Some experience with JavaScript, or other scripting languages, and HTML is also recommended.

Diagram of workflow described in blog

Setting up Amazon CloudFront key pairs for signing cookies

Important: To use signed cookies with Amazon CloudFront, you need to have root credentials to the AWS account that will act as the Trusted Signer to create Amazon CloudFront key pairs.

Follow steps 1-8 under heading To create CloudFront key pairs in the AWS Management Console found in the Creating CloudFront Key Pairs for Your Trusted Signers documentation.. Make note of the Access Key ID for the key pair. You will need this later to deploy the updated CloudFormation stack.

Deploy the live streaming solution

To simplify set up of the live streaming infrastructure, we are extending an existing AWS Solution that provides a CloudFormation template to easily set up the required infrastructure. The original solution, Live Streaming on AWS, deploys AWS Elemental MediaLive, AWS Elemental MediaPackage, Amazon CloudFront, and Amazon S3 buckets for the web application and application logs.

To start, launch the solution either directly from the solutions page by clicking Launch solution in the AWS Console. You can also download the AWS CloudFormation template for the solution and follow the solution deployment instructions. Be sure to select Nodejs12.x from the Source Code option list. Download a copy of the template for updating in the following sections.

The AWS CloudFormation template in this solution includes set up of a CloudFront distribution, which typically takes between 20 and 40 minutes. For this tutorial, it is recommended to deploy the initial solution at this point, although you do not have to wait for it to complete before continuing.

Note: Signed cookies aren’t supported for RTMP distributions and for these you need to use signed URLs instead. Instructions on how to set up signed URLs for CloudFront can be found here. Note that using signed URLs for live video streams require each URL in the streaming manifest file to be signed by the source.

The rest of the instructions in this post involve editing the CloudFormation template mentioned previously. If you haven’t already, download the template from the Live Streaming on AWS solution and save the file locally so that it can be updated with the configurations to add signed URLs.

Once you download the file, edit it using your preferred text editor according to the instructions detailed in the following steps. Then, update the AWS CloudFormation stack you deployed earlier with the modified template using the documentation for Updating Stacks Directly.

Note: Any code snippets that are included in the following steps should be applied to the live-streaming-on-aws.template file unless explicitly specified.

Setting up Amazon Cognito

Amazon Cognito provides a simple and secure way to manage users and access control to your application and content. This solution uses a simple Amazon Cognito user pool to allow only authenticated users to load the video stream.

Add the following two statements for the Amazon Cognito username and user email in the Parameters section of the template.

CognitoUserNameParam:
  Description: Username of the user in the Cognito User Pool
  Type: String
  Default: "demo-user"
CognitoUserEmailParam:
  Description: Email of the user in the Cognito User Pool
  Type: String
  AllowedPattern: ^[a-z0-9!#$%&'*+/=?^_‘{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_‘{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$

Then add the following parameter group labels to the ParameterGroups section of the AWS::CloudFormation::Interface metadata.

-
  Label:
    default: Cognito User Details
  Parameters:
    - CognitoUserNameParam
    - CognitoUserEmailParam

Also add the parameter labels to the ParameterLabels section of the AWS::CloudFormation::Interface metadata.

CognitoUserNameParam:
  default: Cognito User Name
CognitoUserEmailParam:
  default: Cognito User Email

Then add the actual Amazon Cognito resource to the Resources section.

CognitoUserPool:
    Type: AWS::Cognito::UserPool
    Properties:
      UserPoolName: MediaStreamingUserPool
      
CognitoUserPoolAppClient:
    DependsOn: CognitoUserPool
    Type: AWS::Cognito::UserPoolClient
    Properties:
      UserPoolId: !Ref CognitoUserPool
      AllowedOAuthFlows:
        - implicit
      AllowedOAuthFlowsUserPoolClient: True
      AllowedOAuthScopes:
        - email
        - openid
      ClientName: LiveStreamingAppClient
      CallbackURLs:
        - !Sub "https://${CloudFront.DomainName}/login.html"
      DefaultRedirectURI: !Sub "https://${CloudFront.DomainName}/login.html"
      SupportedIdentityProviders:
        - COGNITO
      
CognitoUserPoolDomain:
  DependsOn: CognitoUserPool
  Type: AWS::Cognito::UserPoolDomain
  Properties:
    Domain: !Sub "livestream-${AWS::AccountId}-${AWS::Region}"
    UserPoolId: !Ref CognitoUserPool LiveCognitoDemoUser:
  DependsOn: CognitoUserPool
  Type: AWS::Cognito::UserPoolUser
  Properties:
    DesiredDeliveryMediums:
      - "EMAIL"
    UserAttributes:
      - Name: email
        Value: !Ref CognitoUserEmailParam
    Username: !Ref CognitoUserNameParam
    UserPoolId: !Ref CognitoUserPool

Setting up Secrets Manager

The Private Key from the previous step needs to be stored to allow AWS Lambda to access it without exposing it. This is done using AWS Secrets Manager. Add the following to the Resources section of the template.

  PrivateKeyStore:
    Type: AWS::SecretsManager::Secret
    Properties: 
      Description: Private Key string from master account
      Name: !Ref SecretsMangerSecretName
      SecretString: !Ref PrivateKeyString

To allow AWS CloudFormation to request the needed details at deploy time, a few variables to the Parameters section of the CloudFormation template are required.

  SecretsMangerSecretName:
    Description: The name of the secret in AWS secrets manager containing the certificate to sign the cookies with
    Type: String
    Default: RootKey
  PrivateKeyString:
    Description: The private key string from the root account
    Type: String  
    NoEcho: true
  CookieSigningAccountId:
    Description: "The account that provided the keys for signing the cookies"
    Type: String

Note: An important part of this template to note is the “NoEcho: true” on the PrivateKeyString. This setting means that this value will not be logged as plain text during the deployment.

Easily update your Lambda permissions later by generating an IAM policy to access the Secrets Manager secret. To do this, add the following to the Outputs section of the template.

  PrivateKeyStoreAccessPolicy:
      Description: Execution policy to allow Lambda to access SecretsManager secret
      Value: !Sub "{\"Version\": \"2012-10-17\",\"Statement\":[{\"Sid\": \"SecretsAccessSid\",\"Action\":[\"secretsmanager:GetSecretValue\"],\"Effect\": \"Allow\",\"Resource\": \"${PrivateKeyStore}\"}]}"

Setting up AWS Lambda to use AWS Secrets Manager

This section details how to build a Lambda function to use AWS Secrets Manager. Each step is broken out individually with comments on their purpose.

The key component to the signing request is an authenticated API that generates and returns signed cookies that allow the browser access to the video assets.

First you need to set up some constants, which includes:

New File (lambda/index.js)

const AWS = require('aws-sdk'),
    os = require('os'),
    region = process.env.REGION,
    secretName = process.env.SECRET_NAME,
    cloudFrontUrl = process.env.CLOUDFRONT_URL,
    keyPairID = process.env.ACCESS_KEY_ID,
    keyStart = '-----BEGIN RSA PRIVATE KEY-----',
    keyEnd = '-----END RSA PRIVATE KEY-----',
    client = new AWS.SecretsManager({
      region: region
  });

This solution requires two modules, os and aws-sdk, which are taken care of by the Lambda runtime. No need to worry about installing them separately.

The above code snippet takes four values from the environment parameters that we will set later on, as well as creating a new instance of the AWS Secrets Manager class.

The Lambda function then has three main stages:

1.   Get the Secret Key from Secrets Manager, and rebuild the structure:

lambda/index.js

const getSecret = () => {
  return client.getSecretValue({SecretId: secretName}).promise()
    .then((data) => {
      let secret;
      if ('SecretString' in data) {
          secret = data.SecretString;
      } else {
          const buff = new Buffer(data.SecretBinary, 'base64');
          secret = buff.toString('ascii');
      }
      return repairSecret(secret);
    });
};
const repairSecret = (secret) => {
  secret = secret.replace(/\r?\n|\r/g, '')
                  .replace(keyStart, '')
                  .replace(keyEnd, '');
  return `${keyStart}${os.EOL}${secret}${os.EOL}${keyEnd}`;
};

In this stage, you can see the two functions: one that receives the secret key from AWS Secret Manager and another for repairing the structure. This is to ensure that the key can be accepted in any format, as the beginning and the end line as well as the first and last new lines are essential to make the key work. This tutorial uses os.EOL to ensure that the new lines work even if you choose to run the code on a local machine with a different operating system.

2.     Create the Cookie Values from CloudFront

lambda/index.js

const getSignedCookies = (url, params) => {
  const signer = new AWS.CloudFront.Signer(params.keyPairId, params.privateKeyString);
  const policy = JSON.stringify({
    'Statement': [{
      'Resource': url,
      'Condition': {
        'DateLessThan': {
          'AWS:EpochTime': params.expireTime
        }
      }
    }]
  });
  const signingParams = {
        privateKeyString: params.privateKeyString,
        expires: params.expireTime,
        policy: policy
      };
  return signer.getSignedCookie(signingParams);
}

In this stage, you create a function that first builds a Amazon CloudFront policy using the passed URL, and the passed expiry time. This policy is then used alongside private key details to create the values needed to set your cookies. After the initial call to getSecret, you can now use your secret key to request the signed cookies from CloudFront. For this example, the expiry is set to 24 hours, or 3,600,000*24 milliseconds.

3.   Get the CloudFront Signed Cookies, build the response, and return the response from Lambda to API Gateway

lambda/index.js

module.exports.handler = async (event) => {
  return getSecret()
    .then((token) =>{
      const signingParams = {
        keyPairId: keyPairID,
        privateKeyString: token,
        expireTime: ((new Date()).getTime()) + 3600000*24
      };
      const signedCookies = getSignedCookies(`${cloudFrontUrl}/*`, signingParams);
      return {expiry: signingParams.expireTime, cookies: signedCookies};
    })
    .then((cookieObject) => {
      let response = {
        statusCode: 200,
        headers: {
          'Access-Control-Allow-Origin': '*',
          'Cache-Control': 'max-age=0; no-cache; no-store;'
        },
        body: JSON.stringify({})
      };
      response.multiValueHeaders = {
        'Set-Cookie': []
      };
      Object.keys(cookieObject.cookies).map((cookieName) => {
        let cookieString = `${cookieName}=${cookieObject.cookies[cookieName]};`
        cookieString += ` expires=${new Date(cookieObject.expiry).toUTCString()};`;
        cookieString += ' path=/;'
        response.multiValueHeaders['Set-Cookie'].push(cookieString)
      });
      return response;
    })
    .catch((err) => {
      console.log(err);
      throw(err);
    })
}

There are a few key required steps to build the response. Using the response.multiValueHeaders[] block allows you to return multiple headers with the same name; in this case, "Set-Cookie".

Then, build the Set-Cookie header string for each of the cookies returned by CloudFront. Set the path to “/” or the root of the domain, and the expiry to the UTC value of the expiry you passed to CloudFront. When this step is complete, you can return your response object.

If you plan to deploy this manually, you must remember to set the four environment variables from within the AWS Lambda console.

  • REGION  – This is the region that you deployed the main CloudFormation Stack into; for example, ‘us-west-1’.
  • SECRET_NAME – The name that you gave to the the Secrets Manager store, to store your private key. Unless you intend to change it when you deploy the updated CloudFormation stack, this will be ‘RootKey’.
  • CLOUDFRONT_URL – This is the URL that the cookies need to grant end users access to. The path will be https://{CloudFrontDomainName}/out/v1 with {CloudFrontDomainName} replaced by your CloudFront domain name. You can get this from the output section of your deployed CloudFormation template, using the first part (everything up to the /index.html) from the DemoConsole output parameter.
  • ACCESS_KEY_ID – This is the name of the CloudFront Key Pair that you created in the first step.

Once the Lambda function is deployed, make note of the ARN of your Lambda function to use in the next step.

Setting Up API Gateway

Now that setup for your Lambda function and Cognito user pool are complete, the next step is setting up Amazon API Gateway. Create a new API with a method that calls the Lambda function we created earlier. Cognito Authorizer is used to ensure that only users who have logged in are able to get cookies allowing access to the live stream.

The following CloudFormation template snippet creates the entire API Gateway REST API to be added to the Resources section of the template.

  DemoApi:
    Type: AWS::ApiGateway::RestApi
    Properties:
      Name: "GetCookiesApi"
      EndpointConfiguration: 
        Types: 
          - REGIONAL

This creates a new, empty API that you can then add methods to. Add the following code snippet to the to the Resources section of the CloudFormation template.

  DemoApiAuthorizer:
    Type: 'AWS::ApiGateway::Authorizer'
    Properties:
      Type: COGNITO_USER_POOLS
      IdentitySource: method.request.header.Auth
      Name: "DemoApiAuthorizer"
      ProviderARNs:
        - !GetAtt CognitoUserPool.Arn
      RestApiId: !Ref DemoApi

In this section, you define your Authorizer. Specify the source of the authorization parameter, in our case method.request.header.Auth, which specifies that the token should be taken from the Auth header that was sent with the request. Point this to the CognitoUserPool that you created earlier.

Add the following snippet to the Resources section of the CloudFormation template. Make sure you replace the {{Lambda_Arn}} placeholder with the ARN of your Lambda function.

  LambdaDemoApiInvoke:
    Type: "AWS::Lambda::Permission"
    Properties:
      Action: "lambda:InvokeFunction"
      FunctionName: {{Lambda_Arn}}
      Principal: "apigateway.amazonaws.com"
      SourceArn: !Sub "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${DemoApi}/*/GET/*"

The next step is to allow your API Gateway Method to trigger the Lambda function. To do so, create a Lambda permission, with a Principal that specifies that it allows requests from apigateway.amazonaws.com. Your SourceArn is the ARN of your API Gateway Rest API, and the FunctionName is the name of the Lambda function to be triggered.

Add following snippet to the Resources section of the CloudFormation template. Once again, make sure you replace the {{Lambda_Arn}} placeholder with the ARN of your Lambda function.

  DemoApiRootMethod:
    Type: "AWS::ApiGateway::Method"
    DependsOn: DemoApiAuthorizer
    Properties:
      AuthorizationType: COGNITO_USER_POOLS
      AuthorizerId: !Ref DemoApiAuthorizer
      HttpMethod: "GET"
      Integration:
        IntegrationHttpMethod: "POST"
        Type: "AWS_PROXY"
        Uri: !Sub
          - "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${lambdaArn}/invocations"
          - lambdaArn: {{Lambda_Arn}}
      ResourceId: !GetAtt "DemoApi.RootResourceId"
      RestApiId: !Ref "DemoApi"

Next you need to add a GET method that calls your Lambda function and attaches the full method to our API Gateway. Add the following snippet to the Resources section of the CloudFormation template.

  DemoApiDeployment:
    Type: "AWS::ApiGateway::Deployment"
    DependsOn: DemoApiRootMethod
    Properties:
      RestApiId: !Ref "DemoApi"
      StageName: "getCookiesProduction"

The final step is to create a deployment of your Rest API. This creates a stage named getCookiesProduction and allows the API to be called from a web frontend.

In part 2 of this post series, we will provide instructions on how to complete the workflow by deploying the updated CloudFormation template and redirecting non-authenticated users. Continue building with Protecting your video stream with Amazon CloudFront and serverless technologies – Part 2.