483Ganhammaronsdag 15 maj 2024

<- Back

OpenIddict on AWS Serverless: Flexible OAuth2/OIDC Provider

OpenIddict is a flexible OAuth2 and OpenID Connect server for ASP.NET Core. It's a great choice for building your own OAuth2/OIDC provider. In this post, we'll show you how to deploy OpenIddict on AWS Serverless using AWS Lambda, API Gateway, DynamoDB, and Systems Manager Parameter Store. We will focus on the OAuth2 Client Credentials flow for simplicity, but you can easily extend it to support other flows.

Why not Amazon Cognito?

Amazon Cognito is a managed service that provides user authentication and authorization for your applications. It supports OAuth2 and OpenID Connect out of the box, so why not use it instead of OpenIddict? Here are a few reasons:

  1. Customization: Amazon Cognito provides a set of predefined features and configurations. If you need more flexibility or customization, you may find it limiting, especially when it comes to the hosted UI.
  2. Geo-Location: Amazon Cognito is a regional service, which means it's limited to the region where it's deployed. If you need to support multiple regions, you'll have to deploy multiple instances of Amazon Cognito, which can be complex and costly, especially if you have a global user base.
  3. Cost: Amazon Cognito has a free tier, but, additional costs will incur as your user base grows, especially if you have a need to customize the access token, using the pre-token-generation Lambda, since it requires Advanced Security to be enabled on the user pool.
  4. Disaster Recovery: Amazon Cognito doesn't provide a built-in disaster recovery solution. You'll have to implement your own backup and restore strategy, which can be complex and error-prone. For instance, the user sub (subject) is unique per user pool, so if you lose the user pool, you'll lose the user sub, which can be a problem if you're referencing it in your application(s).

Architecture

The architecture of our solution is rather simple and straightforward. We'll use AWS Lambda to host our OpenIddict server, which will be a Lambdalith (Lambda Monolith) function, API Gateway to expose the proxy endpoint, DynamoDB to store the configuration, mainly client configuration in this example, and Systems Manager Parameter Store to store keys for data protection and the OpenIddict certificates used for signing and encryption.

Implementation

Let's start from an empty web project, run dotnet new web in the target folder and add the dependencies that we'll use for the project:

dotnet add package Amazon.AspNetCore.DataProtection.SSM dotnet add package Amazon.Extensions.Configuration.SystemsManager dotnet add package Amazon.Lambda.AspNetCoreServer.Hosting dotnet add package AWSSDK.DynamoDBv2 dotnet add package Community.OpenIddict.AmazonDynamoDB dotnet add package OpenIddict.AspNetCore

Great, let's start by adding AWS Lambda hosting and configuring DynamoDB in Program.cs:

var builder = WebApplication.CreateBuilder(args); var services = builder.Services; var configuration = builder.Configuration; var environment = builder.Environment; services.AddAWSLambdaHosting(LambdaEventSource.HttpApi); services .AddDefaultAWSOptions(configuration.GetAWSOptions()) .AddSingleton<IAmazonDynamoDB>(new AmazonDynamoDBClient());

Next we'll configure AspNetCore DataProtection, which is used to encrypt and decrypt data, including authentication tokens. To ensure the ability to decrypt tokens even after the environment is reinitialized, it's crucial to store the keys externally, rather than in the project's file system. For this purpose, we'll utilize AWS Systems Manager Parameter Store:

services .AddDataProtection() .PersistKeysToAWSSystemsManager("/OpenIddictServerlessDemo/DataProtection");

It's time to configure OpenIddict, we're going to use DynamoDB to persist OpenIddict data, which is where the package Community.OpenIddict.AmazonDynamoDB comes in. We will only enable client credentials for this project:

services .AddOpenIddict() .AddCore(builder => { builder .UseDynamoDb() .SetDefaultTableName("openiddict-serverless-demo.openiddict"); }) .AddServer(builder => { builder.SetTokenEndpointUris("/connect/token"); builder.AllowClientCredentialsFlow(); var aspNetCoreBuilder = builder .UseAspNetCore() .EnableTokenEndpointPassthrough(); if (environment.IsDevelopment()) { builder.AddEphemeralEncryptionKey(); builder.AddEphemeralSigningKey(); aspNetCoreBuilder.DisableTransportSecurityRequirement(); } });

At this point, we should be able to run the project and access the OpenID discovery document by going to http://localhost:{PROJECT_PORT}/.well-known/openid-configuration. The server should return a JSON document with the details about your OpenID server.

It's time to configure the token endpoint by adding a POST endpoint with a path that matches the path specified when adding OpenIddict to the project (builder.SetTokenEndpointUris("/connect/token");). The request has already been validated by OpenIddict, all we have to do is to create the claims principal that will be used to generate the token. Here we´re replacing the existing MapGet with:

app.MapPost("/connect/token", async ( HttpContext httpContext, IOpenIddictApplicationManager applicationManager, IOpenIddictScopeManager scopeManager) => { var openIddictRequest = httpContext.GetOpenIddictServerRequest()!; var application = await applicationManager.FindByClientIdAsync(openIddictRequest.ClientId!); if (application == default) { return Results.BadRequest(new OpenIddictResponse { Error = Errors.InvalidClient, ErrorDescription = "The specified client identifier is invalid." }); } var identity = new ClaimsIdentity( TokenValidationParameters.DefaultAuthenticationType, Claims.Name, Claims.Role); identity.SetClaim(Claims.Subject, (await applicationManager.GetClientIdAsync(application))!); var principal = new ClaimsPrincipal(identity); principal.SetScopes(openIddictRequest.GetScopes()); principal.SetResources(await scopeManager.ListResourcesAsync(principal.GetScopes()).ToListAsync()); return Results.SignIn(principal, new(), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); });

The method scopeManager.ListResourcesAsync returns IAsyncEnumerable, to simplify the usage we'll create an extension method, ToListAsync, which can be used to convert the result to a list. Here we're adding a new file (Extensions/AsyncEnumerableExtensions.cs) with the follow extension method:

namespace System.Collections.Generic; public static class AsyncEnumerableExtensions { public static Task<List<T>> ToListAsync<T>(this IAsyncEnumerable<T> source) { ArgumentNullException.ThrowIfNull(source); return ExecuteAsync(); async Task<List<T>> ExecuteAsync() { var list = new List<T>(); await foreach (var element in source) { list.Add(element); } return list; } } }

Finally we need to ensure that the database is seeded and that there is client for us to test with. In a production scenario, the below code would be moved to a seed script that is run a part of ci/cd.
We'll use the OpenIddictApplicationManager to check if the demo client exists, if not, we'll use the same instance to create it. Let's add the script just before app.Run():

OpenIddictDynamoDbSetup.EnsureInitialized(app.Services); using (var scope = app.Services.CreateScope()) { CreateDemoClient(scope.ServiceProvider).GetAwaiter().GetResult(); } static async Task CreateDemoClient(IServiceProvider provider) { var manager = provider.GetRequiredService<IOpenIddictApplicationManager>(); var clientId = "openiddict-serverless-demo"; var exists = await manager.FindByClientIdAsync(clientId); if (exists != null) { return; } await manager.CreateAsync(new() { ClientId = clientId, ClientSecret = "{SOME_SECRET_STRING}", DisplayName = "Demo client application", Permissions = { Permissions.Endpoints.Token, Permissions.GrantTypes.ClientCredentials } }); }

Let's run the project and request a token using client_credentials. For this we can use any HTTP client, such as Postman, Insomnia, Thunder Client, or plain cURL:

curl http://localhost:{PROJECT_PORT}/connect/token \ -X POST \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=client_credentials&client_id=openiddict-serverless-demo&client_secret={SOME_SECRET_STRING}"

Deploying to AWS

Next we will create the GitHub pipeline that will deploy the project to AWS, but, before we do that, we must add signing and encryption certificates for production use. The certificates will be stored as secure strings in AWS Systems Manager Parameter Store. To use them in our application, we will add Systems Manager as a configuration source:

var builder = WebApplication.CreateBuilder(args); builder.Configuration.AddSystemsManager("/OpenIddictServerlessDemo/Certificates"); // Add this var services = builder.Services; // ...

Then we will add an else-statement connected to the environment.IsDevelopment()-check:

if (environment.IsDevelopment()) { // ... } else { var signingCertificate = configuration.GetValue<string>("SigningCertificate"); var encryptionCertificate = configuration.GetValue<string>("EncryptionCertificate"); if (string.IsNullOrEmpty(signingCertificate) || string.IsNullOrEmpty(encryptionCertificate)) { throw new InvalidOperationException("SigningCertificate and EncryptionCertificate must be set in the configuration."); } builder .AddSigningCertificate(new X509Certificate2(Convert.FromBase64String(signingCertificate))) .AddEncryptionCertificate(new X509Certificate2(Convert.FromBase64String(encryptionCertificate))); }

The certificates can be generated using System.Security.Cryptography, I will not go into to much details about how it is done here, but, there is a helper project in the companion repository that can be used to generate them. Then create the secured strings using the AWS Console following naming convention above (/OpenIddictServerlessDemo/Certificates/(SigningCertificate|EncryptionCertificate)).

Defining the stack

For this project we will use AWS SAM to define our stack. The stack contains an instance of an API Gateway HTTP API and a Lambda function, running our OpenIddict instance, with all requests to the API Gateway proxied to it. The Lambda function needs access to the DynamoDB table, created out side of the stack, and Systems Manager, to read the certificates, and to write and update DataProtection keys. We also need to add some policies for the seed script that should not be needed once the seed script is moved out of project startup.

The Lambda function will be built using a makefile, which needs to be put in the root of OpenIddict project:

build-OpenIddictFunction: dotnet publish -c Release ./OpenIddictServerlessDemo.csproj -o $(ARTIFACTS_DIR)

And this is the AWS SAM template for our stack:

AWSTemplateFormatVersion: '2010-09-09' Transform: AWS::Serverless-2016-10-31 Description: OpenIddit Serverless Demo Globals: Function: MemorySize: 1769 Architectures: - arm64 Runtime: dotnet8 Timeout: 30 Environment: Variables: TABLE_NAME: openiddict-serverless-demo.openiddict ASPNETCORE_ENVIRONMENT: Production Resources: OpenIddictApi: Type: AWS::Serverless::HttpApi OpenIddictFunction: Type: AWS::Serverless::Function Properties: CodeUri: ./src/OpenIddictServerlessDemo Handler: OpenIddictServerlessDemo Events: Api: Type: HttpApi Properties: ApiId: !Ref OpenIddictApi Path: /{proxy+} Method: ANY Policies: - DynamoDBCrudPolicy: TableName: !Sub "openiddict-serverless-demo.openiddict" - Statement: - Effect: Allow Action: - ssm:GetParametersByPath Resource: - Fn::Sub: arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/OpenIddictServerlessDemo/Certificates - Effect: Allow Action: - ssm:PutParameter - ssm:GetParameter - ssm:DescribeParameters - ssm:GetParametersByPath Resource: - Fn::Sub: arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/OpenIddictServerlessDemo/DataProtection/ # Needed for setup script, should not be run during startup in production - Effect: Allow Action: - dynamodb:ListTables Resource: "*" - Effect: Allow Action: - dynamodb:DescribeTable - dynamodb:DescribeTimeToLive Resource: - Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/openiddict-serverless-demo.openiddict Metadata: BuildMethod: makefile

Adding GitHub pipelines

Finally we'll add a GitHub pipeline which will be used to build and deploy our project. It uses OpenID Connnect to authenticate against AWS. We'll add the pipeline in the file .github/workflows/main.yml with the following definition:

on: push: branches: - main env: AWS_REGION: eu-north-1 permissions: id-token: write contents: read packages: read jobs: build-deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup .NET Core SDKs uses: actions/setup-dotnet@v4 with: dotnet-version: 8.0.x - uses: aws-actions/setup-sam@v2 - name: Configure AWS Credentials uses: aws-actions/configure-aws-credentials@v4 with: aws-region: eu-north-1 role-to-assume: ${{ vars.DEPLOY_ROLE }} role-session-name: OIDCSession - run: sam build - run: sam deploy --no-fail-on-empty-changeset --stack-name openiddict-serverless-demo --resolve-s3 --capabilities CAPABILITY_IAM

Wrapping Up

And that is it, now you should have a deployed instance of OpenIddict that is utilizing a completly serverless-stack which can easily be extended with more OAuth2/OIDC flows. It could also easily be scaled out to more regions with DynamoDB global tables.

The companion repository for this post can be found here.

<- Back