Networking & Content Delivery

Building a Serverless Subscription Service using Lambda@Edge

Personalizing content helps to drive subscriptions, improve revenue, and increase retention rates by providing a more engaging and responsive customer experience. In this blog post, we’ll show you how to build a serverless subscription service for your website that personalizes and monetizes content by using Amazon CloudFront and AWS Lambda@Edge.

Customers have typically used content delivery networks (CDNs) to reduce latency for global applications by serving content closer to their users. Since we announced Lambda@Edge in December 2016, customers have also started using Lambda functions to shift compute-heavy processing to the edge. By using Lambda@Edge, developers can build and continuously deliver features in edge locations, closer to their users and web consumers. Using CloudFront and Lambda@Edge together helps you to build and provide highly-performant online experiences. Using serverless applications at the edge also helps you avoid managing an extra tier of infrastructure at the origin.

If you’re just learning about Lambda@Edge, we recommend checking out the Get Started section in the documentation first, before you read this article, to get a general understanding about how Lambda@Edge works.

In our example application for personalizing content, users must register first, so that we can show them content that is most relevant to them. We use Lambda@Edge to validate registered users by authenticating them. For simplicity, we haven’t included a customer registration page but it’s straightforward to include one in your web flow. If someone is visiting your site for the first time, you can redirect them to a registration page, and then attach an entitlement to the profile to permit them to perform actions based on the level of their subscription.

There are a number of reasons to use Lambda@Edge when you build a subscription service. For example, you and your customers can gain the following benefits:

  • Lambda@Edge is a serverless computing platform, which has several advantages. There’s no infrastructure to manage when you use it. It’s an event-driven system, so you only pay for the service when an event is triggered. It scales automatically based on the demand. And, finally, it’s highly available.
  • A Lambda@Edge function runs closer to the viewer, so users have a better experience with faster response times.
  • The load on your origin is reduced because you can offload some CPU-intensive applications and processes from your web and app servers. Caching at the edge further reduces the load on your origin.
  • You can control your user journey in a more fine-grained manner, so you can, for example, implement micropayments, micro-subscriptions, bots management, and metering content. These features help your website to interact in innovative ways with customers and frequent viewers.
  • The AWS ecosystem includes more than 100 managed services that you can integrate with. For example, you can build analytics based on the logs generated on Lambda@Edge, CloudFront, and CloudWatch.
  • You can promote advertisements on your articles that align with your brand and opinion by using Lambda@Edge to provide relevant tags to advertising platforms at the Edge, allowing you to further drive revenue based on the viewer’s subscription level.

Solution overview

Our example application supports providing a custom experience for website visitors who sign in to the site, so we start by authenticating users when they navigate to the website in their browser. There are several options for authenticating users, including using an existing system that you already have in place or using Amazon Cognito, an Amazon-managed service. (For more information about Amazon Cognito, see the AWS documentation.)

When a customer opens a website page that accesses content that is delivered through CloudFront, it creates a viewer request event in CloudFront. After the user is authenticated, the viewer request triggers a Lambda@Edge function which retrieves the entitlement associated with the customer. Then the function evaluates the entitlement to see if the website visitor is authorized to access protected content; or, if they’re not registered, they’re redirected to the subscription page.

To determine the scope of the customer’s entitlement, the Lambda function accesses an authorization service that you run on your origin server, and then applies the entitlement associated with the viewer. To modify their subscription level, use the management interface of your user subscription management. In our example application, we use the DynamoDB service interface in the AWS Management console.

The following flow diagram provides more details about how our example implements user authentication and determines the customer’s access level.

As you can see in the diagram, authentication is managed by doing the following:

  • When a user requests content, the viewer request is received by CloudFront. Users can be federated by using their social identity to sign in, or by using enterprise directory services like Microsoft AD. Or they can simply register using a registration page hosted by the website. You can learn more about managing user logins with Amazon Cognito in the following blog post: Building fine-grained authorization using Amazon Cognito User Pools groups.
  • When you implement authentication, you can decide to integrate this process with an existing authorizer or third-party solution that you’re already using. Or you can build a stateless authentication and authorization solution that uses Amazon services like Lambda functions and DynamoDB.

  • After the user authenticates, we issue a JWT token, and then redirect the request traffic to a private content path based on the scope defined in the JWT payload. The following is a sample JWT payload:
{
    "iss": "https://idp.example.com",
    "client_id": "exampleclient",
    "sub": "user1",
    "scope": {
        "compute": "2018-05-11T00:00:00Z",
        "edge": "2018-05-09T00:00:00Z",
        "language": "ja"
     },
}
  • Typically, CloudFront serves the content for the request from the cache—when the session isn’t expired—or routes the request to the closest edge location to the viewer. But in our example, a Lambda@Edge function is triggered on the CloudFront viewer request. The function makes sure that the user is authorized to view private content before CloudFront serves it.
  • To verify authorization, the function makes a network call to the authorizer, and then applies a policy to the user, based on the entitlement that is associated with them. For example, a user might have an entitlement for premium access, stranded access, or basic access with limited page views. Or they might have access to just one section, such as sports.

Accessing the example application

Ready to explore this yourself? There are three parts to our example:

  • The example application, with three content sections. You can set this up by using the CloudFormation automation that we provide.
  • The entitlement management layer, which we implement by using DynamoDB.
  • The example Lambda@Edge function code, for managing access based on a user’s entitlement.

We provide the code for our example application on the AWS Labs GitHub repository, https://github.com/aws-samples/aws-serverless-subscription-service-node, together with a step-by-step guide for how to deploy the code to set up the application. Our code contains CloudFormation automation scripts that provide infrastructure with code that is generated by using the Serverless application framework. Running these scripts simply creates the example application—a basic site for the sample Lambda function to interact with—to show you how our solution works.

We also include a simple user and entitlement management layer that is implemented by using a DynamoDB table, which you can easily convert into a DynamoDB global table to replicate to global locations. To learn more, see Working with DynamoDB Global Tables in the Amazon DynamoDB Developer Guide.

Walking through the code example

Along with the example application, we provide a Lambda@Edge sample function that illustrates how you can implement this solution. Let’s walk through the sample code so that you can see how it works.

First, we set up the required permissions. To run Lambda@Edge functions, you must add an IAM role that lets CloudFront execute Lambda and Lambda@Edge functions on your behalf during events. To grant the required permission, the CloudFormation scripts that we provide contain the following IAM role:

Add inline policy dev-serverlesssubscriptionplatform-lambda
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream"
            ],
            "Resource": "arn:aws:logs:*:*:*",
            "Effect": "Allow"
        },
        {
            "Action": [
                "logs:PutLogEvents"
            ],
            "Resource": "arn:aws:logs:*:*:*",
            "Effect": "Allow"
        }
    ]
}

Edit trust relationship
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": [
          "edgelambda.amazonaws.com",
          "lambda.amazonaws.com"
        ]
      },
      "Action": "sts:AssumeRole"
    }
  ]
}

Now, let’s look at the following code from the Lambda@Edge function that implements the paywall. In our example application, it’s called serverlesssubscriptionplatform-dev-paywall. The comments in the code explain the functionality of each code block.

/**
 * The paywall-specific functionality of the platform.
 *
 * @link       https://aws.amazon.com/
 * @since      1.0.0
 *
 * @package    ServerlessSubscriptionPlatform
 * @subpackage ServerlessSubscriptionPlatform/paywall
 */

/**
 * The paywall-specific functionality of the platform.
 *
 * This Lambda function is associated with a viewer-request event
 * type. The example shows how to verify the JWT by making two separate calls,
 * to hello api and headless cms, combining the response, and
 * then the personalized response is returned at edge. It also enables pass
 * through for remaining use cases, such as login and default
 * view.
 *
 * @package    ServerlessSubscriptionPlatform
 * @subpackage ServerlessSubscriptionPlatform/paywall
 * @author     AWS Labs
 */

'use strict';

const https = require('https')
    ,nJwt = require('njwt')
    ,config = require('./config');

const HOST_NAME = config.web.hostName;
const TEMPLATE_URL = config.web.headlessCmsUrl;

function parseCookies(headers) {
    const parsedCookie = {};
    if (headers.cookie) {
        headers.cookie[0].value.split(';').forEach((cookie) => {
            if (cookie) {
                const parts = cookie.split('=');
                if (parts[1]) {
                    parsedCookie[parts[0].trim()] = parts[1].trim();
                }
            }
        });
    }
    return parsedCookie;
}

exports.handler = (event, context, callback) => {
    const request = event.Records[0].cf.request;
    console.log(JSON.stringify(request));
    const parsedCookies = parseCookies(request.headers);

    if (parsedCookies && parsedCookies['londonsheriff-Token'] && request.uri == "/articles") {
        console.log('Cookie present');

        const token = parsedCookies['londonsheriff-Token'];
        console.log(token);
        const b64string = config.web.base64SigningKey;
        const signingKey = Buffer.from(b64string, 'base64');
        console.log(signingKey);
        try {
            let verifiedJwt = nJwt.verify(token,signingKey);
            console.log(verifiedJwt);
        } catch(e) {
            console.log(e);
        }

        // TODO: Decide what to do when the passed token is not valid or expired

        const userDetails = token.split('.')[1];
        console.log(userDetails);
        console.log(Buffer.from(userDetails, 'base64').toString('ascii'));
        let userToken = JSON.parse(Buffer.from(userDetails, 'base64').toString('ascii'));
        const userName = userToken.sub;
        const scope = userToken.scope;

        let templateUrl = TEMPLATE_URL;

        https.get(templateUrl, (res) => {
            var content = '';
            res.on('data', (chunk) => { content += chunk; });
            res.on('end', () => {
                console.log(content);
                let responseBody = [];
                var responseWithTags = {};
                let jsonBody = JSON.parse(content);
                // Go through the scope to find out what this user is intersted in
                for (var key in scope) {
                    if (scope.hasOwnProperty(key) && key != 'language') {
                        for (var i = 0; i < jsonBody.length; i++) {
                            let article = jsonBody[i];
                            if (article['field_tags'].indexOf(key) > 0) {
                                responseBody.push({'title': article['title'], 'body': article['body']})
                            }
                        }
                        responseWithTags[key] = responseBody;
                    }
                    responseBody = [];
                }
                const options = {
                    hostname: HOST_NAME,
                    port: 443,
                    protocol: 'https:',
                    path: '/dev/api/hello',
                    method: 'GET',
                    headers: {
                        'Cookie': request.headers.cookie[0].value
                    }
                };
                console.log("cookie " + request.headers.cookie)
                var req = https.request(options, function(res) {
                    var content = '';
                    res.on('data', (chunk) => { content += chunk; });
                    res.on('end', () => {
                        console.log("got hello: " + content);
                        const jsonBody = JSON.parse(content);
                        responseWithTags['hello'] = jsonBody[scope['language']];
                        const response = {
                            status: '200',
                            statusDescription: 'OK',
                            body: JSON.stringify(responseWithTags),
                            bodyEncoding: 'text',
                        };
                        callback(null, response);
                    });
                });
                req.end();
            });
        });
    } else if (request.uri == '/login' || request.uri.startsWith('/api/login')) {
        console.log('login uri found');
        if (!request.headers.authorization) {
            console.log('No auth header');
            const options = {
                hostname: HOST_NAME,
                port: 443,
                protocol: 'https:',
                path: '/dev/login',
                method: 'GET'
            };

            var req = https.request(options, function(res) {
                var content = '';
                res.on('data', (chunk) => { content += chunk; });
                res.on('end', () => {
                    console.log(content);
                    const response = {
                        status: '200',
                        statusDescription: 'OK',
                        body: content,
                        bodyEncoding: 'text',
                    };
                    callback(null, response);
                });
            });
            req.end();
        } else {
            const options = {
                hostname: HOST_NAME,
                port: 443,
                protocol: 'https:',
                path: '/dev/api/login',
                method: 'POST',
                headers: {
                    'Authorization': request.headers.authorization[0].value
                }
            };

            var req = https.request(options, function(res) {
                var content = '';
                res.on('data', (chunk) => { content += chunk; });
                res.on('end', () => {
                    const response = {
                        status: res.statusCode,
                        statusDescription: 'OK',
                        body: content,
                        bodyEncoding: 'text',
                        headers: {
                            'set-cookie': [{
                                key: 'set-cookie',
                                value: res.headers['set-cookie'],
                            }]
                        },
                    };
                    callback(null, response);
                });
            });
            req.end();
        }
    } else {
        let templateUrl = TEMPLATE_URL;
        console.log('default case');
        https.get(templateUrl, (res) => {
            var content = '';
            res.on('data', (chunk) => { content += chunk; });
            res.on('end', () => {
                console.log(content);
                let jsonBody = JSON.parse(content);
                let responseEdge = [];
                let responseSecurity = [];
                let responseCompute = [];
                for (var i = 0; i < 3; i++) {
                    let article = jsonBody[i];
                    if (article['field_tags'].indexOf("edge") > 0) {
                        responseEdge.push({'title': article['title'], 'body': article['body']})
                    }
                    if (article['field_tags'].indexOf("security") > 0) {
                        responseSecurity.push({'title': article['title'], 'body': article['body']})
                    }
                    if (article['field_tags'].indexOf("compute") > 0) {
                        responseCompute.push({'title': article['title'], 'body': article['body']})
                    }
                }

                let responseBody = {'hello': 'hello', 'edge': responseEdge, 'security': responseSecurity, 'compute': responseCompute };
                console.log(JSON.stringify(responseBody));
                const response = {
                    status: '200',
                    statusDescription: 'OK',
                    body: JSON.stringify(responseBody),
                    bodyEncoding: 'text',
                };
                callback(null, response);
            });
        });
    }

};

Deploying the example application (Part 1)

The example subscription service solution that we provide has two parts:

  • In this first section, we deploy an example application origin that we can use to demonstrate how the sample solution works.
  • In the next section, Part 2, we focus on how to use the Lambda function to integrate the subscription service example with the sample application. We describe how in the next section, Deploying the subscription service (Part 2).

If you prefer, instead of setting up a sample application origin as we do in this section, you can simply use your own application and adapt the subscription service solution (Lambda code and entitlements management) to work with it.

The sample application origin has three sections that a user can access, depending on their subscription level: Compute, Security, and Edge.

To deploy the solution by using CloudFormation, do the following:

  1. Set up the Serverless Framework on your local machine by following the instructions at serverless.com.
  2. In your git checkout folder, run serverless deploy -v to deploy the sample application. Note that the stack will launch in the N. Virginia (us-east-1) region.

The following screenshot shows the CloudFormation output.

3. In the CloudFormation Outputs list, choose the service endpoint URL, to view the sample application. You’ll see the following three sections: Compute, Security, and Edge, as shown in the following screenshot.

 

Now that the sample origin is set up, let’s deploy the subscription service, with using entitlements management and Lambda code to manage user access to content on the origin.

Deploying the subscription service (Part 2)

In this section, we integrate the example subscription service with the sample application origin that we just deployed. You can follow a similar process to integrate a subscription service with your own website origin.

The sample application comes with an empty DynamoDB table that you can use to manage entitlements that specify what users can access on your site. You can add and remove user entitlements by using a token in the table. The entitlements contain profile information that shows the permissions associated with each user.

For example, in the following DynamoDB profile entry, user1 has access to only the Compute and Security sections. Note that the entitlement includes other access customizations, including a language customization (Hindi) based on the user’s location. This just illustrates how you can further customize and manage the user experience by using entitlements tokens.

{
    "userId": "user1",
    "name": "full_name",
    "entitlement": {"compute":"2018-05-11T00:00:00Z","security":"2018-05-18T00:00:00Z", "language":"hi"},
    "validFrom": "2018-04-17T00:00:00Z",
    "validTo": "9999-12-31T23:59:59Z",
    "entered": "2018-04-17T00:00:00Z",
    "enteredBy": "yourname"
}

After you set up the DynamoDB table with the entitlements, the next step is to add the Lambda function and enable triggers in CloudFront so that the subscription service will work with the origin. The example subscription management function that we’ve written is available in the Lambda console in the AWS Management console, so you can add it there.

To add the Lambda function and enable a trigger to associate it with CloudFront, do the following:

1. In the AWS Management console, open the Lambda console, choose Create function, and then search for and choose serverlesssubscriptionplatform-dev-app.

2. Edit the function to customize it. For example, you can restrict users so they can access only one of the three sections (Compute, Security, or Edge), and only allow access within a specific window by setting an expire parameter. You can also extend the capabilities of the function to match your requirements by modifying the DynamoDB table. The function code and the entitlements that you’ve set up with DynamoDB will work together to manage users’ access.

3. To enable the paywall, open the CloudFront console and then create a CloudFront distribution. You’ll use the distribution to trigger the Lambda function.

To create the CloudFront distribution, you’ll do the following. For details and specific steps, see the comments in the CloudFormation scripts.

In the CloudFront console, choose Create Distribution, and then, under Web, choose Get Started.

  • For Origin Domain Name, to use our sample application, enter the service endpoint that is listed in the output from the CloudFormation template. (If you’re using your own website, list your website URL as the origin.) For example, you might see something like the following under Outputs:

ServiceEndpoint       https://randomstring.execute-api.us-east-1.amazonaws.com/dev/     URL of the service endpoint

  • For Origin Path, enter a specific path for your content. For our example, enter /dev, as shown in the following screenshot.

  • Keep the default settings for the other distribution options.

To create the CloudFront distribution using your settings, choose Create Distribution.

4. In your browser, navigate to the git repository for this solution, https://github.com/aws-samples/aws-serverless-subscription-service-node, and then edit the config.js file. Update the URLs in the following variables to the ServiceEndpoint URL listed under Outputs in the CloudFormation results. Making these changes will enable the Lambda function to communicate with the sample application.

config.web.rootPath = ‘https://ServiceEndpoint.execute-api.us-east-1.amazonaws.com/dev’;

config.web.hostName = ServiceEndpoint.execute-api.us-east-1.amazonaws.com’;

config.web.headlessCmsUrl = ‘https:// ServiceEndpoint.execute-api.us-east-1.amazonaws.com/dev/articlesexportall’;

5. Create a deployment package in your local machine for the Lambda@Edge solution, to make sure that all of the required code and dependencies are set up for the Lambda@Edge function to run correctly. To do this, create a zip file of the Lambda function directory where you updated the config file. You’ll upload the file in the Lambda console in Step 7, after you create the function.

To learn more, see Creating a Deployment Package in the AWS Lambda Developer Guide.

6. Next, open the Lambda Console, and then choose Create function.

a.       Make sure that you’re in the us-east-1 Region: In the drop-down list, select US East (N. Virginia).

b.      Choose Author from scratch. Enter a name, and then, in the Role drop-down list, select the role created by the Serverless Framework.

c.       Choose Create function.

7. Upload the ZIP file that you created.

a.       Under Function Code, in the Code entry type drop-down list, choose Upload a .ZIP file.

b.      Under Function package, choose Upload.

8. Enable your function to be run by CloudFront. Under Add triggers, choose CloudFront, and then add viewer-request triggers for /login, /api/login, and /articles by adding cache behaviors for each one. You can find step-by-step instructions in Adding Triggers by Using the Lambda Console in the CloudFront Developer Guide.

9. Finally, test the functionality by signing in to the sample application as different users.

a.       Navigate to the cloudfrontendpoint URL for the sample application.

b.      Sign in as one of the users that you created in DynamoDB table.

c.       Repeat the process for other users, to verify that they have only the entitlements that you specified.

Conclusion

This blog post explains how to use Lambda@Edge to implement a serverless-based subscription service for your website. A subscription service enables you to control your user’s journey by using an access model that lets you add metering, restrict users to particular section or article, include micropayments, and set up a viewing strategy that offers visitors a rich customer experience. And by integrating a subscription model with a payment method, you can monetize your content.

The source code for this solution is available on GitHub. If you have questions or suggestions, feel free to comment below. For troubleshooting or implementation help, check out the Lambda forum or contact AWS Support.