OIDC in Next.js App Router with Lucia

by Lorenz Bösch

October 2024

Who is this lucia, your talking about?!

This blog post dives into OIDC and session handling for a Next.js App Router application. I’ll show you why it’s a great idea to use Lucia with a persistent session storage like Redis.

For those not familiar with OIDC, check out this blog post about the essentials of OIDC.

But, who is Lucia?!?! Lucia is a small yet powerful auth library that helps you handle sessions and basic OIDC flows with ease. And guess what? It has some pretty awesome documentation. And here’s the best part—it already has examples for integrating with a Next.js App Router!

Lucia Auth DocumentationApp Router Example on Github

So, why another blog post, you might ask?

While Lucia’s documentation is excellent, this post goes beyond the basics. We'll cover

  • Session Handling for both Server and Client Components
  • How to update the session effectively
  • Refreshing access tokens using refresh tokens
  • Ensure User has always a valid access token

By diving into these more advanced topics, you’ll be able to implement a robust, real-world authentication solution tailored to your Next.js application.

Prerequisites

  • Node 18 or newer
  • Next.js 14 with App router
  • Redis

Basic Setup

javascript
const client = new OAuth2Client(
process.env.OIDC_CLIENT_ID || "",
AUTHORIZE_ENDPOINT,
TOKEN_ENDPOINT,
{
redirectURI: `${process.env.NEXT_PUBLIC_FULL_APP_URL}${
process.env.OIDC_REDIRECT_PATH || ""
}`,
}
);
// 1: create authorization URL for your openid connect provider to redirect client side
export const createAuthorizationURL = async (
state: string,
codeVerifier: string
) =>
await client.createAuthorizationURL({
state,
scopes: ["openid", "profile", "email", "address"],
codeVerifier,
});
// 2: get access token form reutrned code by calling provider's token endpoint server side
export const createSessionFromCode = async (
code: string,
codeVerifier?: string
) => {
const { id_token, access_token, refresh_token, expires_in } =
await client.validateAuthorizationCode<
TokenResponseBody & { id_token: string }
>(code, {
credentials: process.env.OIDC_CLIENT_SECRET || "",
codeVerifier,
authenticateWith: "request_body",
});
const userId = await fetchAndUpdateUser(access_token);
return await lucia.createSession(userId, {
accessToken: access_token,
refreshToken: refresh_token,
idToken: id_token,
accessTokenExpiresAt: Date.now() + (expires_in || 0) * 1000,
});
};

Note: Make sure to use Arctic if your provider is supported. In our example we use Goolge as Identity Provider. We could use arcitc instead of oslo and get a ready-to-use client, i just wanted to show you how you could configure an unkown Identity Provider.

Refresh Tokens

javascript
// 3: refresh access token when it expires with refresh token
const refreshAccessToken = async (session: Session) => {
try {
const response = await client.refreshAccessToken(
session.refreshToken || "",
{
credentials: process.env.OIDC_CLIENT_SECRET,
authenticateWith: "request_body",
}
);
if (!response) {
throw response;
}
const updatedSession = await lucia.createSession(
session.userId,
{
accessToken: response.access_token,
refreshToken: response.refresh_token ?? session.refreshToken, // Fall back to old refresh token,
idToken: session.idToken, // Fall back to old id token,
accessTokenExpiresAt: Date.now() + (response.expires_in || 0) * 1000,
},
{ sessionId: session.id }
);
return updatedSession;
} catch (e) {
if (e instanceof OAuth2RequestError) {
const { request, message, description } = e;
console.error(request, message, description);
return await lucia.createSession(
session.userId,
{
...session,
refreshToken: undefined,
error: "RefreshAccessTokenError",
},
{ sessionId: session.id }
);
}
console.error(e, "RefreshAccessTokenError");
return null;
}
};
javascript
export const getServerSession = cache(
async (): Promise<
{ user: User; session: Session } | { user: null; session: null }
> => {
... use from basic lucia example
// check if access token will expire soon
if (result.session) {
if (
(!result.session.accessToken ||
Date.now() >
result.session.accessTokenExpiresAt -
60000 * USE_REFRESH_MINUTES_BEFORE_EXPIRATION) &&
result.session.refreshToken
) {
const refreshdSession = await refreshAccessToken(result.session);
if (refreshdSession) {
return ensureOnlyValidAccessToken({
user: result.user,
session: refreshdSession,
});
}
return { user: null, session: null };
}
}
return ensureOnlyValidAccessToken(result);
}
);
const ensureOnlyValidAccessToken = async (
sessionResponse: SessionResponse
): Promise<SessionResponse> => {
if (
sessionResponse.session?.accessToken &&
Date.now() > sessionResponse.session.accessTokenExpiresAt
) {
const updatedSession = await lucia.createSession(
sessionResponse.session.userId,
{
...sessionResponse.session,
accessToken: undefined,
},
{ sessionId: sessionResponse.session.id }
);
return {
user: sessionResponse.user,
session: updatedSession,
};
}
return sessionResponse;
};
javascript
const { user, session } = await getServerSession();
return (
<RootLayoutProvider
user={user}
sessionState={
session
? {
error: session.error,
accessTokenExpiresAt: session.accessTokenExpiresAt,
userId: user.id,
}
: undefined
}
>
{children}
</RootLayoutProvider>
javascript
const [sessionUser, setSessioUser] = useOptimistic<User | null>(user);
const [, startTransition] = useTransition();
<SessionContext.Provider
value={
sessionUser
? {
user: sessionUser,
updateUserSession: (user: DeepPartial<User> | null) => {
if (user === null) {
startTransition(() => {
setSessioUser(null);
});
} else {
startTransition(() => {
setSessioUser(all<User>([sessionUser as any, user]));
});
updateSession();
}
},
}
: { user: null, updateUserSession: () => {} }
}
>
<ValidateSessionProvider sessionState={sessionState}>
{children}
</ValidateSessionProvider>
</SessionContext.Provider>

Dude, just use next-auth, it's much easier

Well, not quite. While NextAuth is definitely a great option for quick setups and smaller projects, it’s not yet a first-class citizen for the App Router. The session context has some limitations, and NextAuth v5 has been in beta for over a year. Plus, when it comes to handling database schema and refreshing tokens, Lucia gives you a lot more flexibility and control.

So, if you need something more customizable, Lucia is a great choice. NextAuth is absolutely fine for smaller projects or when you need to get something up and running quickly, but for more advanced, scalable solutions, Lucia shines.

See the full Code Example at Github

Previous post
Back to overview