Setting up a NextJS Client to use IdentityServer for Authentication

This is a client using NextJS, authenticating with a .NET Duende IdentityServer.

This uses a separate IdentityServer setup than the installation guidelines provided elsewhere in this documentation repository.

This documentation assumes that you have a Next.js client you wish to set up with account login and registration, using Duende IdentityServer.

Table of Contents

  1. Dependencies
  2. Installing the IdentityServer Dependencies
  3. Setting up the IdentityServer
  4. Setting up the Client
  5. Adding User Registration

Dependencies

  1. .NET 6 SDK
  2. ASP.NET 6
  3. Duende IdentityServer 6
  4. Next.js (Node.js 20.9.0)
  5. NextAuth

Installing the IdentityServer Dependencies

Set up NuGet source. Run these commands in a terminal:

dotnet nuget add source https://api.nuget.org/v3/index.json -n nuget.org
dotnet nuget enable source nuget.org

Install the IdentityServer templates. You will use these to start the new IdentityServer project.

dotnet new install Duende.IdentityServer.Templates

Setting up the IdentityServer

Create a directory wherever you want to have these files. In the root directory of your project, add a src directory.

In the root of the project, create a new .NET solution.

dotnet new sln -n project_name

Create an IdentityServer ASP.NET Identity project in the src folder and add it to the solution.

dotnet new isaspid -n IdentityServerAspNetIdentity
dotnet sln add IdentityServerAspNetIdentity/IdentityServerAspNetIdentity.csproj

The CLI will prompt you if you want to seed the data. If you want to use the default SQLite database, select Yes. Otherwise, select No. This will seed a SQLite database with test users “alice” and “bob” who have passwords “Pass123$”.

This will spin up an IdentityServer project that we can use to authenticate clients, so now we need to configure this to accept our Next.js client.

In order for the application to communicate with the IdentityServer, we need to approve the origin on the server-side. In the HostingExtensions.cs file, add the following service to the builder:

// Configure CORS
builder.Services.AddCors(options =>
        {
            options.AddPolicy(name: "SpecificOrigin", policy =>
            {
                policy.WithOrigins("origin of your Next.js app","...other origins")
                    .AllowAnyHeader()
                    .AllowAnyMethod();
            });
        });

Now that the builder has the service, we need to register it in the pipeline. In the same file, add the following to the middleware:

app.UseCors("SpecificOrigin");

Important Note!!! The call to UseCors() must be made after the call to UseRouting() and before the call to UseIdentityServer(). Otherwise, the IdentityServer will not register the accepted CORS origins, and reject all incoming traffic.

Now we need to set up the Client configuration, so the IdentityServer can approve the Next.js client.

In the Config.cs file of the IdentityServer project, add the following to the list of Clients:

new Client
{
  ClientId = "name of Next.js client",
  ClientSecrets = { new Secret("secret".Sha256()) },

  AllowedGrantTypes = GrantTypes.ResourceOwnerPassword,

  RedirectUris = {"URI of Next.js client/api/auth/callback/credentials"},

  PostLogoutRedirectUris = { "URI of Next.js client" },
  AllowedCorsOrigins = { "URI of Next.js client" },

  AllowOfflineAccess = true,
  AllowedScopes = {
    "openid",
    "profile",
    "...other scopes"
  }
}

Now the IdentityServer should be configured to authorize our Next.js client, and return an access token.

Setting up the Client

This documentation assumes two things: 1. You already have a Next.js project that you want to add authentication to using a login page, and 2. That you want to use a form to login, rather than redirect clients to a new login page and back.

In order to configure the Next.js client to authenticate with Duende IdentityServer, we use NextAuth. If you don’t have it installed in the project, run this command:

npm install next-auth

Add the file [...nextauth].js to the folder api/auth in the pages directory of the project.

Inside this file, we’re going to configure a ‘CredentialsProvider’ to access the Duende IdentityServer. Add the following to the [...nextauth].js file:

import NextAuth from 'next-auth/react'
import CredentialsProvider from 'next-auth/providers/credentials'

export default NextAuth({
  providers: [
    CredentialsProvider({
      id: 'credentials',
      name: 'Credentials',
      credentials: {
        username: { label: "Username", type: "text" },
        password: { label: "Password", type: "password" }
      },
      authorize: async (credentials) => {
        try {
            const response = await fetch('https://identity.optim.boo/connect/token', {
                method: 'POST',
                headers: {
                    'Content-Type' : 'application/x-www-form-urlencoded',
                },
                body: new URLSearchParams({
                    grant_type: 'password',
                    client_id: process.env.DUENDE_IDS6_ID,
                    client_secret: process.env.DUENDE_IDS6_SECRET,
                    username: credentials.username,
                    password: credentials.password,
                    scope:  "openid profile offline_access ...other"
                }),
            })
  
            const data = await response.json()
  
            if (response.ok && data.access_token){
                return Promise.resolve({
                    id: data.user_id,
                    name: data.user_name
                })
            } else {
                return Promise.resolve(null)
            }
        } catch (error) {
            console.error('Authentication error: ', error)
            return Promise.resolve(null)
        }
      }
    })
  ],
  callbacks: {
    async session({ session, token }) {
      session.accessToken = token.accessToken
      return session
    },
    async jwt({ token, user }) {
      if (user){
        token.accessToken = user.accessToken
      }
      return token
    },
    pages: {
      signIn: 'Path to sign in page'
    }
  })

This creates a CredentialsProvider in NextAuth that accesses the IdentityServer and requests an access token. We can use this in our login page to request access.

This CredentialsProvider uses environment variables for the IdentityServer client information, so make sure to update the .env file with the proper information.

For this project, we want to create a separate LoginForm component. We can create the following LoginForm.jsx component:

import { useState } from 'react'

/***
 * Login form.
 */
const LoginForm = ({ onSubmit }) => {
    const [username, setUsername] = useState('')
    const [password, setPassword] = useState('')

    /***
     * Handles submission of the form.
     */
    const handleSubmit = (e) => {
        e.preventDefault()

        onSubmit({ username, password })
    }

    return (
        <form onSubmit={handleSubmit}>
            <div className='flex flex-col my-4'>
                <input className='rounded-lg my-2' type="text" value={username} placeholder='Username' onChange={(e) => setUsername(e.target.value)} />
                <input className='rounded-lg my-2' type="password" value={password} placeholder='Password' onChange={(e) => setPassword(e.target.value)} />
                <button className='rounded-lg shadow-md my-2 p-2 bg-violet-700 hover:bg-violet-900 text-white' type="submit">Login</button>
            </div>
        </form>
    )
}

/***
 * Main export function
 */
export default LoginForm

This will be used in the login page and handles the username and password credentials.

In the login page, add the component and call the handler method:

<LoginForm onSubmit={handleLogin} />

Add the following function to the login page, to handle the form submission:

const handleLogin = async (credentials) => {
  
  try {
    await signIn('credentials', {...credentials, redirect: false})
  } catch (error) {
    console.error('Authentication error: ', error)
  }
}

Now we need to add the handling to the project that redirects pages properly when authenticated, or not. In the login page, add the following:

const { data: session } = useSession()
const router = useRouter()

/***
 * Validates session.
 */
useEffect(() => {
    if (session) {
        router.push('/')
    } 
}, [router, session])

This validates the session, and if one exists, redirects the user to the home page.

On the home page, add the redirection handling:

const { data: session, status } = useSession()
const router = useRouter()

// Redirect to the login page if the session is not authenticated.
useEffect(()=> {
  if(status !== 'authenticated') {
      // Redirect if not authenticated.
      router.push('/account/login')
  }
}, [status, router])

And that should be it! The client should now be able to route users to a login page, and the users should be able to log in using the Next.js form and be authenticated using Duende, then redirected back to the protected home page. Currently, the IdentityServer only has the users “alice” and “bob”. We will want to add registration so that new users can join our client.

Adding User Registration

We want to add a way for new users to register to the client, so that they can log in with their own accounts.

In the Next.js client, add a registration page. This page should have a form with fields for users to input their name, password, email, etc.

We want to add a submission handler to the form: <form ... onSubmit={handleFormRegister}>

Next, implement the handler function:

const handleFormRegister = async (e) => {
  // prevent reloading the form
  e.preventDefault()

  try {
    const registrationResponse = await fetch("URI of IdentityServer/api/registration", {
        method: "POST",
        headers: {
            "Content-Type": "application/json",
        },
        body: JSON.stringify(formData)
    })
  
    if (registrationResponse.ok) {
        console.log("User registered successfully")

        // optional: redirect client back to login page after successful registration.
        //router.push('/account/login')
    } else {
        const registrationData = await registrationResponse.json()
        console.error("Registration error:", registrationData)
    }
  } catch (error) {
    console.error('Registration error: ', error)
  }  

Now we need to configure the IdentityServer to accept and handle requests to the ./api/registration endpoint.

In the IdentityServer project, create the directory src/IdentityServerAspNetIdentity/api/Controllers.

The rest of this documentation assumes that you are using the default IdentityServer SQLite database. If you are using a different database, you may need to configure some files differently.

Inside the new directory, create the file RegistrationController.cs and add the following code to it:

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using IdentityServerAspNetIdentity.Models;
using IdentityServerAspNetIdentity.Data;

[ApiController]
[Route("api/[controller]")]
public class RegistrationController : ControllerBase
{
    private readonly ApplicationDbContext _dbContext;

    public RegistrationController(ApplicationDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    [HttpPost]
    public async Task<IActionResult> Register([FromBody] RegistrationRequest request)
    {
        // Validate request data.
        if (!ModelState.IsValid)
        {
            return BadRequest(ModelState);
        }

        // Check if the username or email is already taken.
        if (await _dbContext.Users.AnyAsync(u=>u.UserName == request.UserName || u.Email == request.Email))
        {
            return Conflict("Username or email is already taken.");
        }

        // Create a new user.
        var newUser = new ApplicationUser
        {
            UserName =  request.UserName,
            NormalizedUserName = request.UserName.ToUpper(),
            Email = request.Email,
            NormalizedEmail = request.Email.ToUpper(),
            PasswordHash = HashPassword(request.Password),
        };

        // Add user to the database.
        _dbContext.Users.Add(newUser);
        await _dbContext.SaveChangesAsync();

        return Ok("User registered successfully.");
    }

    private string HashPassword(string password)
    {
        PasswordHasher<ApplicationUser> passwordHasher = new PasswordHasher<ApplicationUser>();

        return passwordHasher.HashPassword(null, password);
    }
}

This controller accepts an incoming request, and registers a new user to the SQLite database based on the request information.

In the Models directory of the IdentityServer, add the file RegistrationRequest.cs with the following:

using System.ComponentModel.DataAnnotations;

public class RegistrationRequest
{
    [Required]
    public string UserName { get; set; }

    [Required]
    [EmailAddress]
    public string Email { get; set; }

    [Required]
    [MinLength(6)]
    public string Password { get; set; }
}

This is a model class for the incoming user registration requests.

Finally, we need to configure the API endpoint of the IdentityServer to use the CORS, so that it approves the incoming requests from our Next.js client.

In the HostingExtensions.cs file, add the following line to the middleware pipeline:

app.MapControllers()
    .RequireCors("SpecificOrigin");

And that should be it! The Next.js client should now be able to submit registration requests to the IdentityServer, which approves them and adds the user to the database. Then, the user can use that new login information to log into the Next.js client.