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_SECRET
. next-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:
id_token_hint
– ID token of the userpost_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:
- If a user is authenticated, user is able to access both public and private routes.
- 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.
- After a successful sign in, user is redirected back to the former private route for better user experience.
- 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
- https://www.keycloak.org/getting-started/getting-started-docker
- https://www.keycloak.org/docs/latest/server_admin/index.html#core-concepts-and-terms
- https://next-auth.js.org/configuration/initialization#route-handlers-app
- https://next-auth.js.org/providers/keycloak
- https://nodejs.org/docs/latest-v18.x/api/cli.html#–dns-result-orderorder
- https://nextjs.org/docs/getting-started/react-essentials
- https://authjs.dev/guides/basics/refresh-token-rotation
- https://github.com/nextauthjs/next-auth/discussions/3938
- https://nextjs.org/docs/app/building-your-application/routing/middleware
- https://next-auth.js.org/tutorials/securing-pages-and-api-routes#nextjs-middleware