Technical Development in Practice: Building an OAuth Lambda Function
Here at Wolfjaw, we regularly build account systems that can potentially service millions of players. These systems can appear complex and difficult to understand. In many cases, they are. However, all account systems are made up of simple building blocks at their core.
This article aims to shed some light on how Wolfjaw designs these account systems, using a practical example which touches on the core concepts that make up production grade solutions.
By the end of this two-part series, we will have an account system that lets users:
- Login with Discord
- Create Barebones Accounts
- Get a Login Session
- Log Out
This system will also set the stage for future expansions, where we can integrate account linking and support storing other information, like profile pictures, display names, and more.
Technology
We often say that what technologies you choose don’t matter within some subset of reasonable choices for your problem and your data. Let's talk through some facts about our system and how it leads to certain decisions regarding which database we use and which programming language we use:
Database
When choosing between databases, I like to first decide, do we need a relational database? Or is a simple document store sufficient?
When I think of accounts, I think: an account may have data that relates to other data.
- An account may have authentication data (like linked identity providers) associated with it
- An account can have other data, like a profile picture, username, etc associated with it.
- An account may need to be queried regularly for some subset of its data
- We require strong consistency guarantees between certain data, it doesn’t make sense to have authentication and external login data without an internal account associated with it.
At this point the answer becomes clear, we should use a relational database.
Some common options in this category are:
- PostgreSQL
- MySQL
- SQLite
Programming Language
Choosing which programming language to use is a bit easier in my opinion. We don’t have any need to access memory directly for what we are building, so a language where direct memory manipulation is possible (like C++) isn’t necessary. The performance of this service is not likely going to be limited by any business logic, rather than by I/O to the database and Web Requests to Discord (our auth provider).
With this in mind, my advice would be to build in whatever language you’re most comfortable in. In the general case for most popular languages, there isn’t a wrong choice, it’s just a tool to get the job done.
For this project in particular, a language like Python or C#, while not traditionally considered “as performant” as a language like C++, is still sufficient and doesn’t require us to focus on things like memory management, compilation complexities, and other readability concerns.
With the above in mind, I chose C# for this project.
Platform
After deciding on our database and programming language, we also should think about how we want to run our code. For this tutorial, I'm writing a serverless function using AWS Lambda which accesses an RDS SQL database.
This is primarily for cost and scalability. Early in development, accounts systems experience a very light load, making Lambda’s bill by usage model very appealing.
Next, let's talk about how to design our API endpoints.
Design
We are essentially building a REST API to support login. Something like this is required to implement one of the flows in our Building a Multiplayer Backend: Authentication article. We’re going to provide the following endpoints in our API:
Our login flow will follow the sequence diagram below, we are relying on Discord and OAuth to help us execute the login flow and provide some seed data for our account. A user is only successfully logged in if they complete the login flow on Discord, provide a valid code, and we get valid details back from Discord.
Setup
First, if you don’t have one, Create an AWS Account.
Within this AWS Account, we will want to create an IAM account that we can use to provision these resources, but this is not required.
If you wish to create an IAM policy for this tutorial, you should create the IAM role and add AWSLambdaFullAccess, AWSCloudFormationFullAccess, IAMFullAccess, AmazonAPIGatewayAdministrator and AmazonS3FullAccess to the account. For a later part of the tutorial, you should also add RDSFullAccess. Alternatively, you can use the AdministratorAccess policy as a catch-all.
You can do this from the CLI using the root account or an administrative IAM role:
aws iam create-user --user-name LambdaUser
aws iam create-access-key --user-name LambdaUser
# Save this access key and ID
# Attach permissions to the account
aws iam attach-user-policy --user-name LambdaUser --policy-arn arn:aws:iam::aws:policy/AWSLambdaFullAccess
aws iam attach-user-policy --user-name=LambdaUser --policy-arn arn:aws:iam::aws:policy/AmazonS3FullAccess
aws iam attach-user-policy --user-name=LambdaUser --policy-arn arn:aws:iam::aws:policy/AWSCloudFormationFullAccess
aws iam attach-user-policy --user-name=LambdaUser --policy-arn arn:aws:iam::aws:policy/IAMFullAccess
aws iam attach-user-policy --user-name=LambdaUser --policy-arn arn:aws:iam::aws:policy/AmazonAPIGatewayAdministrator
You can follow the documentation here: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_users_create.html.
Additionally, we will install the following tools:
- AWS CLI - https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html
- AWS SAM - https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html
- Dotnet 8 - https://dotnet.microsoft.com/en-us/download/dotnet/8.0
- An Editor, like VSCode, Visual Studio or Rider
Configure Your Tooling
Firstly, set up the AWS CLI with your AWS account. The simplest way to do this is to follow the instructions here: https://docs.aws.amazon.com/cli/latest/userguide/cli-authentication-user.html. However, more secure options exist and can be explored on the Authentication and access credentials page.
This tutorial assumes that you are working in a Unix environment (Git bash, Windows Subsystem for Linux, a Linux environment, or MacOS)
Getting Started
AWS Provides some helpful templates to kick off a new Lambda project, we are going to take advantage of those to create ours.
In a working directory, run:
dotnet tool install -g Amazon.Lambda.Tools
dotnet new install "Amazon.Lambda.Templates"
dotnet new serverless.AspNetCoreMinimalAPI -n AuthLogin
This will give you an empty template, the contents of which should look something like this: https://github.com/CatenaTools/blog-auth-tutorial/tree/59cbb7fcb3561ece797e96005db2f8cae34d0ebe.
There are a couple important components here, but to start we are going to primarily work out of Program.cs
, and serverless.template
.
cd
to the directory containing Program.cs
and serverless.template
. We are going to start by deploying the template.
cd AuthLogin/src/AuthLogin/
mv serverless.template template.json # https://github.com/CatenaTools/blog-auth-tutorial/tree/8e5e2cbfec34a4a3fc32bb0c761f63977cf8d2d0
sam build
You should see some output, and eventually
Build Succeeded
Built Artifacts : .aws-sam/build
Built Template : .aws-sam/build/template.yaml
Commands you can use next
=========================
[*] Validate SAM template: sam validate
[*] Invoke Function: sam local invoke
[*] Test Function in the Cloud: sam sync --stack-name {{stack-name}} --watch
[*] Deploy: sam deploy --guided
After which we can follow its instructions and run sam deploy –guided
Most of the defaults are okay, but for this example, reply Y
to the question “AspNetCoreFunction has no authentication. Is this okay?”
Note: You may need to specify --profile {aws_profile} if you do not have one specified in your configuration and --region us-east-1 (or whatever region you chose).
After you complete all of the prompts, the command line tool will deploy your Lambda application. You will see a URL in the command output, you can visit this URL and see:
“Welcome to running ASP.NET Core Minimal API on AWS Lambda”
This means we’re all set to start working!
Implementation
Writing Some Code
Now it’s time to dive in and get to the fun part! First, we’ll start with a simple scaffolding to test our basic functionality and iterate from there. Remember to test your code often. Iterating quickly like this allows you to understand the system as you build it, catch bugs early, and write more understandable code.
First things first, open up Program.cs
. We are going to make a few changes to set up our API. Add one GET endpoint and one POST endpoint to the API. These will act as our callback and logout calls respectively.
app.MapGet("/", () => "Welcome to the Authentication Tutorial!");
app.MapGet("/api/v1/callback", () => {
return Results.Ok("Callback received");
});
app.MapPost("/api/v1/logout", () => {
return Results.Ok("Logout received");
});
Now how do we test this? We can use the sam
cli tool to sync our changes to the running Lambda function. Run sam sync –stack-name sam-app –watch
(change the stack name to whatever your stack name was in the guided deployment).
Note: You may need to specify --profile {aws_profile} if you do not have one specified in your configuration and --region us-east-1 (or whatever region you chose).
Now, as we change the code in our local directory, it is synced to the cloud.
If you refresh the index /
route of your function, you will see the text has changed! And you can visit the callback route and see “Callback Received.” note: the API routes are prefixed with /Prod/
, so callback will be /Prod/api/v1/callback
.
You can see the changes to Program.cs here: https://github.com/CatenaTools/blog-auth-tutorial/commit/fe3227c2e43098d9e62f064aa65fd4cacd6e7534
I have also added a .gitignore, and committed a .sln file for working in my editor, these may look slightly different for you.
Setting up Discord Login
To actually log in with Discord we need to set up an App that our users can log into. Discord has a very good tutorial page on this, but I will highlight the minimum set of steps for this tutorial.
First, visit https://discord.com/developers/applications and click “New Application.” Name it something meaningful.
Once your app is created, navigate to the OAuth tab on the sidebar, we will add our Lambda function’s callback to the redirect URIs. This will be [your lambda url]/Prod/api/v1/callback
.
Scroll down to the OAuth2 URL Generator, check “Identify,” and copy the URL that is returned, we will use that shortly.
If you navigate to it now, you will be prompted to log in to Discord, and after you will see that you have hit our “callback received” endpoint! This is the first building block of our login function.
Creating the Login Button
Now we will give our users some basic UI to log in to our platform, this will be hosted by our Lambda function, but it could be easily built into a launcher, game or other webpage.
We are going to focus on our index /
handler. We need to return some HTML with a link that sends the user to log in through the flow we just practiced.
We are going to create a new directory called Results/
in the root of our project. In it, create HtmlResult.cs. We are going to build out some custom result types that we will use later. The entire file is shown here, but we will break it down bit by bit.
using System.Net.Mime;
using System.Text;
namespace AuthLogin.Results;
internal static class ResultsExtensions
{
public static IResult Html(this IResultExtensions resultExtensions, string html)
{
ArgumentNullException.ThrowIfNull(resultExtensions);
return new HtmlResult(html);
}
public static IResult HtmlWithCookie(this IResultExtensions resultExtensions, string html, Dictionary<string,string> cookies)
{
ArgumentNullException.ThrowIfNull(resultExtensions);
return new HtmlResultWithCookie(html, cookies);
}
}
internal class HtmlResult(string html) : IResult
{
public Task ExecuteAsync(HttpContext httpContext)
{
httpContext.Response.ContentType = MediaTypeNames.Text.Html;
httpContext.Response.ContentLength = Encoding.UTF8.GetByteCount(html);
return httpContext.Response.WriteAsync(html);
}
}
internal class HtmlResultWithCookie(string html, Dictionary<string, string> cookies) : IResult
{
public Task ExecuteAsync(HttpContext httpContext)
{
foreach (var keyValuePair in cookies)
{
httpContext.Response.Cookies.Append(keyValuePair.Key, keyValuePair.Value);
}
httpContext.Response.ContentType = MediaTypeNames.Text.Html;
httpContext.Response.ContentLength = Encoding.UTF8.GetByteCount(html);
return httpContext.Response.WriteAsync(html);
}
}
These helpers will enable us to return HTML to the user, or HTML with cookies. First, we define ResultsExtensions, this is a way for us to expand the functionality of the Results class we use in our handlers (for example Results.Ok()
).
For our HTML extension, we return a new instance of the HtmlResult Class. This class has a very simple implementation, and only sets some content type headers so that the browser knows to interpret our string as HTML.
Now, let's see this in action:
At the top of your Program.cs file, add
using AuthLogin.Results;
To use our new class. Then we can write our handler:
app.MapGet("/", () => {
// The discord login url
var LoginUrl = "https://discord.com/oauth2/authorize?client_id=1263133865019572305&response_type=code&redirect_uri=https%3A%2F%2Fa1kaj9imm7.execute-api.us-east-1.amazonaws.com%2FProd%2Fapi%2Fv1%2Fcallback&scope=identify";
return Results.Extensions.Html($"<html><body><a href='{LoginUrl}'>Login With Discord</a></body></html>");
});
I have updated my index handler to return a link to log in with Discord. You will need to use the URL generated by Discord rather than the one above.
Now when you visit your Lambda function’s index, it will have a link. Clicking that link will bring you to log in with Discord, and then redirect you back to our auth handler.
You’ll notice we have one query parameter, ?code=XXXX
this code is what we need to authenticate with Discord. See the highlighted response in the chart below:
Discord passes us this code so that we can prove a user passed through Discord’s system to Authenticate, without this the login flow would not be secure. Discord will check that this matches our app, and that it came from a trusted source.
Logging in
We need to design our auth system. Thinking back, to authenticate someone, we need to have some information about them that we can correlate to only one user. In this case, that information is going to come from Discord.
Discord and many other platforms will let you request user data using an authenticated OAuth2 session. That is why we checked the Identity scope when we generated the URL above. We are going to ask Discord for information about the user that just logged in, and use that to correlate to another user.
Since Discord’s IDs are globally unique within Discord, one ID will only ever refer to one user, so we can tie that to our internal account.
The code that follows will allow us to:
- Get an Access Token to request data on behalf of the user
- Request user account details about the current user
- Return those details back to the user (for eventual storage in our accounts system)
First, let's grab our code from the query parameter:
app.MapGet("/api/v1/callback", (HttpRequest request) =>
{
var code = request.Query["code"];
if (string.IsNullOrEmpty(code))
{
return Results.BadRequest("No code query parameter");
}
Next, we can make an HTTP Request to Discord using the returned code to complete the login flow. Note in the code below replace client_id
and client_secret
with your ID and secret from your Discord OAuth screen.
Two helpful definitions for OAuth flows in general:
Note: It is best practice NOT to hardcode these values, and instead pull them from an environment variable or secret store. That will not be covered in this tutorial.
We set some parameters to tell Discord what information we’d like using this code, and provide our OAuth scopes. We also provide our Client ID and Secret so Discord knows that we are acting on behalf of our app.
Make sure to add using System.Net.Http.Headers
to the top of your file if the editor doesn’t do it for you, and update the URL in the code below to your OAuth2 redirect URL.
var data = new Dictionary<string, string>
{
{ "grant_type", "authorization_code" },
{ "code", code! },
{ "redirect_uri", "https://a1kaj9imm7.execute-api.us-east-1.amazonaws.com/Prod/api/v1/callback" }, // change this line in your lambda function to match your url
{ "scope", "identify" }
};
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic",
Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"client_id:client_secret")));
var requestURLParameters = new FormUrlEncodedContent(data);
Finally, we can make the request and parse the response:
Be sure to add using System.Text.Json;
at the top of your file if your editor doesn’t do it for you
var oauthTokenResponse = client.PostAsync("https://discord.com/api/oauth2/token", requestURLParameters).Result;
var oauthTokenResponseBody = oauthTokenResponse.Content.ReadAsStringAsync().Result;
var decodedOauthTokenResponse = JsonSerializer.Deserialize<Dictionary<string, Object>>(oauthTokenResponseBody);
var accessToken = decodedOauthTokenResponse["access_token"].ToString();
Now, the accessToken variable contains a token that we can use to request the data we need to authenticate an account, and most importantly, the user ID. See more on access tokens in the table below.
Let’s add one more snippet of code to request that data from Discord, and dump it back to the user:
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var userDataResponse = client.GetAsync("https://discord.com/api/users/@me").Result;
var jsonUserData =
JsonSerializer.Deserialize<Dictionary<string, Object>>(userDataResponse.Content.ReadAsStringAsync().Result);
return Results.Json(jsonUserData);
You will notice in this handler, we are using the access token in the AuthenticationHeaderValue instead of the client id and secret, this is because we are now requesting information on behalf of the user. Discord knows we got this token through our app and so we can use this as our primary identifier.
If you now go back and do the OAuth flow, you should see your discord user data displayed back to you as a JSON Object.
The full code for the callback handler is shown below.
app.MapGet("/api/v1/callback", (HttpRequest request) =>
{
var code = request.Query["code"];
if (string.IsNullOrEmpty(code))
{
return Results.BadRequest("No code query parameter");
}
var data = new Dictionary<string, string>
{
{ "grant_type", "authorization_code" },
{ "code", code! },
{ "redirect_uri", "https://a1kaj9imm7.execute-api.us-east-1.amazonaws.com/Prod/api/v1/callback" },
{ "scope", "identify" }
};
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic",
Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"client_id:client_secret")));
var requestURLParameters = new FormUrlEncodedContent(data);
var oauthTokenResponse = client.PostAsync("https://discord.com/api/oauth2/token", requestURLParameters).Result;
var oauthTokenResponseBody = oauthTokenResponse.Content.ReadAsStringAsync().Result;
var decodedOauthTokenResponse = JsonSerializer.Deserialize<Dictionary<string, Object>>(oauthTokenResponseBody);
var accessToken = decodedOauthTokenResponse["access_token"].ToString();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var userDataResponse = client.GetAsync("https://discord.com/api/users/@me").Result;
var jsonUserData =
JsonSerializer.Deserialize<Dictionary<string, Object>>(userDataResponse.Content.ReadAsStringAsync().Result);
return Results.Json(jsonUserData);
});
The full code is here: https://github.com/CatenaTools/blog-auth-tutorial/commit/5477fc4a55f84f1d376dd84fb8462910fbd2dd5f
Conclusion
At this point, we have a working authentication flow! Our system is able to authenticate with Discord on behalf of a user. This is the first step to getting our accounts system up and running. In a follow-up article, we are going to take this small building block and turn it into a fully fledged accounts system.
If you would like to circumvent the need to build an accounts system from scratch, consider using Catena Tools. If you or your team would like assistance standing Catena up on your infrastructure contact our development team or join our Discord. We’d be happy to help!