Next.js + Express
Next.js frontend with an Express backend that owns the session
Use this pattern when your Next.js app talks to an Express API on the same domain (or a known subdomain) and Express manages the session cookie.
The Express backend handles the entire OIDC flow. There is no login page and no "Sign in" button — a Next.js middleware bounces visitors straight through Campus One. Because Campus One already holds the student's session, signed-in users land authenticated with no UI; visitors who aren't signed in to Campus One fall through to your public view. See Automatic & silent sign-in for the underlying mechanics.
Server (Express)
// server/index.ts
import express from "express";
import session from "express-session";
import cookieParser from "cookie-parser";
import { Issuer, generators } from "openid-client";
const app = express();
app.use(cookieParser());
app.use(
session({
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production" },
})
);
// Discover Campus One's endpoints once at boot. `openid-client` caches JWKS
// internally and refreshes them automatically.
const issuer = await Issuer.discover("https://auth.campusone.com.ng");
const client = new issuer.Client({
client_id: process.env.CAMPUS_ONE_CLIENT_ID!,
client_secret: process.env.CAMPUS_ONE_CLIENT_SECRET!,
redirect_uris: [`${process.env.APP_URL}/auth/callback`],
response_types: ["code"],
});
declare module "express-session" {
interface SessionData {
user?: {
sub: string;
email: string;
name: string;
role: string;
roles?: string[];
studentId?: string;
};
pkceVerifier?: string;
oauthState?: string;
// Whether the in-flight request was a silent (prompt=none) attempt, and
// where to send the user once it resolves.
silent?: boolean;
next?: string;
}
}
// OIDC errors Campus One returns when a silent (prompt=none) request can't
// complete without showing UI — i.e. the visitor has no Campus One session.
const SILENT_ERRORS = new Set([
"login_required",
"interaction_required",
"consent_required",
"account_selection_required",
]);
// Kicks off SSO. `?prompt=none` makes it a *silent* attempt — Campus One
// returns immediately whether or not a session exists, so anonymous visitors
// never see a login screen. Omit it to force interactive sign-in at Campus One.
app.get("/auth/login", (req, res) => {
const verifier = generators.codeVerifier();
const state = generators.state();
const silent = req.query.prompt === "none";
req.session.pkceVerifier = verifier;
req.session.oauthState = state;
req.session.silent = silent;
req.session.next = (req.query.next as string) ?? "/";
res.redirect(
client.authorizationUrl({
scope: "openid profile email academic roles offline_access",
state,
code_challenge: generators.codeChallenge(verifier),
code_challenge_method: "S256",
// Silent check: don't render Campus One's login UI, just tell us whether
// a session exists.
...(silent ? { prompt: "none" } : {}),
})
);
});
app.get("/auth/callback", async (req, res) => {
const params = client.callbackParams(req);
const wasSilent = req.session.silent === true;
const next = req.session.next ?? "/";
req.session.silent = undefined;
// A silent attempt for a visitor with no Campus One session comes back as an
// OIDC error instead of a code. Treat that as "anonymous": set a short-lived
// marker so the middleware doesn't silently retry on every request, and let
// the app render its public view.
if (typeof params.error === "string") {
if (wasSilent && SILENT_ERRORS.has(params.error)) {
res.cookie("c1_anon", "1", {
maxAge: 5 * 60 * 1000,
httpOnly: true,
sameSite: "lax",
});
return res.redirect(`${process.env.APP_URL}${next}`);
}
return res.status(401).send(`Sign-in failed: ${params.error}`);
}
try {
const tokenSet = await client.callback(
`${process.env.APP_URL}/auth/callback`,
params,
{
code_verifier: req.session.pkceVerifier,
state: req.session.oauthState,
}
);
// `tokenSet.claims()` verifies iss/aud/exp/signature against the issuer's
// JWKS for you. Throws if anything is off.
const claims = tokenSet.claims();
req.session.user = {
sub: claims.sub,
email: claims.email as string,
name: claims.name as string,
role: claims.role as string,
roles: claims.roles as string[] | undefined,
studentId: claims.student_id as string | undefined,
};
req.session.pkceVerifier = undefined;
req.session.oauthState = undefined;
res.clearCookie("c1_anon"); // they're signed in now — allow future silent retries
res.redirect(`${process.env.APP_URL}${next}`);
} catch (err) {
console.error("Sign-in failed:", err);
res.status(401).send("Sign-in failed");
}
});
app.get("/auth/me", (req, res) => {
if (!req.session.user) {
return res.status(401).json({ error: "Not signed in" });
}
res.json(req.session.user);
});
app.post("/auth/logout", (req, res) => {
req.session.destroy(() => res.json({ ok: true }));
});
app.listen(4000);Frontend (Next.js)
No login route, no button. A middleware drives the auto sign-in: protected paths are bounced through Campus One interactively (invisible when a Campus One session exists), and everything else gets a one-shot silent attempt so already-signed-in students arrive authenticated while anonymous visitors fall through.
// middleware.ts
import { type NextRequest, NextResponse } from "next/server";
// Paths that strictly require a signed-in user. Everything else is public but
// still gets an opportunistic silent sign-in.
const PROTECTED = ["/dashboard", "/admin"];
const API_URL = process.env.NEXT_PUBLIC_API_URL!;
function authorize(req: NextRequest, silent: boolean) {
const url = new URL(`${API_URL}/auth/login`);
url.searchParams.set("next", req.nextUrl.pathname + req.nextUrl.search);
if (silent) {
url.searchParams.set("prompt", "none");
}
return NextResponse.redirect(url);
}
export function middleware(req: NextRequest) {
// Your Express session cookie (and the API on a shared parent domain) must be
// visible here — see the cookie `domain` notes in the React Router example.
const signedIn = req.cookies.has("connect.sid");
if (signedIn) {
return NextResponse.next();
}
const { pathname } = req.nextUrl;
const isProtected = PROTECTED.some((p) => pathname.startsWith(p));
// Protected route + no session → force interactive sign-in at Campus One.
if (isProtected) {
return authorize(req, false);
}
// Public route → try silent SSO once. The `c1_anon` marker (set by the
// backend after a failed silent attempt) stops this from looping for users
// who aren't signed in to Campus One.
if (!req.cookies.has("c1_anon")) {
return authorize(req, true);
}
return NextResponse.next();
}
export const config = {
// Skip Next internals and static assets.
matcher: ["/((?!_next/|favicon.ico).*)"],
};// app/dashboard/page.tsx (server component)
import { cookies } from "next/headers";
async function getUser() {
const res = await fetch(`${process.env.API_URL}/auth/me`, {
headers: { cookie: cookies().toString() },
cache: "no-store",
});
if (!res.ok) return null;
return res.json() as Promise<{
name: string;
role: string;
studentId?: string;
}>;
}
export default async function Dashboard() {
// The middleware guarantees a signed-in user by the time we render a
// protected page, so there's no "Sign in" branch — just use the session.
const user = await getUser();
return (
<div>
<h1>Hello {user?.name}</h1>
<p>Role: {user?.role}</p>
{user?.role === "admin" && <a href="/admin">Admin panel</a>}
</div>
);
}Role-based middleware
// server/middleware/requireRole.ts
import type { RequestHandler } from "express";
export const requireRole =
(...allowed: string[]): RequestHandler =>
(req, res, next) => {
const role = req.session.user?.role;
if (!role || !allowed.includes(role)) {
return res.status(403).json({ error: "Forbidden" });
}
next();
};
// Usage
app.get("/admin/reports", requireRole("admin", "staff"), (req, res) => {
// ...
});Webhook receiver
// server/webhooks.ts
import { createHmac, timingSafeEqual } from "crypto";
// IMPORTANT: mount the raw body parser BEFORE express.json() on this route,
// otherwise the signature will not match what Campus One signed.
app.post(
"/webhooks/campus-one",
express.raw({ type: "application/json" }),
(req, res) => {
const sig = (req.headers["x-campus-one-signature"] as string) ?? "";
const expected = `sha256=${createHmac("sha256", process.env.CAMPUS_ONE_WEBHOOK_SECRET!)
.update(req.body)
.digest("hex")}`;
const a = Buffer.from(sig);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return res.status(401).send("Invalid signature");
}
const event = JSON.parse(req.body.toString()) as {
event: string;
data: Record<string, unknown>;
};
if (event.event === "user.role_changed") {
// Invalidate this user's session so they re-authenticate and pick up
// the new role on next request.
console.log("Role changed:", event.data);
}
res.send("ok");
}
);