265Ganhammarsöndag 4 maj 2025

<- Back

OpenIddict on AWS Serverless: Adding Interactive Login

In a previous post, I described how OpenIddict, a flexible OAuth2/OIDC provider, can be deployed on AWS using only Serverless components such as AWS Lambda, API Gateway, DynamoDB, and Systems Manager Parameter Store. In this post, I'll demonstrate how to extend this demo project with interactive login using Amazon S3 and Amazon CloudFront. If you haven't checked out the first post and created the baseline for this project, I recommend starting there!

Implementation

Let's jump straight into the implementation! Firstly, we will update the openiddict-serverless-demo project to support interactive login, focusing on the Authorization Code Flow. Then we will create a new frontend project, which will use Vite, React, and Radix UI, and will proxy unmatched requests to the OpenIddict API. Finally, we'll add a CloudFront instance with two origins: S3 for the frontend project and API Gateway for the backend project.

Architecture Diagram

Adding Interactive Login

OpenIddict handles the authentication protocols and token issuance; it does not manage user accounts. Therefore, we must add a framework that can manage user accounts, such as ASP.NET Core Identity. Since we're running on AWS Serverless, we'll use the DynamoDB provider AspNetCore.Identity.AmazonDynamoDB. From the root of the openiddict-serverless-demo project, add the package with the following command:

> dotnet add package AspNetCore.Identity.AmazonDynamoDB --project src/OpenIddictServerlessDemo

Now we must configure the framework. In Program.cs, add the following code before the point where we configure OpenIddict (AddOpenIddict):

services .AddIdentity<DynamoDbUser, DynamoDbRole>() .AddDefaultTokenProviders() .AddDynamoDbStores() .SetDefaultTableName("openiddict-serverless-demo.identity");

Next, in the block where we configure the OpenIddict server, we currently allow the Client Credentials flow. Add the following code to configure the authorize endpoint and to allow the Authorization Code flow:

builder.SetAuthorizationEndpointUris("/connect/authorize"); builder.AllowAuthorizationCodeFlow();

Great, let's move on to the endpoints that we'll need to add for login to work. We're going to need two of them, one endpoint to check if the current user is already logged in and one endpoint to handle the actual login:

app.MapPost("/api/user/login", [Consumes("application/json")] async ( LoginInput? login, HttpContext httpContext, UserManager<DynamoDbUser> userManager, SignInManager<DynamoDbUser> signInManager) => { if (string.IsNullOrEmpty(login?.Email) || string.IsNullOrEmpty(login?.Password)) { return Results.BadRequest(); } var user = await userManager.FindByEmailAsync(login.Email); if (user == default) { return Results.BadRequest(); } return Results.Ok(new LoginResult( await signInManager.PasswordSignInAsync( user.UserName!, login.Password, false, false))); }); app.MapGet("/api/user/current", async( HttpContext httpContext, UserManager<DynamoDbUser> userManager ) => { if (httpContext!.User?.Identity?.IsAuthenticated != true) { return Results.NotFound(); } var user = await userManager.GetUserAsync(httpContext!.User); return Results.Ok(user); });

All endpoints are routed through /api/ to simplify the CloudFront configuration. Otherwise, we would need one cache behavior per resource (user and connect today). This means we will also change the path for /connect/token to /api/connect/token.

The login endpoint depends on two models: one for the input and one for the output. Let's add them by creating two new files in a new Models folder. Start with LoginInput.cs, which only needs two properties, Email and Password:

namespace OpenIddictServerlessDemo.Models; public class LoginInput { public string? Email { get; set; } public string? Password { get; set; } }

Next, add LoginResult.cs, which informs the frontend project about the login result. Some properties are not necessarily needed for this project but provide hints on how it can be extended.

using Microsoft.AspNetCore.Identity; namespace OpenIddictServerlessDemo.Models; public class LoginResult(SignInResult signInResult) { public bool Succeeded { get; set; } = signInResult.Succeeded; public bool IsLockedOut { get; set; } = signInResult.IsLockedOut; public bool IsNotAllowed { get; set; } = signInResult.IsNotAllowed; public bool RequiresTwoFactor { get; set; } = signInResult.RequiresTwoFactor; }

Note that the models are in another namespace, so ensure to add the necessary using statements in Program.cs.

Before moving on to the frontend project, we need to add a demo user to test the login functionality. Add the following to the end of Program.cs, where the rest of the setup script is:

OpenIddictDynamoDbSetup.EnsureInitialized(app.Services); AspNetCoreIdentityDynamoDbSetup.EnsureInitialized(app.Services); // Add this using (var scope = app.Services.CreateScope()) { CreateDemoClient(scope.ServiceProvider).GetAwaiter().GetResult(); CreateDemoUser(scope.ServiceProvider).GetAwaiter().GetResult(); // And this } static async Task CreateDemoUser(IServiceProvider provider) { var manager = provider.GetRequiredService<UserManager<DynamoDbUser>>(); var user = new DynamoDbUser { UserName = "Alice", Email = "[email protected]" }; await manager.CreateAsync(user, "Pass@word1"); }

Building the Frontend

For the frontend project, we'll be using Vite as the build tool and React with Radix UI as the UI frameworks. Let's use the React with TypeScript template offered by the Vite community as the base. In a new folder, run the following command:

> npm create vite@latest . -- --template react-ts

Let's also install the dependencies that we'll be using for this project:

> npm i radix-ui @radix-ui/themes styled-components

We will build two screens: one for users to log in and another for users who are already logged in. The expected end result should look like this:

Login ScreenLogged-in Screen
Screenshot of Login ScreenScreenshot of Logged-in Screen

Boilerplating

Before we get started with the login view, we need to add some boilerplating for Radix UI and Styled-Components following the official usage guides. Let's update main.tsx, adding theming, suspense loader fallback, and the container where the app will be rendered:

import { Suspense, StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { App } from "./App.tsx"; import { gray } from "@radix-ui/colors"; import { ThemeProvider } from "styled-components"; import "@radix-ui/themes/styles.css"; import { Flex, Spinner, Theme } from "@radix-ui/themes"; const theme = { colors: { ...gray, }, }; createRoot(document.getElementById("root")!).render( <StrictMode> <Theme> <ThemeProvider theme={theme}> <Flex align="center" justify="center" height="100vh"> <Suspense fallback={<Spinner />}> <App /> </Suspense> </Flex> </ThemeProvider> </Theme> </StrictMode> );

The font that we're using for the login header is Pacifico. Let's add the reference to index.html by adding the following line somewhere in the head section:

<link href="https://fonts.googleapis.com/css?family=Pacifico" rel="stylesheet">

The last thing that we'll do before moving on with the business logic is to add the proxy server. We want to mimic how the application will behave once we have the CloudFront instance in front. All requests to /api/* should be routed to the OpenIddict API. This can be done by updating the vite.config.ts with the following server section in the config:

export default defineConfig({ plugins: [react()], server: { proxy: { "/api": { target: "http://localhost:3001", // Port may vary changeOrigin: true, secure: false, }, }, }, });

We will not be using any router in this demo project. Instead, we will determine which view to render based on whether the user is logged in. This logic will be in App.tsx. We will call the /api/user/current endpoint and, based on the result, either display the Profile or the Login page.

First, we'll define the shape of the user response, if logged in. In a new folder, types, create the file user.d.ts and let's add the properties we care about in this demo:

export type User = { email: string; userName: string; };

Great! Now let's define the function that will call the above-mentioned API and return a User instance or null, depending on the HTTP status of the request. In a new folder, utils, create the file checkUser.ts:

import { User } from "../types/user"; export const checkUser = async (): Promise<User | null> => { const res = await fetch("/api/user/current"); if (res.ok) { return await res.json(); } return null; };

Now it is time to define App.tsx. We will rely on React suspense to render a spinner, defined in main.tsx, while the result from the server is pending, using React 19's use-hook. Upon login, the onLogin callback will be called, which will update the promise by retriggering the API call that the use hook is dependent on, re-rendering the spinner while again waiting for the server response. Upon successful response, the user object will be passed to the Profile-view:

import { use, useState } from "react"; import { Box, Flex, Heading } from "@radix-ui/themes"; import styled from "styled-components"; import { Login } from "./Components/Login"; import { Profile } from "./Components/Profile"; import { checkUser } from "./utils/checkUser"; const MainBox = styled(Box)` background-color: ${({ theme }) => theme.colors.gray1}; border-radius: var(--radius-4); box-shadow: var(--shadow-3); `; const MainHeading = styled(Heading)` font-family: Pacifico; color: ${({ theme }) => theme.colors.gray11}; margin: 0 0 var(--space-5) 0; `; const checkUserPromise = checkUser(); export function App() { const [promise, updatePromise] = useState(checkUserPromise); const user = use(promise); const update = () => { updatePromise(checkUser()); }; return ( <MainBox width="400px" p="5"> <Flex justify="center"> <MainHeading as="h1">Login</MainHeading> </Flex> {user ? <Profile user={user} /> : <Login onLogin={update} />} </MainBox> ); }

Login Screen

Let's move on to the login screen. In a new folder, Components, create the file Login.tsx. For this view, we'll use the Form component from Radix UI, which makes input validation a breeze. We'll also use the React useActionState-hook to indicate that the form is being processed after submission, as well as rendering an error message if the response isn't successful. If it is successful, we'll trigger the onLogin callback function mentioned in the previous section.

import { useActionState } from "react"; import { Form } from "radix-ui"; import { Box, Button, TextField, Flex, Text, Callout } from "@radix-ui/themes"; export function Login({ onLogin }: { onLogin: () => void }) { const [result, onSubmit, isPending] = useActionState( async (_: Record<string, string>, formData: FormData) => { await new Promise((resolve) => setTimeout(resolve, 1000)); const response = await fetch("/api/user/login", { method: "POST", body: JSON.stringify(Object.fromEntries(formData)), headers: { "Content-Type": "application/json", }, }); if (!response.ok) { return { error: `Oops! (Status: ${response.statusText})`, }; } onLogin(); return await response.json(); }, null ); return ( <Form.Root action={onSubmit}> <Box mb="4"> <Form.Field name="email"> <Flex justify="between"> <Form.Label>Email</Form.Label> <Form.Message className="FormMessage" match="valueMissing"> <Text size="2">Please enter your email</Text> </Form.Message> <Form.Message className="FormMessage" match="typeMismatch"> <Text size="2">Please provide a valid email</Text> </Form.Message> </Flex> <Form.Control asChild> <TextField.Root type="email" required></TextField.Root> </Form.Control> </Form.Field> </Box> <Box mb="4"> <Form.Field name="password"> <Flex justify="between"> <Form.Label>Password</Form.Label> <Form.Message className="FormMessage" match="valueMissing"> <Text size="2">Please enter your password</Text> </Form.Message> </Flex> <Form.Control asChild> <TextField.Root type="password" required></TextField.Root> </Form.Control> </Form.Field> </Box> {result?.error && ( <Callout.Root color="red" mb="4"> <Callout.Text>{result.error}</Callout.Text> </Callout.Root> )} <Flex justify="end"> <Form.Submit asChild> <Button loading={isPending}>Login</Button> </Form.Submit> </Flex> </Form.Root> ); }

Profile Screen

The last screen that we'll add is the Profile view. This view accepts the user object as input and renders a, for now, disabled form where the user can update their email:

import { Box, Button, Flex, Text, TextField } from "@radix-ui/themes"; import { Label } from "radix-ui"; import { User } from "../types/user"; export function Profile({ user: { userName, email } }: { user: User }) { const ucFirst = (str: string) => str.charAt(0).toUpperCase() + str.slice(1); return ( <Box> <Text weight="medium" size="4"> Welcome back, {ucFirst(userName)}! </Text> <Box my="4"> <Label.Root>Email</Label.Root> <TextField.Root type="email" disabled value={email}></TextField.Root> </Box> <Flex justify="end"> <Button disabled>Update</Button> </Flex> </Box> ); }

Defining the Stack

We'll continue to use AWS SAM to define our stack. The stack for the frontend app is relatively simple, except for the dependency between the CloudFront instance, which we are yet to define, and the S3 bucket where we need to allow the instance to access it. Similarly, we need the S3 bucket name for the CloudFront origin. To work around this, we will use CloudFormation conditions to only deploy the GetObject policy if the distribution ARN is set:

AWSTemplateFormatVersion: '2010-09-09' Transform: AWS::Serverless-2016-10-31 Description: OpenIddict Interactive Login Serverless Demo Parameters: DistributionArn: Type: String Description: The ARN of the CloudFront distribution to allow access to the bucket Default: '' Conditions: HasDistributionArn: !Not [!Equals [!Ref DistributionArn, '']] Resources: LoginWeb: Type: AWS::S3::Bucket LoginWebPolicy: Type: 'AWS::S3::BucketPolicy' Condition: HasDistributionArn Properties: Bucket: !Ref LoginWeb PolicyDocument: Statement: - Action: 's3:GetObject' Resource: !Sub '${LoginWeb.Arn}/*' Principal: Service: 'cloudfront.amazonaws.com' Effect: 'Allow' Outputs: LoginWebBucketName: Description: "The name of the LoginWeb S3 bucket" Value: !Ref LoginWeb

Now it's time to add the CloudFront instance, which we will add in the stack where we have the API, at least for this proof-of-concept project. The first thing we'll need to do is to add the WebBucketName parameter, passing the name from the frontend stack:

Parameters: WebBucketName: Type: String Description: The name of the Web S3 bucket

Then we also need to add the identity table to the CRUD policies:

- DynamoDBCrudPolicy: TableName: "openiddict-serverless-demo.identity"

And lastly, we'll add the CloudFront distribution with the two cache behaviors and S3 origin access control:

OpenIddictDistribution: Type: AWS::CloudFront::Distribution Properties: DistributionConfig: Enabled: true Origins: - DomainName: Fn::Sub: "${OpenIddictApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}" Id: OpenIddictApiOrigin CustomOriginConfig: HTTPPort: 80 HTTPSPort: 443 OriginProtocolPolicy: https-only - DomainName: Fn::Sub: "${WebBucketName}.s3.${AWS::Region}.${AWS::URLSuffix}" Id: OpenIddictWebOrigin S3OriginConfig: OriginAccessIdentity: "" OriginAccessControlId: !GetAtt S3OriginAccessControl.Id CacheBehaviors: - PathPattern: /api/* TargetOriginId: OpenIddictApiOrigin ViewerProtocolPolicy: redirect-to-https AllowedMethods: [GET, HEAD, OPTIONS, PUT, PATCH, POST, DELETE] MinTTL: 0 MaxTTL: 0 DefaultTTL: 0 ForwardedValues: QueryString: true Headers: ["Authorization"] Cookies: Forward: all DefaultCacheBehavior: TargetOriginId: OpenIddictWebOrigin ViewerProtocolPolicy: redirect-to-https AllowedMethods: [GET, HEAD, OPTIONS] Compress: true DefaultTTL: 0 ForwardedValues: QueryString: true Cookies: Forward: all S3OriginAccessControl: Type: AWS::CloudFront::OriginAccessControl Properties: OriginAccessControlConfig: Name: OpenIddictDemoS3OriginAccessControl OriginAccessControlOriginType: s3 SigningBehavior: always SigningProtocol: sigv4

Remember to re-deploy the frontend stack with the ARN of the CloudFront distribution once it has been created.

Wrapping Up

And that is it. In this project, we have extended the OpenIddict on AWS Serverless project with interactive login. To also utilize the project for generating tokens for your client apps, you'll need to add the /api/connect/authorize endpoint, as demonstrated here and in the official OpenIddict sample projects.

There are two companion repositories for this project. The API project can be found here, and the frontend project can be found here.

<- Back