Hacking Bangkok Blog

Tech and Living in Thailand's City of Angels

The Hacking Bangkok blog covers I.T. and technology in general, and my experiences working and living in the Kingdom of Thailand. Bangkok has a very long Thai name, which starts with Krung thep - City of Angels.
Bangkok sunset from my bedroom balcony

Wednesday, January 3, 2018

Custom Authorizer for Serverless ASP.NET Core Web APIs with Lambda and Cognito

In real-world web-applications, you nearly always want some way to authenticate users – either all of them, or some subset of them – in order to gate access to some features. In ASP.NET Web API, developers are used to adding the [Authorize] attribute to controller methods, and then relying on a membership database to store users and their roles. With Amazon Cognito User Pools, however, we can offload the storage, management and authentication of users and their roles, while still leveraging the [Authorize] attribute plus a custom AuthorizationHandler class, to control access to Web API methods.
In this walk-through, we’ll create an Amazon Cognito User Pool to store and authenticate our users, an ASP.NET Core Web API serverless project that will be hosted in AWS Lambda and fronted by API Gateway, and a simple Calendar SPA consisting of HTML and JavaScript, which will be part of the Web API project and served by the Lambda function.  Our client-side script will pass a user’s login credentials to Cognito, get back a JSON Web Token (JWT), and pass that in the HTTP Authorization header to our Web API methods that require authorization. We’ll allow reading calendar events for everybody, but restrict creating and editing them to users in a group that we specify.

Create an Amazon Cognito User Pool

Our Cognito User Pool will contain our users, and the groups to which they belong. For this walk-through, we’ll create and configure the User Pool using the AWS Management console, and then add a couple of users manually. For an application with lots of users, you would typically bulk import your users, or allow them to register via your web-application, both of which are supported in Cognito.
In the AWS Management Console, navigate to the AWS Cognito home page, and then click the “Manage your User Pools” button.
Next, create a user pool by clicking the blue, “Create a user pool” button at the top right. Give your user pool a name; for this walk-through, we’ll use the name, “WebApi Calendar App Users”, and then choose “Review Defaults” to see the default settings. You should see something like the following:
Before we create the pool, let’s add an App Client that is allowed to authenticate against our pool.  Click the, “Add app client…” link, which takes you to the App Clients list for this User Pool. There aren’t any yet, so click the, “Add an app client” link, and specify a name for the JavaScript web-app we’ll be creating later. We’ll use “CalendarWebClientApp” for this walk-thru. You need to uncheck the “Generate client secret” option, as the Cognito JavaScript SDK doesn’t support using secrets (since anyone can view the source code in their browser). You should see something similar to the image below:
Create the app client, then the “Return to pool details” link. We can now see our App Client name next to “App clients”. Click the blue “Create Pool” button to create the User Pool. After the pool is successfully created, copy the Pool Id from the top of the page and save it for later. Then click the “App client settings” link in the same navigation menu, and locate the App client ID for CalendarWebClientApp, and save that for later also.
Finally, let’s create a Cognito group and a user for it. We’ll log into our web application with this user later. Click the “Users and groups” link in the left-hand navigation menu, then on the Groups tab, create a new group called, “CalendarWriter”. We don’t need to select an IAM role.  On the Users tab, create a new user. Pick a username, temporary password, and supply an email address. Uncheck “Mark phone number as verified” unless you want to provide a phone number.  Then, add the user you just created to the “CalendarWriter” group. That’s the group we’ll require to create and edit events. New users created via the admin console this way will have to choose a new password on first login – we’ll handle that in JavaScript.
Now we’re ready to create the ASP.NET Core Web API project that will run in our Lambda function.

Create AWS Serverless Application (.NET Core)

Ensure you have already installed the latest version of the AWS Toolkit for Visual Studio, which installs the project templates for AWS projects. We’re creating a new “AWS Serverless Application (.NET Core)” project. After selecting that project type, we’ll choose the “ASP.NET Core Web API” blueprint from the blueprints list. This will generate skeleton code for our project, and also a CloudFormation template that will deploy the solution. This blueprint relies on the Amazon.Lambda.AspNetCoreServer NuGet package to translate calls between API Gateway and the ASP.NET Core framework.
Note that you can do all this on Mac OS X or Linux also. Once you have the .NET Core framework installed, you can install the AWS templates with the dotnet new command:
Bash
@:~$ dotnet new -i Amazon.Lambda.Templates::*

The “Lambda ASP.NET Core Web API” template (short name lambda.AspNetCoreWebAPI) is then available to create new projects. You could then use Visual Studio Code, or any other code editor to write your code, and deploy using the CLI or AWS Management Console. For this walk-through, though, we’ll use Visual Studio 2017 and the AWS Toolkit for Visual Studio.
The ASP.NET Core Web API blueprint project template creates two entry point classes, LocalEntryPoint.cs and LambdaEntryPoint.cs. The LocalEntryPoint class is used when running in your local dev environment, and leverages Kestrel, the ASP.NET Core webserver, while the LambdaEntryPoint relies on API Gateway. This makes it really easy to test out your project locally before deploying it. In fact, you should make sure everything is set up properly by building the project and then running it. Verify the endpoint http://localhost:5000/api/values returns the json array ["value1","value2"] by visiting it in a web browser. That will call the ValuesController’s Get method.
The blueprint project also creates an S3ProxyController, which we don’t need for this project, so we can delete that. We can also remove the CloudFormation template parameters ShouldCreateBucket and BucketName, the conditions CreateS3Bucket and BucketNameGenerated, and all references to them from the serverless.template file.
Before we write any code, let’s add the NuGet package, “Microsoft.AspNetCore.Authentication.JwtBearer”, v1.0.1. Be careful not to install the latest version, since it requires .NET Standard 2.0, which isn’t yet supported in AWS Lambda. You’ll notice that some other NuGet packages are already installed as part of the project template. You can optionally remove the AWSSDK.S3 package, and then delete this line from Startup.ConfigureServices so that the project will still build: services.AddAWSService();
Once we add the Microsoft.AspNetCore.Authentication.JwtBearer v1.0.1 package, we can add our implementation of IAuthorizationRequirement, and our customer AuthorizationHandler class as in the following code samples.
C#
class CognitoGroupAuthorizationRequirement : IAuthorizationRequirement
{
    public string CognitoGroup { get; private set; }

    public CognitoGroupAuthorizationRequirement(string cognitoGroup)
    {
        CognitoGroup = cognitoGroup;
    }
}

C#
class CognitoGroupAuthorizationHandler : AuthorizationHandler<CognitoGroupAuthorizationRequirement>
{
    protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, CognitoGroupAuthorizationRequirement requirement)
    {
        if (!context.User.HasClaim(c => c.Type == "cognito:groups"))
        {
            context.Fail();
            return Task.CompletedTask;
        }

        var group = context.User.FindFirst(c => c.Type == "cognito:groups").Value;

        if (group == requirement.CognitoGroup)
        {
            context.Succeed(requirement);
        }
        else
        {
            context.Fail();
        }

        return Task.CompletedTask;
    }
}

Our custom handler just checks to see if the Cognito group is equal to whatever group we specify in the Authorization Policy we add. Let’s register our policy and add a singleton of the handler class by editing the Startup.ConfigureServices method to look like the below example.
C#
public void ConfigureServices(IServiceCollection services)
{
    // add our Cognito group authorization requirement, specifying CalendarWriter as the group
    services.AddAuthorization(
        options => options.AddPolicy("InCalendarWriterGroup", policy => policy.Requirements.Add(new CognitoGroupAuthorizationRequirement("CalendarWriter")))
    );
    // add a singleton of our cognito authorization handler
    services.AddSingleton<IAuthorizationHandler, CognitoGroupAuthorizationHandler>();
           
    services.AddMvc();

    // Pull in any SDK configuration from Configuration object
    services.AddDefaultAWSOptions(Configuration.GetAWSOptions());
}
For our simple Calendar app, we’ll create one controller, “EventsController”, and one model, “CalendarEvent”. The model will be serialized and deserialized automatically as it’s passed to and from the controller methods. It’s just a POCO class that maps to the required properties of a FullCalendar event. FullCalendar is an open-source JavaScript calendar built on JQuery, which we’ll use in the client-side portion of the project.
C#
public class CalendarEvent
{
    public string id { get; set; }
    public string title { get; set; }
    public bool allDay { get; set; }
    public DateTime start { get; set; }
    public DateTime end { get; set; }
}
In our API controller, we’ll implement three methods – a Get() method that returns all events in a date range, a Post() method to create new events, and a Put() method to edit existing events. We’ll decorate the Put() and Post() methods with our custom [Authorize] attribute and specify the InCalendarWriterGroup policy we registered in Startup.ConfigureServices.  To keep things really simple, for this walk-thru we’ll just be storing events in a static collection in the controller – in a real application, you would want to persist them somewhere durable and shared between different Lambda invocations, like Amazon DynamoDB, a serverless NoSQL database. Lambda functions will “go away” after about 5 minutes of non-use, so any events we add will disappear when that happens. Also note the “test event” we create in the constructor – that’s just to demonstrate the ability to see events when not authenticated.
C#
[Route("api/[controller]")]
public class EventsController : Controller
{
    private static List<CalendarEvent> events;

    public EventsController()
    {
        if (events == null)
        {
            var calEvent = new CalendarEvent()
            {
                id = Guid.NewGuid().ToString(),
                title = "Test Event Title",
                allDay = false,
                start = DateTime.Now,
                end = DateTime.Now.AddHours(2)
            };
            events = new List<CalendarEvent>( new[] { calEvent } );
        }
    }

    // GET api/events
    [HttpGet]
    public IEnumerable<CalendarEvent> Get(DateTime? start, DateTime? end)
    {  
        start = start ?? new DateTime(DateTime.Now.Year, DateTime.Now.Month, 1);
        end = end ?? new DateTime(DateTime.Now.Year, DateTime.Now.Month, DateTime.DaysInMonth(DateTime.Now.Year, DateTime.Now.Month));

        return events.FindAll(x => x.end >= start && x.start <= end);
    }

    // POST api/events
    [HttpPost]
    [Authorize(Policy = "InCalendarWriterGroup")]
    public void Post([FromForm]CalendarEvent calEvent)
    {
        calEvent.id = Guid.NewGuid().ToString();
        events.Add(calEvent);
    }

    // PUT api/events/5
    [HttpPut("{id}")]
    [Authorize(Policy = "InCalendarWriterGroup")]
    public void Put(string id, [FromForm]CalendarEvent calEvent)
    {
        var index = events.FindIndex(x => x.id == id);
        events[index] = calEvent;
    }

}
Finally, we’ll add code to the Startup.Configure method configure the JWT authentication options, and also to enable our app to serve static files (like html, css and JS) as well default files, so we don’t have to specify “index.html” in the URL of the main page. Static files support is enabled with the NuGet package, “Microsoft.AspNetCore.StaticFiles”, v1.0.0. Again, don’t use the latest version since that requires .NET Standard 2.0.
We’re just hard-coding the “Audience” (the app client ID from Cognito) and “Authority” (based on User Pool ID) values for simplicity.  In a production app, you could pass those values as Environment Variables at deploy time, then read the values with Environment.GetEnvironmentVariable. Your Configure method should look like this.
C#
public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
{
    loggerFactory.AddLambdaLogger(Configuration.GetLambdaLoggerOptions());

    app.UseDefaultFiles();  //needs to be before the app.UseStaticFiles() call below
    app.UseStaticFiles();

    app.UseJwtBearerAuthentication(new JwtBearerOptions
    {
        Audience = "",
        Authority = "https://cognito-idp..amazonaws.com/",
        AutomaticAuthenticate = true,
        RequireHttpsMetadata = false //false for dev only, for production the JWT should be sent via https
    });

    app.UseMvc();
}
To build the “Authority” value, just plug in your region and User Pool Id that you copied earlier. If your region was Oregon (us-west-2) and your User Pool Id was us-west-2_AbcDefGhi, for example, your Authority string would be “https://cognito-idp.us-west-2.amazonaws.com/us-west-2_AbcDefGhi”.  For testing locally without worrying about certificates, set “RequireHttpsMetadata” to false. For any production app, you should set this to true so that the JWT token is only sent over HTTPS. The JwtBearer middleware validates the JWT’s digital signature, checks the expiration and does other checks, then creates a ClaimsPrincipal for the current user.
Now we’re ready to add our client-side application!

Add HTML, CSS and JavaScript

ASP.NET Core uses the folder wwwroot as web root by default, so we’ll create that folder in our project, and add our web files to it. You can either install the JavaScript files manually, or using a tool like npm. We’ll need the following libraries for this demo app. I installed the Amazon Cognito, FullCalendar and Moment.js files locally, and referenced the JQuery files via URL, but you can change that to suit your needs. I recommend installing with npm to ensure you get all the dependencies, but links to downloads are included below.



  • Amazon Cognito Identity:  amazon-cognito-identity.min.js
  • AWS Cognito SDK:  aws-cognito-sdk.min.js
  • FullCalendar:  FullCalendar-3.5.1.zip
  • JQuery:  https://code.jquery.com/jquery-3.2.1.min.js
  • JQuery UI:  JQuery UI Download Builder
  • Moment.js:  moment.min.js

  • The API Gateway – Lambda integration doesn’t currently support serving default files from web root, so we’ll place our index.html file in a folder called calendar, inside of wwwroot. Since this demo app is very simple, I embedded the JavaScript used to authenticate and create/edit events inline. You can see the full source code in the Git repo. We set the source for events in FullCalendar to “../api/events” (FullCalendar passes the start and end arguments), and add handlers for dayClick and eventClick events. Reading events (the “Get()” method in EventsController) doesn’t require authorization.
    Creating or editing events will require a valid JWT showing the user in the CalendarWriter group. So we add a login button to our HTML, and wire it up to an authentication flow. When authenticating, we’ll handle the onSuccess, onFailure, and newPasswordRequired events. On first login, the newPasswordRequired event is raised. We’ll prompt the user for a new password, and then they’ll need to log in with it. This will change the user’s status in Cognito from FORCE_CHANGE_PASSWORD to CONFIRMED.
    The JavaScript in the authenticate() function reads the username and password values from the form, configures the user pool (with the User Pool Id and App Client Id you copied earlier), then calls the CognitoUser.authenticateUser() function which makes the call out to Amazon Cognito. The response (if successful) includes the JWT token, which we put into local storage to use for subsequent Post or Put calls to our calendar API. To keep things simple for this walk-thru, we’re not implementing any refresh strategy – when the JWT expires after 1 hour, subsequent PUT/POST calls will fail with a 401 Unauthorized response.
    JavaScript
    function authenticate() {

            var authenticationData = {
                Username: $('#username').val(),
                Password: $('#password').val(),
            };

            var CognitoIdentityServiceProvider = AWSCognito.CognitoIdentityServiceProvider;
            var authenticationDetails = new CognitoIdentityServiceProvider.AuthenticationDetails(authenticationData);
            var CognitoUserPool = AmazonCognitoIdentity.CognitoUserPool;

            var poolData = {
                UserPoolId: 'us-west-2_AbcDefGhi', //user pool id
                ClientId: '' //app client id
            };
            var userPool = new CognitoUserPool(poolData);

            var userData = {
                Username: authenticationData.Username,
                Pool: userPool
            };
            var cognitoUser = new CognitoIdentityServiceProvider.CognitoUser(userData);

            cognitoUser.authenticateUser(authenticationDetails, {
                onSuccess: function (result) {
                    localStorage.setItem('jwt', result.getIdToken().getJwtToken())
                    console.log('jwt token: ' + localStorage.getItem('jwt'));
                    $('#loginform').dialog('close');
                    $('#login').prop("disabled", true);
                },

                onFailure: function (err) {
                    alert(err);
                },

                newPasswordRequired: function (userAttributes, requiredAttributes) {
                    // the api doesn't accept this field back
                    delete userAttributes.email_verified;
                    delete userAttributes.phone_number_verified;
                    $('#loginform').dialog('close');
                    $('#changepass').dialog({ modal: true, title: '1st Login - Change Password', width: 360 });
                    $('#changebtn').click(function () {
                        if ($('#newpass').val() == $('#confirmpass').val()) {
                            cognitoUser.completeNewPasswordChallenge($('#newpass').val(), userAttributes, this);
                            alert('Password changed, please log in with the new password');
                            $('#changepass').dialog('close');
                            Password: $('#password').val('');
                            showLogin();
                        }
                    });
                }
            });
        }

    One important note: we’re implementing the UI and authentication flow ourselves, in HTML and JavaScript. Alternatively, you could have Cognito host the login form and handle the change password flow, and other scenarios as well, including customizing the form with your own logo and CSS.
    You can run the entire project locally from Visual Studio. Once the command-line window shows up, just go to http://localhost:5000/calendar/ in a browser (don’t forget the trailing slash). To deploy to AWS, first stop the application (if it’s running locally), then right-click the project node in Solution Explorer and select, “Publish to AWS Lambda”. The AWS Toolkit’s CloudFormation tab will open and track the status of the stack as it’s deployed. When it is finished, you can copy the AWS Serverless URL into a browser, and add “/calendar/”. The initial load will take a few seconds, as it is a cold start for the Lambda function.
    Once the calendar UI is loaded, and the test event that is generated in EventController’s constructor shows up, you can try adding a new event by clicking on any day box in the calendar. Until you log in successfully, you’ll see an alert, “You are not authenticated, please log in (401)”. After you’ve successfully changed your password (the first time only) and then logged in, you can create and edit events. Note that the JavaScript here is just to demo the minimum Cognito functionality to support a custom AuthorizationHandler in ASP.NET Core.
    Note: Keep in mind that EventsController is storing events in a static collection. Our Lambda function could experience a "cold start" at any time, erasing any events you create - especially after a few minutes of inactivity. For any real application, you will want to store your events in a database. DynamoDB is a great option for this.

    Conclusion

    Hosting an entire website in Lambda is a cool and fun technique, but using the Lambda function to serve static content isn’t optimal – Lambda cold starts mean the index.html page will be slow to load for users whenever the website hasn’t been visited for more than a few minutes. Plus, there’s the limitation of not serving up a default page from the root of our site. To solve both issues, you could move all the static content – html, css and JavaScript – to an Amazon S3 bucket, and enable static website hosting. You can also use your own domain for both the API Gateway API and for the S3 bucket, setting the domain root (yourdomain.com or www.yourdomain.com) to point to the S3 bucket, and a CNAME, like api.yourdomain.com, pointing at your API Gateway API. That way, your webpage can load immediately, and show an “updating” message to users if the API is responding slowly.
    If you have an existing ASP.NET Web API project that you’d like to migrate to run in AWS Lambda, rather than starting from a new project template, check out Norm Johanson’s blog, “Deploy an Existing ASP.NET Core Web API to AWS Lambda”.