Published on

OpenID Connect with Next.js

Table of Contents

Introduction

Most of the time, I use next-auth for authentication in my Next.js apps. But the problem with next-auth is that it does not support Multitenancy for self-hosted apps. If you want to know more about this you can check out the issue. So I thought it would be a good idea to write a post on how to implement OpenID Connect with Next.js. if you have only one tenant or you are hosting your app on Vercel, you can use Next-auth. But if you have multiple tenants or you are hosting your app on your own server, then you need a flexible solution where you have full control over the authentication process.

Where is the code?

You can find the code for this post in the github repo.

What is OpenID Connect?

OpenID Connect is a simple identity layer on top of the OAuth 2.0 protocol. It allows clients to verify the identity of the end-user based on the authentication performed by an authorization server, as well as to obtain basic profile information about the end-user in an interoperable and REST-like manner.

What is my use case?

Most of my backend services are written in .Net. So I have Openiddict as my OpenID Connect server and I want to use this server to authenticate my Next.js app. All my API calls are authenticated using JWT tokens. So I wanted to do the same with my Next.js app.

What are current options?

There are few libraries that support OpenID Connect and OAuth for the react app. Most notable one is oidc-client-ts and react-oidc-context. They are great options if you are using react. But I wanted something which will not store the tokens in the local storage. I wanted something which will store the tokens in the cookies.

How to implement OpenID Connect with Next.js?

To implement OpenID Connect with Next.js, we will use the openid-client library. This library is a certified OpenID Connect client library for node.js. This library is used by many projects and is well maintained. for the cookies we will use iron-session library. This library is a simple session middleware for Next.js.

Lets get started

First, lets create a new Next.js app

npx create-next-app@latest next-oidc-sample

Next, lets install the required libraries

npm install openid-client iron-session

lets create some files

new-item -f src/app/auth/login/route.ts
new-item -f src/app/auth/logout/route.ts
new-item -f src/app/auth/openiddict/route.ts
new-item -f src/app/session/route.ts
new-item -f src/components/Login.tsx
new-item -f src/hooks/useSession.ts
new-item src/lib.ts
new-item src/.env

These are the files we will be creating. I have used the new-item command to create the files. You can use any editor to create the files.

Next, Add the env variables to the .env file

NEXT_PUBLIC_API_URL=https://abp.antosubash.com
NEXT_PUBLIC_CLIENT_ID=AbpReact_Next_App
NEXT_PUBLIC_APP_URL=http://localhost:3000
NEXT_PUBLIC_SCOPE='openid profile email AbpTemplate offline_access'

In the above code, I'm using the ABP template as my OpenID Connect server. You can use any OpenID Connect server.

Now lets create lib.ts file

import { IronSession, SessionOptions, getIronSession } from 'iron-session'
import { cookies } from 'next/headers'
import { Issuer } from 'openid-client'

export const clientConfig = {
  url: process.env.NEXT_PUBLIC_API_URL,
  audience: process.env.NEXT_PUBLIC_API_URL,
  client_id: process.env.NEXT_PUBLIC_CLIENT_ID,
  scope: process.env.NEXT_PUBLIC_SCOPE,
  redirect_uri: `${process.env.NEXT_PUBLIC_APP_URL}/auth/openiddict`,
  post_logout_redirect_uri: `${process.env.NEXT_PUBLIC_APP_URL}`,
  response_type: 'code',
  grant_type: 'authorization_code',
  post_login_route: `${process.env.NEXT_PUBLIC_APP_URL}`,
}

export interface SessionData {
  isLoggedIn: boolean
  access_token?: string
  code_verifier?: string
  userInfo?: {
    sub: string
    name: string
    email: string
    email_verified: boolean
  }
  tenantId?: string
}

export const defaultSession: SessionData = {
  isLoggedIn: false,
  access_token: undefined,
  code_verifier: undefined,
  userInfo: undefined,
  tenantId: undefined,
}

export const sessionOptions: SessionOptions = {
  password: 'complex_password_at_least_32_characters_long',
  cookieName: 'next_js_session',
  cookieOptions: {
    // secure only works in `https` environments
    // if your localhost is not on `https`, then use: `secure: process.env.NODE_ENV === "production"`
    secure: process.env.NODE_ENV === 'production',
  },
  ttl: 60 * 60 * 24 * 7, // 1 week
}

export async function getSession(): Promise<IronSession<SessionData>> {
  let session = await getIronSession<SessionData>(cookies(), sessionOptions)
  if (!session.isLoggedIn) {
    session.access_token = defaultSession.access_token
    session.userInfo = defaultSession.userInfo
  }
  return session
}

export async function getClient() {
  const abpIssuer = await Issuer.discover(clientConfig.url!)
  const client = new abpIssuer.Client({
    client_id: clientConfig.client_id!,
    response_types: ['code'],
    redirect_uris: [clientConfig.redirect_uri],
    token_endpoint_auth_method: 'none',
  })
  return client
}

Session endpoint

This file contains the configuration for the OpenID Connect server and the session configuration. It also contains the functions to get the session and the client.

Let's create a new file for the session endpoint app/session/route.ts

import { defaultSession, getSession } from '@/lib'

export async function GET() {
  try {
    const session = await getSession()
    if (!session) {
      return Response.json({ defaultSession })
    }
    return Response.json({
      isLoggedIn: session.isLoggedIn,
      userInfo: session.userInfo,
    })
  } catch (e) {
    return Response.json({ error: e }, { status: 500 })
  }
}

This file contains the session endpoint. This endpoint will return the session data. This way we can check if the user is logged in or not. this endpoint will be used by the client components to check if the user is logged in or not.

Login, Logout and Callback endpoints

Now lets create the login endpoint app/auth/login/route.ts

import { getClient, getSession, clientConfig } from '@/lib'
import { generators } from 'openid-client'

export async function GET() {
  const session = await getSession()
  session.code_verifier = generators.codeVerifier()
  const code_challenge = generators.codeChallenge(session.code_verifier)
  const client = await getClient()
  const url = client.authorizationUrl({
    scope: clientConfig.scope,
    audience: clientConfig.audience,
    redirect_uri: clientConfig.redirect_uri,
    code_challenge,
    code_challenge_method: 'S256',
    __tenant: session.tenantId,
  })
  await session.save()
  return Response.redirect(url)
}

This file contains the login endpoint. This endpoint will redirect the user to the OpenID Connect server. The OpenID Connect server will authenticate the user and redirect the user back to the callback endpoint.

Now lets create the callback endpoint app/auth/logout/route.ts

import { defaultSession, getClient, getSession, clientConfig } from '@/lib'
import { generators } from 'openid-client'

export async function GET() {
  const session = await getSession()
  const client = await getClient()
  var endSession = client.endSessionUrl({
    post_logout_redirect_uri: clientConfig.post_logout_redirect_uri,
    id_token_hint: session.access_token,
    state: generators.state(),
  })
  session.isLoggedIn = defaultSession.isLoggedIn
  session.access_token = defaultSession.access_token
  session.userInfo = defaultSession.userInfo
  await session.save()
  return Response.redirect(endSession)
}

This file contains the logout endpoint. This endpoint will redirect the user to the OpenID Connect server to logout the user. The OpenID Connect server will clear the session and redirect the user back to the callback endpoint.

Now lets create the callback endpoint app/auth/openiddict/route.ts

import { getClient, getSession, clientConfig } from '@/lib'
import { IncomingMessage } from 'http'

export async function GET(request: IncomingMessage) {
  const session = await getSession()
  const client = await getClient()
  const params = client.callbackParams(request)
  const tokenSet = await client.callback(clientConfig.redirect_uri, params, {
    code_verifier: session.code_verifier,
  })
  session.isLoggedIn = true
  session.access_token = tokenSet.access_token
  const userinfo = await client.userinfo(tokenSet)
  session.userInfo = {
    sub: userinfo.sub,
    name: userinfo.given_name!,
    email: userinfo.email!,
    email_verified: userinfo.email_verified!,
  }
  await session.save()
  return Response.redirect(clientConfig.post_login_route)
}

This file contains the callback endpoint. This endpoint will be called by the OpenID Connect server after the user is authenticated. This endpoint will save the tokens in the session and redirect the user to the home page.

Login component

Now lets create the login component components/Login.tsx

'use client'
import useSession from '@/hooks/useSession'

const Login = () => {
  const { session, loading } = useSession()
  if (loading) {
    return <div>Loading...</div>
  }
  if (session?.isLoggedIn) {
    return (
      <button
        className="inline-flex h-10 items-center justify-center rounded-md bg-blue-600 px-6 text-sm font-medium text-white shadow-sm transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:bg-blue-500 dark:hover:bg-blue-600 dark:focus:ring-blue-400"
        onClick={() => {
          window.location.href = '/auth/logout'
        }}
      >
        Logout
      </button>
    )
  }
  return (
    <button
      className="inline-flex h-10 items-center justify-center rounded-md bg-blue-600 px-6 text-sm font-medium text-white shadow-sm transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:bg-blue-500 dark:hover:bg-blue-600 dark:focus:ring-blue-400"
      onClick={() => {
        window.location.href = '/auth/login'
      }}
    >
      Login
    </button>
  )
}

export default Login

useSession hook

This component will show the login button if the user is not logged in. If the user is logged in, it will show the logout button.

Now lets create the useSession hook hooks/useSession.ts

import { SessionData } from '@/lib'
import { useEffect, useState } from 'react'

export default function useSession() {
  const [session, setSession] = useState<SessionData | null>(null)
  const [loading, setLoading] = useState(true)
  useEffect(() => {
    const fetchSession = async () => {
      try {
        const response = await fetch('/session')
        if (response.ok) {
          const session = (await response.json()) as SessionData
          setSession(session)
        }
      } finally {
        setLoading(false)
      }
    }
    fetchSession()
  }, [])
  return { session, loading }
}

This hook will fetch the session data from the server. This way we can check if the user is logged in or not. This will be used to show the login or logout status in the client components.

Putting it all together

Now lets update the app/page.tsx file

import Login from '@/components/Login'
import { getSession } from '@/lib'

export default async function Home() {
  const session = await getSession()
  return (
    <main className="flex min-h-[100dvh] items-center justify-center bg-gray-100 px-4 dark:bg-gray-900">
      <div className="w-full max-w-md space-y-4 text-center">
        <h1 className="text-3xl font-bold tracking-tight text-gray-900 dark:text-gray-50">
          Welcome back
        </h1>
        <p className="text-gray-500 dark:text-gray-400">Sign in to your account to continue.</p>
        <div>
          <pre>{JSON.stringify(session, null, 2)}</pre>
        </div>
        <Login />
      </div>
    </main>
  )
}

This file will show the session data and the login component. This way we can check if the user is logged in or not.

With this, we have implemented OpenID Connect with Next.js. This is a simple implementation. You can extend this implementation to support more features like refresh tokens, silent renew, etc. You can find the code for this post in the github repo.

Conclusion

The main advantage of using this approach is that you have full control over the authentication process. You can use any OpenID Connect server and you can store the tokens in the cookies. This way you can use the same tokens for the backend services. This approach is more flexible and you can customize it according to your needs. I hope this post was helpful. If you have any questions or feedback, feel free to leave a comment below.