In the last article, I have discussed about bootrsapping keycloak server along with a basic setup of nextjs with next-auth. In this subsequent article, our focus will shift to configuring next-auth to enhance the user experience while keeping a security-first approach.

Contents

A. Bootstrapping keycloak server

B. Setting up nextjs along with next-auth

C. Refreshing access tokens

D. Federated logout

E. Securing pages with middleware

F. Customising sign in and sign out routes

(A & B was discussed in the last part, we will discuss C–F in this part)

C. Refreshing access token

Understanding JWT session strategy

By default next-auth uses JWT session strategy. This means, all the session data for a user to authenticate is stored in the browser in the form of encrypted cookies. The cookie is encrypted using NEXTAUTH_SECRETnext-auth provides features to use JWT session strategy to store session data such as access token, refresh token and id token and session metadata like name, image etc. in an encrypted form stored in the user’s browser as encrypted cookies. There is no requirement of database.

Storing tokens and session metadata in Browser

We will use callbacks which taps into various steps in an authentication lifecycle to add our requirements.

Let’s add jwt and session callbacks to the authOptions which will help us store tokens and session metadata.

// src/app/api/auth/[...nextauth]/route.ts
export const authOptions: AuthOptions = {
...
...
session: {
maxAge: 60 * 30
},
callbacks: {
async jwt({ token, account }) {
if (account) {
token.idToken = account.id_token
token.accessToken = account.access_token
token.refreshToken = account.refresh_token
token.expiresAt = account.expires_at
}
return token
},
async session({ session, token }) {
session.accessToken = token.accessToken
return session
}
}
}

We define maxAge of Session based on default lifetime span of refresh token in keycloak.

We also require the accessToken to be renewed before its expiry. next-auth has a good guide to help us and we will also go over this now.

The access token has a short life span and needs to be refreshed before its expiry. The default lifespan of an access token in keycloak is 5 minutes.

We are given an additional token known as the refresh token, which has a longer lifespan. The primary function of this token is to renew or acquire a new access token until the refresh token itself expires. Once the refresh token also reaches the end of its validity period, users of the application will be required to re-authenticate with the keycloak authorization server.

We will setup a keycloak API call which would renew access token by taking client credentials and refresh token as input.

// src/app/api/auth/[...nextauth]/route.ts
function requestRefreshOfAccessToken(token: JWT) {
return fetch(`${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/token`, {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: process.env.KEYCLOAK_CLIENT_ID,
client_secret: process.env.KEYCLOAK_CLIENT_SECRET,
grant_type: "refresh_token",
refresh_token: token.refreshToken!,
}),
method: "POST",
cache: "no-store"
});
}

We also need to setup the JWT callback in such a way that if access token has not expired we do not perform any action. If an access token is just about to expire(lets take a buffer of 1 minutes before its expiry) we would send the above request to keycloak authorisation server for a new access token.

We might also need to consider that even refresh token can expire, we request the user of the application to redo the process of authenticating with keycloak.

// src/app/api/auth/[...nextauth]/route.ts
import { AuthOptions, TokenSet } from "next-auth";
import { JWT } from "next-auth/jwt";
import NextAuth from "next-auth/next";
import KeycloakProvider from "next-auth/providers/keycloak"

function requestRefreshOfAccessToken(token: JWT) {
// ... Discussed in previous step
}

export const authOptions: AuthOptions = {
// ...
  callbacks: {
    async jwt({ token, account }) {
      if (account) {
        token.idToken = account.id_token
        token.accessToken = account.access_token
        token.refreshToken = account.refresh_token
        token.expiresAt = account.expires_at
        return token
      }
      // we take a buffer of one minute(60 * 1000 ms)
      if (Date.now() < (token.expiresAt! * 1000 - 60 * 1000)) {
        return token
      } else {
        try {
          const response = await requestRefreshOfAccessToken(token)

          const tokens: TokenSet = await response.json()

          if (!response.ok) throw tokens

          const updatedToken: JWT = {
            ...token, // Keep the previous token properties
            idToken: tokens.id_token,
            accessToken: tokens.access_token,
            expiresAt: Math.floor(Date.now() / 1000 + (tokens.expires_in as number)),
            refreshToken: tokens.refresh_token ?? token.refreshToken,
          }
          return updatedToken
        } catch (error) {
          console.error("Error refreshing access token", error)
          return { ...token, error: "RefreshAccessTokenError" }
        }
      }
    },
// ...
  }
}

const handler = NextAuth(authOptions);

export { handler as GET, handler as POST }

We will wrap our components with a SessionGuard and SessionProvider which would trigger the authentication flow if RefreshAccessTokenError occurs. The wrapping is done in layout component so that all pages/route would be wrapped around with a common root layout which contains the session guard.

The session provider would make sure that the session is kept alive by polling the nextjs server every 4 minutes. This is calculated by having one minute as buffer time.

// src/app/Providers.tsx
'use client'

import { SessionProvider } from "next-auth/react"
import { ReactNode } from "react"

export function Providers({ children }: { children: ReactNode }) {
  return (
    <SessionProvider refetchInterval={4 * 60}>
      {children}
    </SessionProvider>
  )
}
// src/components/SessionGuard.tsx
"use client";
import { signIn, useSession } from "next-auth/react";
import { ReactNode, useEffect } from "react";

export default function SessionGuard({ children }: { children: ReactNode }) {
  const { data } = useSession();
  useEffect(() => {
    if (data?.error === "RefreshAccessTokenError") {
      signIn("keycloak");
    }
  }, [data]);

  return <>{children}</>;
}
// src/app/layout.tsx
import './globals.css'
import { Providers } from './Providers'
import SessionGuard from '@/components/SessionGuard'

// ...

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <Providers>
          <SessionGuard>
            {children}
          </SessionGuard>
        </Providers>
      </body>
    </html>
  )
}

D. Federated Logout

You might notice that when we sign out of the application, it happens instantaneously. Also the subsequent sign in(s) does not prompt username and password. This is due to presence of session created by keycloak with the browser. The signOut function provided by next-auth clears the session present in next application but fails to clear the session on the keycloak. There is a discussion in the next-auth repository to implement federated logout which will end session on the provider. Till then we will implement the functionality manually.

To logout from keycloak we need to redirect to the following end session endpoint:

http://localhost:8080/realms/<realm_name>/protocol/openid-connect/logout

This endpoint requires following query parameters:

  1. id_token_hint – ID token of the user
  2. post_logout_redirect_uri – Self explanatory

Make sure to setup the post_logout_redirect_uri in Keycloak security admin console

Let us start by creating a GET /api/auth/federated-logout route which builds the end session endpoint URL with the required query parameters.

// src/app/api/auth/federated-logout/route.ts
function logoutParams(token: JWT): Record<string, string> {
  return {
    id_token_hint: token.idToken as string,
    post_logout_redirect_uri: process.env.NEXTAUTH_URL,
  };
}

function handleEmptyToken() {
  const response = { error: "No session present" };
  const responseHeaders = { status: 400 };
  return NextResponse.json(response, responseHeaders);
}

function sendEndSessionEndpointToURL(token: JWT) {
  const endSessionEndPoint = new URL(
    `${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/logout`
  );
  const params: Record<string, string> = logoutParams(token);
  const endSessionParams = new URLSearchParams(params);
  const response = { url: `${endSessionEndPoint.href}/?${endSessionParams}` };
  return NextResponse.json(response);
}

export async function GET(req: NextRequest) {
  try {
    const token = await getToken({ req })
    if (token) {
      return sendEndSessionEndpointToURL(token);
    }
    return handleEmptyToken();
  } catch (error) {
    console.error(error);
    const response = {
      error: "Unable to logout from the session",
    };
    const responseHeaders = {
      status: 500,
    };
    return NextResponse.json(response, responseHeaders);
  }
}

Finally we replace signOut functions provided by next-auth with federatedLogout function

// src/components/Logout.tsx
"use client"
import federatedLogout from "@/utils/federatedLogout";

export default function Logout() {
  return <button onClick={() => federatedLogout()}>
    Signout of keycloak
  </button>
}
// src/utils/federatedLogout.ts
import { signOut } from "next-auth/react";

export default async function federatedLogout() {
  try {
    const response = await fetch("/api/auth/federated-logout");
    const data = await response.json();
    if (response.ok) {
      await signOut({ redirect: false });
      window.location.href = data.url;
      return;
    }
    throw new Error(data.error);
  } catch (error) {
    console.log(error)
    alert(error);
    await signOut({ redirect: false });
    window.location.href = "/";
  }
}

E. Securing pages with middleware

In our web application, we might want to keep certain routes accessible when the user is authenticated otherwise the user is redirected to a sign in page. In this section we will discuss the requirements and how to implement it in our application.

Let’s list down our requirements:

  1. If a user is authenticated, user is able to access both public and private routes.
  2. If a user is not authenticated, user can access public routes. In case, the user tries to access private route — the user will be redirected to signin page.
  3. After a successful sign in, user is redirected back to the former private route for better user experience.
  4. The redirection in the requirement — 2 should not be a client side navigation i.e. the application should not load private component on the user’s browser and then realise that the user was not authenticated. Use of getServerSession() would do the work. However, can we do better?

nextjs provides middleware which will run a snippet of code before processing any request. We can define routes which are protected and should be redirected to sign in page. next-auth provides a middleware snippet which can be applied to selected private routes by setting up the path matcher of the route. More details on path matchers can be found here.

// src/middleware.ts
export { default } from "next-auth/middleware"

export const config = {
  matcher: ["/private"]
}

Let us define a public and private routes/pages.

// src/app/public/page.tsx
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import Logout from '@/components/Logout';
import Login from '@/components/Login';

export default async function Public() {
  const session = await getServerSession(authOptions)
  if (session) {
    return <div className='flex flex-col space-y-3 justify-center items-center h-screen'>
      <div>You are accessing a public page</div>
      <div>Your name is {session.user?.name}</div>
      <div>
        <Logout />
      </div>
    </div>
  }
  return (
    <div className='flex flex-col space-y-3 justify-center items-center h-screen'>
      <div>You are accessing a public page</div>
      <div>
        <Login />
      </div>
    </div>
  )
}
// src/app/private/page.tsx
import { getServerSession } from 'next-auth';
import Logout from '@/components/Logout';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';

export default async function Private() {
  const session = await getServerSession(authOptions)
  if (session) {
    return <div className='flex flex-col space-y-3 justify-center items-center h-screen'>
      <div>You are accessing a private page</div>
      <div>Your name is {session.user?.name}</div>
      <div>
        <Logout />
      </div>
    </div>
  }
}

Please note that private component will always have the session since unauthenticated request will be redirected by middleware and redirected to next-auth’s signin page.

Demo

If you try to open http://localhost:3000/private. You would notice in the browser’s network console that a redirection with status code 307 Temporary Redirection happens to sign in route. This redirection takes place as the /private route is matched as defined in src/middleware.ts and the middleware snippet defined by next-auth/middleware redirects to sign in route. The sign in route is provided by next-auth out of the box as discussed earlier.

This redirection does not take place with public route.

F. Using custom Sign in and Sign out components

There might be a requirement that the sign in and sign out routes provided by the next-auth should match you application’s design system. This can be done defining our own routes.

// src/app/auth/signin/page.tsx
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import Login from "@/components/Login";
import { getServerSession } from "next-auth";
import { redirect, useParams } from "next/navigation";

const signinErrors: Record<string | "default", string> = {
// ...
}

interface SignInPageProp {
  params: object
  searchParams: {
    callbackUrl: string
    error: string
  }
}

export default async function Signin({ searchParams: { callbackUrl, error } }: SignInPageProp) {
  const session = await getServerSession(authOptions);
  if (session) {
    redirect(callbackUrl || "/")
  }
  return (
    <div>
      {error && <div>
        {signinErrors[error.toLowerCase()]}
      </div>}
      <Login />
    </div>
  )
}
// src/app/auth/signout/page.tsx
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import Logout from "@/components/Logout";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";

export default async function SignoutPage() {
  const session = await getServerSession(authOptions);
  if (session) {
    return (
      <div>
        <div>Signout</div>
        <div>Are you sure you want to sign out?</div>
        <div>
          <Logout />
        </div>
      </div>
    )
  }
  return redirect("/api/auth/signin")
}

Finally we need to configure next-auth to use custom sign in and sign out routes. This can be done by configuring the right authOptions .

// src/app/api/auth/[...nextauth]/route.ts
import { AuthOptions, TokenSet } from "next-auth";
import NextAuth from "next-auth/next";
import KeycloakProvider from "next-auth/providers/keycloak"

export const authOptions: AuthOptions = {
// ...
  pages: {
    signIn: '/auth/signin',
    signOut: '/auth/signout',
  },
// ...
}

const handler = NextAuth(authOptions);

export { handler as GET, handler as POST }

Demo

If you try to access the private route you will be redirected to our custom sign in route.

Similarly if we try to access http://localhost:3000/auth/signout, the custom sign out route is shown.

Conclusion

I hope you have got an understanding of authentication in Nextjs v13 application.

We have discussed the following features/implementation in detail in this article.

A. Bootstrapping keycloak server

B. Setting up nextjs along with next-auth

C. Refreshing access tokens before they expire

D. Federated logout

E. Securing pages with middleware

F. Customising sign in and sign out routes

All code related to this article is available in this GitHub repository.

References

  1. https://www.keycloak.org/getting-started/getting-started-docker
  2. https://www.keycloak.org/docs/latest/server_admin/index.html#core-concepts-and-terms
  3. https://next-auth.js.org/configuration/initialization#route-handlers-app
  4. https://next-auth.js.org/providers/keycloak
  5. https://nodejs.org/docs/latest-v18.x/api/cli.html#–dns-result-orderorder
  6. https://nextjs.org/docs/getting-started/react-essentials
  7. https://authjs.dev/guides/basics/refresh-token-rotation
  8. https://github.com/nextauthjs/next-auth/discussions/3938
  9. https://nextjs.org/docs/app/building-your-application/routing/middleware
  10. https://next-auth.js.org/tutorials/securing-pages-and-api-routes#nextjs-middleware