Logo
Published on

Next.js OpenID Connect Middleware for Sitecore (with iron-session + crypto-js)

Authors
  • avatar
    Name
    Jorge Lusar
    Twitter

This post shows how I wired OpenID Connect into a Sitecore Headless app using Next.js middleware, iron-session, and crypto-js to enable login/logout, keep session state, and safely bounce back to the originating page.

We’ll cover the 5 endpoints implemented in middleware:

  • GET /login (or /register) → redirect to the IdP with an encrypted state
  • GET /callback → exchange the code, extract claims, persist iron-session
  • GET /status → return current session JSON (handy for debugging)
  • GET /logout → redirect to IdP logout
  • GET /logout-callback → clear cookies and return

This builds on the session model from the previous post: securing a Sitecore Headless app with Next.js and iron-session


1) The Middleware Plugin

Key idea: keep auth logic close to the edge. In a plugin-style middleware we route on pathname and act accordingly.

lib/middleware/plugins/openidconnect.ts
import { NextRequest, NextResponse } from 'next/server';
import CryptoJS from 'crypto-js';
import { jwtDecode, JwtPayload } from 'jwt-decode';
import { getIronSession } from 'iron-session';
import { defaultSession, sessionOptions, SessionData } from 'src/models/session';

export interface OpenIDConnectJwtPayload extends JwtPayload {
  nameid: string;
  role: string[];
  email: string;
  givenname: string;
  firstname: string;
  surname: string;
  family_name: string;
  occupation?: string;
  country: string;
}

class OpenIDConnectPlugin {
  order = 2;

  async exec(req: NextRequest, res?: NextResponse): Promise<NextResponse> {
    if (req.nextUrl.pathname === '/login' || req.nextUrl.pathname === '/register') return this.login(req);
    if (req.nextUrl.pathname === '/callback') return this.loginCallback(req);
    if (req.nextUrl.pathname === '/status') return this.status(req);
    if (req.nextUrl.pathname === '/logout') return this.logout(req);
    if (req.nextUrl.pathname === '/logout-callback') return this.logoutCallback(req);
    return res || NextResponse.next();
  }
  // ...helpers shown later
}

2) /login — redirect user to the IdP

  • We remember where the user came from via an encrypted state (with expiry) stored as an httpOnly cookie.
  • If the request hits /register, we add an acr_values=register hint.
async login(req: NextRequest): Promise<NextResponse> {
  const referer = req.headers.get('referer');
  let returnUrl = '/';
  if (referer) {
    try {
      const r = new URL(referer);
      if (r.host === this.getHost(req)) returnUrl = r.pathname;
    } catch {}
  }

  const [state, expireIn] = this.getCookieValue(returnUrl);
  const redirectUri = `https://${this.getHost(req)}/callback`;
  const url = new URL(`https://${this.getAuthority()}/authorize`);
  url.searchParams.append('client_id', this.getClientId());
  url.searchParams.append('response_type', 'code');
  url.searchParams.append('scope', 'openid');
  url.searchParams.append('redirect_uri', redirectUri);
  url.searchParams.append('state', state);
  if (req.nextUrl.pathname === '/register') url.searchParams.append('acr_values', 'register');

  const res = NextResponse.redirect(url.href);
  this.setStateCookie(res, state, expireIn);
  return res;
}

3) /callback — exchange the code, create the session

  • Validate code and state and decrypt the state to recover the original returnUrl.
  • Exchange the auth code for tokens; decode the JWT for user claims.
  • Populate the iron-session and (recommended) call session.save().
async loginCallback(req: NextRequest): Promise<NextResponse> {
  const code = req.nextUrl.searchParams.get('code');
  const state = req.nextUrl.searchParams.get('state');
  if (!code || !state) return NextResponse.json({ error: 'code/state required' }, { status: 400 });

  const stateCookie = req.cookies.get('grt-state');
  if (!stateCookie || stateCookie.value !== state)
    return NextResponse.json({ error: 'invalid state cookie' }, { status: 400 });

  const [returnUrl, expiresAt] = this.getReturnUrlAndDateTime(state);
  if (expiresAt <= new Date())
    return NextResponse.json({ error: 'state expired' }, { status: 400 });

  const redirectUri = `https://${this.getHost(req)}/callback`;
  const claims = await this.getClaimsPrincipal(code, redirectUri);
  if (!claims) return NextResponse.json({ error: 'unable to retrieve claims' }, { status: 400 });

  const res = NextResponse.redirect(`https://${this.getHost(req)}${returnUrl}`);
  const session = await getIronSession<SessionData>(req, res, sessionOptions);

  session.nameid = claims.nameid;
  session.email = claims.email;
  session.firstname = claims.firstname || claims.givenname;
  session.lastname = claims.family_name || claims.surname;
  session.country = claims.country;
  session.isLoggedIn = true;

  // Persist the iron-session cookie (recommended):
  await session.save();

  // Clean up
  res.cookies.delete('grt-state');
  return res;
}

The original implementation sealed and set its own cookie. You can rely on iron-session instead (await session.save()), which ensures your httpOnly/secure/sameSite settings from sessionOptions are applied consistently.


4) /status, /logout, /logout-callback

  • Status returns either the session or a default shape.
  • Logout bounces the user to IdP logout with a fresh state.
  • Logout-callback validates state and clears cookies.
async status(req: NextRequest): Promise<NextResponse> {
  const session = await getIronSession<SessionData>(req, NextResponse.next(), sessionOptions);
  return NextResponse.json(session.isLoggedIn ? session : defaultSession);
}

async logout(req: NextRequest): Promise<NextResponse> {
  const [state, expireIn] = this.getCookieValue('/');
  const redirectUri = `https://${this.getHost(req)}/logout-callback`;
  const url = new URL(`https://${this.getAuthority()}/account/logout`);
  url.searchParams.append('client_id', this.getClientId());
  url.searchParams.append('post_logout_redirect_uri', redirectUri);
  url.searchParams.append('state', state);

  const res = NextResponse.redirect(url.href);
  this.setStateCookie(res, state, expireIn);
  return res;
}

async logoutCallback(req: NextRequest): Promise<NextResponse> {
  const state = req.nextUrl.searchParams.get('state');
  const stateCookie = req.cookies.get('grt-state');
  if (!state || !stateCookie || stateCookie.value !== state)
    return NextResponse.json({ error: 'invalid state' }, { status: 400 });

  const [returnUrl, expiresAt] = this.getReturnUrlAndDateTime(state);
  if (expiresAt <= new Date())
    return NextResponse.json({ error: 'state expired' }, { status: 400 });

  const res = NextResponse.redirect(`https://${this.getHost(req)}${returnUrl}`);
  res.cookies.delete('grt-state');
  // Also clear the iron-session cookie (honor your cookieName in sessionOptions)
  res.cookies.delete(process.env.SESSION_COOKIE!);
  return res;
}

setStateCookie(res: NextResponse, state: string, expireIn: Date) {
  res.cookies.set({
    name: 'grt-state',
    value: state,
    expires: expireIn,
    path: '/',
    secure: process.env.NODE_ENV === 'production',
    httpOnly: true,
    sameSite: 'lax',
  });
}

getCookieValue(returnUrl: string): [string, Date] {
  const expireIn = new Date(Date.now() + 60 * 60 * 1000); // 60 minutes
  const values = `returnUrl:${returnUrl},expired:${expireIn.toUTCString()}`;
  const state = CryptoJS.AES.encrypt(values, process.env.OIDC_STATE_SECRET || 'change-me').toString();
  return [state, expireIn];
}

getReturnUrlAndDateTime(state: string): [string, Date] {
  try {
    const bytes = CryptoJS.AES.decrypt(state, process.env.OIDC_STATE_SECRET || 'change-me');
    const decoded = bytes.toString(CryptoJS.enc.Utf8);
    const [r, e] = decoded.split(',');
    return [r.split(':')[1], new Date(e.split(':')[1])];
  } catch {
    return ['/', new Date(0)];
  }
}

async getClaimsPrincipal(code: string, redirectUri: string) {
  const url = `https://${this.getAuthority()}/oauth/token`;
  const body =
    'grant_type=authorization_code&client_id=' + encodeURIComponent(this.getClientId()) +
    '&client_secret=' + encodeURIComponent(this.getClientSecret()) +
    '&code=' + encodeURIComponent(code) +
    '&redirect_uri=' + encodeURIComponent(redirectUri);

  const resp = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body });
  const data = await resp.json();
  return jwtDecode<OpenIDConnectJwtPayload>(data.access_token);
}

Environment

# IdP
AUTHORITY=your-idp.example.com
CLIENTID=...
CLIENTSECRET=...

# Session (align with your sessionOptions)
SESSION_SECRET=super-long-random-string
SESSION_COOKIE=myapp_session

# State encryption (use a strong random string; do NOT hardcode)
OIDC_STATE_SECRET=super-long-random-string

  • Use await session.save() to let iron-session set the cookie (httpOnly/secure). Avoid manually sealing unless you have a specific need.
  • Align env var names with your sessionOptions (SESSION_SECRET, SESSION_COOKIE) for consistency.
  • Use an env secret for the state encryption (e.g., OIDC_STATE_SECRET), not a hardcoded value.
  • Validate tokens: for production, verify the ID/Access Token (issuer, audience, expiry, signature via JWKS) rather than just decoding.
  • Keep cookies httpOnly, secure (in prod), and sameSite=lax (or stricter if possible).

That’s the entire login/logout loop: redirect → callback → iron-session. With this in place, your paywall and article gating can rely on a robust session.isLoggedIn, email, firstname, and country set during the callback stage.