Campus One
SSOExamples

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");
  }
);

On this page