- Published on
Next.js OpenID Connect Middleware for Sitecore (with iron-session + crypto-js)
- Authors
- Name
- Jorge Lusar
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 encryptedstate
GET /callback
→ exchange thecode
, extract claims, persist iron-sessionGET /status
→ return current session JSON (handy for debugging)GET /logout
→ redirect to IdP logoutGET /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.
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
}
/login
— redirect user to the IdP
2) - 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 anacr_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;
}
/callback
— exchange the code, create the session
3) - Validate
code
andstate
and decrypt the state to recover the originalreturnUrl
. - 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 fromsessionOptions
are applied consistently.
/status
, /logout
, /logout-callback
4) - 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;
}
5) Helpers: state cookie, token exchange
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
Security notes & tweaks (recommended)
- 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), andsameSite=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.