AWS CloudFront User Authentication using Lambda@Edge

• Payton Garland

Our CI system is configured to write build reports to a S3 bucket. However, we found that there’s no easy way to serve private files without running an EC2 instance with proxy software or living with the limitations of IP address restrictions using IAM rules.

CloudFront has a feature named origin access identity that allows you to serve private S3 content. The missing link is the ability to validate requests as they are received by CloudFront. On July 17, 2017, Amazon released a new AWS Lambda feature named Lambda@Edge. From a developer’s perspective, Lambda@Edge allows Node.js functions to inspect, and modify, requests as they arrive at CloudFront POPs around the world.

Having the ability to execute Lambda functions upon viewer request gives us the opportunity to authenticate the request in any way we wish. For our initial proof of concept, we checked for basic authentication with a static username/password.

'use strict';
exports.handler = (event, context, callback) => {

    const request = event.Records[0].cf.request;
    const headers = request.headers;

    // Configure authentication
    const authUser = 'widen-user';
    const authPass = 'secret-password';

    // Construct expected basic auth header value
    const authString = 'Basic ' + new Buffer(authUser + ':' + authPass).toString('base64');

    // If basic auth header does not match, send WWW-Authenticate response
    if (typeof headers.authorization == 'undefined' || headers.authorization[0].value != authString) {
        const body = 'Unauthorized';
        const response = {
            status: '401',
            statusDescription: 'Unauthorized',
            body: body,
            headers: {
                'www-authenticate': [{key: 'WWW-Authenticate', value:'Basic realm="Widen CI Artifacts"'}]
            },
        };
        callback(null, response);
    }

    // Continue request processing if authentication passed
    callback(null, request);
};

This works surprisingly well; however, there was a lot of room for improvement. The most glaring obvious scalability problem is having a single shared password. One option we considered was to extend this code to improve the loading of passwords; the major downsides were still needing to manually manage user credentials and requiring users to remember yet another password.

We use Google’s G Suite internally for email; we thought if we were to leverage their support of OpenID Connect as a relying party we could completely remove the need for our Lambda@Edge function to know anything about usernames or passwords.

At this point, the plan seemed clear:

  1. Configure CloudFront to inspect HTTP requests as they were received.
  2. If the request is not already authenticated, use one of the many OpenID implementations to redirect the user to the Google login UX.
  3. Perform user authorization (email whitelist, Google Groups membership, etc.)
  4. Set a stateless JWT authentication token, as a cookie, with a configurable TTL.
  5. Redirect user to original request path.

As is the case with every new project, the original plan never lasts long. Lambda@Edge has a few major limitations that interfered:

  1. Max size of zipped Lambda function (including libraries) is 1MB
  2. Environment variables are not supported

Having such a small size limit posed a big issue with the use of an OpenID implementation to interact with OpenID Connect providers. All of the supported implementations exceeded the 1MB limit alone (typically ~2MB). Writing the URL query parameter composition, focusing on the bare minimum we needed for full functionality, using the built-in functions in Node proved an easy way to keep us under the limit.

The lack of environment variables created another hurdle to overcome. The end goal for this project was to allow users to configure a function without having to edit source code. Not having environment variables at hand meant user-specific data must be stored in the uploaded Lambda@Edge Javascript function itself. The next best option seemed to be an interactive build script allowing the Lambda function to be dynamically built without having to manually edit a configuration file.

Although having a configuration file and build script seemed cumbersome at first, it proved to be a useful addition. The build script freed up expansion options greatly by easily processing user input and automating the creation of the ZIP file to upload to Lambda. For example, the build script will make structural changes to the project based on the selections chosen (e.g. moving the Google Groups authorization file to the root as auth.js).

After our set of detours, it was an open road. After completing the Google version, we also added support for GitHub and Microsoft authentication. The providers have different authorization methods based upon specific user data available in their authentication callback:

  • Google: Hosted domain verification, email whitelist or Google Groups membership
  • Microsoft: Azure AD membership or Azure AD username whitelist
  • GitHub: Organization membership

cloudfront-auth is now available on GitHub as an open source project under the ISC License.