Campus One
SSOExamples

React Router + Express

SPA with react-router-dom signing in via an Express backend

This pattern trips up the most teams, so the example is more detailed.

The rule that solves 90% of the confusion: the browser never sees the client_secret and never calls Campus One's /token endpoint directly. Your SPA opens https://yourapp/api/auth/login (a route on your server), and the server does the OIDC dance. The session lives in an HTTP-only cookie on your origin.

There is no login page and no "Sign in" button. On load the SPA makes a one-shot silent (prompt=none) attempt: students already signed in to Campus One land authenticated, and anonymous visitors fall through to the public view. Protected routes that strictly require a user auto-redirect into an interactive Campus One sign-in. See Automatic & silent sign-in.

Architecture

Loading diagram...

The SPA only ever calls your server. Campus One only ever talks to your server's /api/auth/callback.

Server (Express)

Reuse the Next.js + Express example wholesale — the Express side is identical. Key bits:

// server/index.ts
import express from "express";
import session from "express-session";
import cors from "cors";
import { Issuer, generators } from "openid-client";

const app = express();

// CORS so the SPA can read cookies on /api/me. `credentials: true` is required
// for the browser to send the session cookie cross-origin.
app.use(cors({ origin: process.env.WEB_URL, credentials: true }));

app.use(
  session({
    secret: process.env.SESSION_SECRET!,
    resave: false,
    saveUninitialized: false,
    cookie: {
      httpOnly: true,
      sameSite: "lax",
      secure: process.env.NODE_ENV === "production",
      // Allow the cookie across subdomains if your SPA and API are on
      // different subdomains of the same root domain.
      domain: process.env.NODE_ENV === "production" ? ".campusone.com.ng" : undefined,
    },
  })
);

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.API_URL}/api/auth/callback`],
  response_types: ["code"],
});

const SILENT_ERRORS = new Set([
  "login_required",
  "interaction_required",
  "consent_required",
  "account_selection_required",
]);

app.get("/api/auth/login", (req, res) => {
  const verifier = generators.codeVerifier();
  const state = generators.state();
  // Capture where the user was so we can send them back after sign-in.
  const next = (req.query.next as string) ?? "/";
  const silent = req.query.prompt === "none";

  req.session.pkceVerifier = verifier;
  req.session.oauthState = state;
  req.session.next = next;
  req.session.silent = silent;

  res.redirect(
    client.authorizationUrl({
      scope: "openid profile email academic roles offline_access",
      state,
      code_challenge: generators.codeChallenge(verifier),
      code_challenge_method: "S256",
      ...(silent ? { prompt: "none" } : {}),
    })
  );
});

app.get("/api/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;

  // Silent attempt + no Campus One session → OIDC error, not a code. Mark the
  // browser anonymous (note: NOT httpOnly, so the SPA can read it to avoid a
  // silent-retry loop) and return to the app's public view.
  if (typeof params.error === "string") {
    if (wasSilent && SILENT_ERRORS.has(params.error)) {
      res.cookie("c1_anon", "1", {
        maxAge: 5 * 60 * 1000,
        sameSite: "lax",
        secure: process.env.NODE_ENV === "production",
        domain: process.env.NODE_ENV === "production" ? ".campusone.com.ng" : undefined,
      });
      return res.redirect(`${process.env.WEB_URL}${next}`);
    }
    return res.status(401).send(`Sign-in failed: ${params.error}`);
  }

  try {
    const tokenSet = await client.callback(
      `${process.env.API_URL}/api/auth/callback`,
      params,
      { code_verifier: req.session.pkceVerifier, state: req.session.oauthState }
    );
    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;
    req.session.next = undefined;
    res.clearCookie("c1_anon"); // signed in now — allow future silent retries
    res.redirect(`${process.env.WEB_URL}${next}`);
  } catch (err) {
    res.status(401).send(`Sign-in failed: ${(err as Error).message}`);
  }
});

app.get("/api/me", (req, res) => {
  if (!req.session.user) return res.status(401).json({ error: "Not signed in" });
  res.json(req.session.user);
});

app.post("/api/auth/logout", (req, res) =>
  req.session.destroy(() => res.json({ ok: true }))
);

Frontend (React Router)

Auth context

// src/auth/AuthContext.tsx
import { createContext, useContext, useEffect, useState } from "react";

interface User {
  sub: string;
  email: string;
  name: string;
  role: string;
  roles?: string[];
  studentId?: string;
}

const Ctx = createContext<{
  user: User | null;
  loading: boolean;
  signIn: (next?: string, silent?: boolean) => void;
  signOut: () => Promise<void>;
}>({ user: null, loading: true, signIn: () => {}, signOut: async () => {} });

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch(`${import.meta.env.VITE_API_URL}/api/me`, { credentials: "include" })
      .then((r) => (r.ok ? r.json() : null))
      .then((u: User | null) => {
        if (u) {
          setUser(u);
          setLoading(false);
          return;
        }
        // No local session. Make a one-shot *silent* SSO attempt so students
        // already signed in to Campus One come back authenticated — unless the
        // `c1_anon` marker tells us this visitor isn't signed in to Campus One
        // (which would otherwise loop).
        if (!document.cookie.includes("c1_anon=1")) {
          signIn(window.location.pathname + window.location.search, true);
          return;
        }
        setUser(null);
        setLoading(false);
      });
  }, []);

  // `silent` issues a prompt=none check (no visible login screen). Omit it to
  // force an interactive sign-in at Campus One.
  const signIn = (next = window.location.pathname, silent = false) => {
    // Full-page navigation, NOT fetch. The browser must follow the 302 to
    // Campus One — fetch() would silently fail the redirect to a different
    // origin.
    const url = new URL(`${import.meta.env.VITE_API_URL}/api/auth/login`);
    url.searchParams.set("next", next);
    if (silent) {
      url.searchParams.set("prompt", "none");
    }
    window.location.assign(url);
  };

  const signOut = async () => {
    await fetch(`${import.meta.env.VITE_API_URL}/api/auth/logout`, {
      method: "POST",
      credentials: "include",
    });
    setUser(null);
  };

  return <Ctx.Provider value={{ user, loading, signIn, signOut }}>{children}</Ctx.Provider>;
}

export const useAuth = () => useContext(Ctx);

Routes

// src/App.tsx
import { useEffect } from "react";
import { BrowserRouter, Routes, Route, Navigate, useLocation } from "react-router-dom";
import { AuthProvider, useAuth } from "./auth/AuthContext";

// No /login route and no button. If a protected route has no user once the
// silent bootstrap has settled, kick off an *interactive* Campus One sign-in
// automatically.
function RequireAuth({ children, roles }: { children: React.ReactNode; roles?: string[] }) {
  const { user, loading, signIn } = useAuth();
  const location = useLocation();

  useEffect(() => {
    if (!loading && !user) {
      signIn(location.pathname + location.search);
    }
  }, [loading, user, location, signIn]);

  if (loading || !user) return <div>Signing you in…</div>;
  if (roles && !roles.includes(user.role)) return <Navigate to="/forbidden" replace />;
  return <>{children}</>;
}

export default function App() {
  return (
    <BrowserRouter>
      <AuthProvider>
        <Routes>
          <Route path="/dashboard" element={<RequireAuth><Dashboard /></RequireAuth>} />
          <Route path="/admin" element={<RequireAuth roles={["admin", "staff"]}><Admin /></RequireAuth>} />
        </Routes>
      </AuthProvider>
    </BrowserRouter>
  );
}

Things developers get wrong

  1. Using fetch() for /api/auth/login. This is a full-page navigation. The browser must follow a 302 to Campus One. fetch will either silently swallow the redirect (mode manual) or fail it (mode cors). Always use window.location.assign() (as the signIn helper does).
  2. Looping on the silent attempt. The silent (prompt=none) bootstrap must be gated by the readable c1_anon marker — without it, a visitor who isn't signed in to Campus One bounces forever. That's why the backend sets c1_anon without httpOnly for this SPA pattern, so client JS can read it.
  3. credentials: "include" missing on fetch. Without it, cookies are not sent and /api/me always returns 401 even though you're signed in. Set it on every authenticated request.
  4. CORS without credentials: true. Same root cause. Set credentials: true on both the Express cors() middleware and every browser-side fetch.
  5. Different cookie domain. If your SPA is at app.example.com and API at api.example.com, set cookie.domain = ".example.com". If they share an exact origin, leave domain unset.
  6. Returning to the wrong page after sign-in. The Express server doesn't know which React Router route you were on. Pass it as ?next=… on /api/auth/login and have the server redirect back to WEB_URL + next after the callback (see the snippets above).
  7. Reading the user's role from localStorage. Don't — anyone can edit localStorage. Read it from the id_token claims on the server side, surface it via /api/me, and trust only that.
  8. X-CSRF-Token confusion. The OIDC sign-in flow is protected against CSRF by the state parameter (which is what the session stores). You don't need additional CSRF tokens on /api/auth/login, but you do need them on state-changing endpoints (/api/auth/logout, etc.) if your session cookie is SameSite=None. For SameSite=Lax (default in the example), the browser will not send the cookie on cross-site POSTs anyway.

Webhook receiver

Same as the Express example. The receiver must use the raw body, not parsed JSON, when computing the HMAC.

On this page