AWS for M&E Blog
[Updated 1/4/2021]: Protecting your video stream with Amazon CloudFront and serverless technologies – Part 1
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.
Setting up Amazon CloudFront key pairs for signing cookies
Important: This blog post relies on root credentials to the AWS account that will act as the Trusted Signer to create Amazon CloudFront key pairs. It is now recommended to use trusted key groups instead of an AWS account.
- Sign in to the AWS Management Console using the credentials of the AWS account root user. IAM users can’t create CloudFront key pairs. You must sign in using root user credentials to create key pairs.
- Choose your account name, then choose My Security Credentials.
- Choose CloudFront key pairs.
- Confirm that you have no more than one active key pair. You can’t create a key pair if you already have two active key pairs.
- Choose Create New Key Pair.
- In the Create Key Pair dialog box, choose Download Private Key File, and then save the file on your computer.
- Record the key pair ID for your key pair. (In the AWS Management Console, this is called the Access Key ID.) You’ll use it when you create signed URLs or signed cookies.
Important: Save the private key for your CloudFront key pair in a secure location, and set permissions on the file so that only the desired administrators can read it.
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, download the AWS CloudFormation template for the solution and create a stack by deploying the template. Be sure to select Nodejs12.x from the Source Code option list. Also make sure to download a copy of the template to make changes according to the instruction in the following sections.
Download the base CloudFormation template here.
The AWS CloudFormation template in this solution includes set up of a CloudFront distribution, which will take a few minutes to deploy. For this tutorial, it is recommended that you deploy the initial solution template 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 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 behttps://{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 above step allows 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"
This adds 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"
This 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.