AWS for M&E Blog

Protecting your video stream with Amazon CloudFront and serverless technologies – Part 2

Amazon CloudFront alongside media services and serverless technologies from AWS make it easy to protect your video stream from unauthorized viewing. In part one of this blog post, you learned how to set up Amazon Cognito, create an API to generate cookies and protect your stream from unauthorized access.

In this post, we will provide instructions on how to complete the workflow by deploying the updated CloudFormation template and redirecting non-authenticated users.

Modify Amazon CloudFront to use a single domain

The original setup uses two Amazon CloudFront distributions: one for the static website and another for the video assets. To simplify this process, and reduce any potential CORS errors on the authenticated solution, merge the two distributions and include the API.

Remove the two Amazon CloudFront distributions from the Resources section of the AWS CloudFormation template and replace with the following code. The existing distributions are called, DemoCloudFront and CloudFront.

  CloudFront:
    DependsOn: MediaPackageChannel
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Origins:
          - Id: mediapackage
            DomainName: !GetAtt MediaPackageHlsEndpoint.DomainName
            CustomOriginConfig:
              OriginProtocolPolicy: https-only
          - Id: cookiesapi
            DomainName: !Sub "${DemoApi}.execute-api.${AWS::Region}.amazonaws.com"
            CustomOriginConfig:
              OriginProtocolPolicy: https-only
          - Id: S3-solution-website
            DomainName: !Sub "${DemoBucket}.s3.${AWS::Region}.amazonaws.com"
            OriginPath: /console
            S3OriginConfig:
                OriginAccessIdentity: !Sub "origin-access-identity/cloudfront/${DemoOriginAccessIdentity}"
        Enabled: 'true'
        CacheBehaviors:
          - TargetOriginId: cookiesapi
            AllowedMethods:
                - GET
                - HEAD
                - OPTIONS
            ForwardedValues:
                QueryString: 'false'
                Headers:
                  - Auth
                  - Set-Cookie
                Cookies:
                  Forward: all
            MaxTTL: 0
            MinTTL: 0
            DefaultTTL: 0
            PathPattern: '/getCookiesProduction'
            ViewerProtocolPolicy: redirect-to-https
          - TargetOriginId: mediapackage
            TrustedSigners:
              - self
              - !Ref CookieSigningAccountId
            SmoothStreaming: 'false'
            AllowedMethods:
              - GET
              - HEAD
              - OPTIONS
            CachedMethods:
              - GET
              - HEAD
              - OPTIONS
            ForwardedValues:
              QueryString: 'true'
              Cookies:
                Forward: all
              Headers:
                - Access-Control-Allow-Origin
                - Access-Control-Request-Method
                - Access-Control-Request-Header
            ViewerProtocolPolicy: allow-all
            PathPattern: '/out/*'
        DefaultCacheBehavior:
          TargetOriginId: S3-solution-website
          AllowedMethods:
              - GET
              - HEAD
              - OPTIONS
          CachedMethods:
              - GET
              - HEAD
              - OPTIONS
          ForwardedValues:
              QueryString: 'false'
          ViewerProtocolPolicy: redirect-to-https
        DefaultRootObject: "index.html"
        ViewerCertificate:
          CloudFrontDefaultCertificate: 'true'
      Tags:
        - Key: MP-Endpoint-ARN
          Value: !GetAtt MediaPackageChannel.Arn

The key section in this snippet is the CacheBehaviors block. Here you are specifying how different roots should be forwarded to their respective origins.

For example, the TargetOriginId: mediapackage specifies a PathPattern: '/out/*'. This means that any request that has a path matching '/out/*' is forwarded to the mediapackage origin.

In the Outputs section of the template, replace the DemoConsole definition to point to the single Amazon CloudFront distribution, like this:

  DemoConsole:
    Description: Demo Player URL
    Value: !Sub https://${CloudFront.DomainName}/index.html

Also in the Outputs section, add the following lines, these will give us the values we need to pass to the client-side application.

  LoginUrl:
    Description: Url to send users to complete thier login
    Value: !Sub https://${CognitoUserPoolDomain}.auth.${AWS::Region}.amazoncognito.com/login?response_type=token&client_id=${CognitoUserPoolAppClient}&redirect_uri=https://${CloudFront.DomainName}/login.html

  ApiEndpoint:
    Description: Cookies api URL
    Value: !Sub https://${CloudFront.DomainName}/getCookiesProduction

Deploy Updated AWS CloudFormation Template

Once you finish updating the AWS CloudFormation templates, deploy the updated versions using the AWS Console or the AWS CLI.

Pass the following parameters when deploying the template:

  • CognitoUserEmailParam – Must be a valid email address that you have access to receive your temporary password
  • PrivateKeyString – The full text content of the private key you obtained from your root account earlier, including the header and footer lines
  • CookieSigningAccountId – The account ID of the account that provided your Key Pair

When this stack is successfully deployed, you need to get three values from the Outputs tab of the stack:

  • LoginUrl
  • ApiEndpoint
  • PrivateKeyStoreAccessPolicy

These values are used in the next stage to update the web application.

Update Lambda execution role policy

Next, update the role assigned to our Lambda function to give it permission to get the value of the secret you deployed to SecretsManager.

Locate the execution role for your Lambda under the Permissions tab of the Lamdba console, and add the policy from the PrivateKeyStoreAccessPolicy output as an inline policy to the execution role. This will allow your Lambda function to read the specific secret you deployed.

You are encouraged to read the policy and see how it uses the Least Privilege Principle to only grant access to the specific secret needed.

Updating the player to send cookies

A few changes are required to the JavaScript that is used to display the player to the end user. These are deployed by the original Live Streaming on AWS Solution and are stored in an S3 bucket. The bucket is named used the format <stack-name>-demobucket-<generated-string>, with <stack-name> being the name you set when deploying the original stack.

Note: All the files you modify or add need to be downloaded from this bucket and re-uploaded to the same place once the changes are complete.

If you have access keys set up, copy the files using the following command:

aws s3 cp s3://{{S3 Bucket Name}} . --recursive 

Add the login URL to the exports.js file

The purpose of this step is to let the application know where to send a user if they do not currently have a valid token. The login URL is your CloudFront distribution URL and the path "/getCookiesProduction". You also need to store the URL of the API created earlier. 

These URLs should be inserted into the exports.js file as new variables. Replace the {{LoginUrl}} and {{ApiEndpoint}} placeholders with to the values from the deployed AWS CloudFormation stack output.

Note: Do not modify any of the existing parameters. Only add login_url and api_endpoint, and make sure you are separating values with commas.

console/assets/js/exports.js

'use strict'

var exports = {
  mediaLiveConsole: 'Existing Value',
  hls_manifest: 'Existing Value',
  dash_manifest: 'Existing Value',
  mss_manifest: 'Existing Value',
  cmaf_manifest: 'Existing Value',
  login_url: '{{LoginUrl}}',
  api_endpoint: '{{ApiEndpoint}}' 
}

Redirect non-authenticated users to the login page

When a user visits the webpage to play a video, you can check to see if the user has a valid cookie to allow them to watch the stream, and redirect users that have not yet signed in.

The following code should be added to the app.js file, immediately after the const player = videojs('video'); line.

console/assets/js/app.js

const login_URL = exports.login_url;

function getCookie(key) {
    var keyValue = document.cookie.match('(^|? ?' + key + '=([^;]*)(;|$)')
    return keyValue ? keyValue[2] : null
}
function goToLogin() {
   window.location.replace(login_URL)
}

var token = getCookie('livestream-token')

if (!token) {
    goToLogin()
}

 

Send cookies with the player request for chunks

By default, the player.js video player does not send cookies with the requests it makes for the chunks; therefore, you should add a beforeinitialize hook that tells the player to require cookies.

The following code should be added immediately after the code you inserted in the previous step.

console/assets/js/app.js

videojs.Html5DashJS.hook('beforeinitialize', function(player, media_player) {
  function loader() {
    var load = this.parent.load
    return {
      load: function(req) {
        req.withCredentials = true
        load(req)
    }}
  } // loader
  media_player.extend('XHRLoader', loader, true)
  media_player.setXHRWithCredentialsForType('GET',true)
  media_player.setXHRWithCredentialsForType('MPD',true)
  media_player.setXHRWithCredentialsForType('MediaSegment',true)
  media_player.setXHRWithCredentialsForType('InitializationSegment',true)
  media_player.setXHRWithCredentialsForType('IndexSegment',true)
  media_player.setXHRWithCredentialsForType('other',true)
})

player.on('error', goToLogin)

Create a login page to handle requests for cookies

When a user is returned from the OAuth journey, a token is passed to the webpage as a GET parameter. Some JavaScript is needed to store this as a cookie and then use it to make an API request to get the cookies to allow access to the S3 bucket containing the required chunks.

The HTML for this procedure is simple and acts as a placeholder message to the user while you make the needed requests. The following code should be saved as console/login.html.

New File – console/login.html

<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <meta name="description" content="">
    <meta name="author" content="">
    <link rel="icon" href="./assets/img/favicon.ico">
    <title>Live Streaming on AWS</title>
</head>

<body>

    If you are not re-directed after a few seconds please click <a href="./index.html">here</a>
    <!-- App -->
    <script src="./assets/js/lib/jquery.min.js"></script>
    <script src="./assets/js/exports.js"></script>
    <script src="./assets/js/login.js"></script>

</body>
</html>

The JavaScript file contains the logic to store the passed token and make a request for the signed cookies. Save this file as console/assets/js/login.js.

New File – console/assets/js/login.js

function getUrlParameter(sParam) {
    var sPageURL = window.location.hash.substring(1),
        sURLVariables = sPageURL.split('&'),
        sParameterName,
        i;

    for (i = 0; i < sURLVariables.length; i++) {
        sParameterName = sURLVariables[i].split('=');

        if (sParameterName[0] === sParam) {
            return sParameterName[1] === undefined ? true : decodeURIComponent(sParameterName[1]);
        }
    }
};

function setCookie(key, value, expiry) {
    var expires = new Date();
    expires.setTime(expires.getTime() + (expiry * 1000));
    document.cookie = key + '=' + value + ';path=/;expires=' + expires.toUTCString();
}

function getSignedUrl(token, callback) {
    $.ajax(
        exports.api_endpoint,
        {
            method: "GET",
            dataType: "json",
            contentType: "application/json; charset=utf-8",
            headers: {
                Auth: `Bearer ${token}`
            }
        }
    ).done(callback);
}


const token = getUrlParameter('id_token');

if (!token) {
    window.location.replace(exports.login_url)
} else {
    setCookie('livestream-token', token, getUrlParameter('expires_in'));
    getSignedUrl(token, function () {
        window.location.replace(`${window.location.origin}/index.html`)
    })
}

Note the ${window.location.origin}/login.html at the end of the line. This is used to inject the login page URL into the string to tell Cognito where to send a user back to after a successful login. Once you are set up on your permanent domain, you should replace this with a fixed URL.

If you are deploying these files manually, upload all the files that have been changed or updated to the S3 bucket that was created as DemoBucket.

If you have AWS Command Line Interface (AWS CLI) access, you can upload your modified code using the following command from within the folder that you copied from the DemoConsole bucket earlier.

aws s3 cp . s3://{{S3 Bucket Name}} --recursive --exclude "*" --include "*.html" --include "*.js"

Summary

Your live stream is now protected from unauthorized access and only users logged with Cognito can watch the video. To view the video stream, browse to the location provided by the DemoConsole CloudFormation output and log in with the username you selected (default is “demo-user”). The password was emailed to the address you provided when deploying the updated CloudFormation template.

For an additional layer of access control, protect the MediaPackage origin from direct access by following the instructions in the service documentation.

You can further protect your video content from unauthorized viewing and piracy by leveraging the Digital Rights Management (DRM) integration capabilities of MediaPackage to encrypt the video. Refer to the Content Encryption in AWS Elemental MediaPackage documentation for instructions.

You can also enable your live stream users to sign in through a SAML identity provider (IdP), such as Microsoft Active Directory Federation Services (ADFS) or Shibboleth. For instructions on how to set this up, refer to the Adding SAML Identity Providers to a User Pool documentation.