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
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
- Using
fetch()for/api/auth/login. This is a full-page navigation. The browser must follow a 302 to Campus One.fetchwill either silently swallow the redirect (modemanual) or fail it (modecors). Always usewindow.location.assign()(as thesignInhelper does). - Looping on the silent attempt. The silent (
prompt=none) bootstrap must be gated by the readablec1_anonmarker — without it, a visitor who isn't signed in to Campus One bounces forever. That's why the backend setsc1_anonwithouthttpOnlyfor this SPA pattern, so client JS can read it. credentials: "include"missing onfetch. Without it, cookies are not sent and/api/mealways returns 401 even though you're signed in. Set it on every authenticated request.- CORS without
credentials: true. Same root cause. Setcredentials: trueon both the Expresscors()middleware and every browser-sidefetch. - Different cookie domain. If your SPA is at
app.example.comand API atapi.example.com, setcookie.domain = ".example.com". If they share an exact origin, leavedomainunset. - 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/loginand have the server redirect back toWEB_URL + nextafter the callback (see the snippets above). - 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. X-CSRF-Tokenconfusion. The OIDC sign-in flow is protected against CSRF by thestateparameter (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 isSameSite=None. ForSameSite=Lax(default in the example), the browser will not send the cookie on cross-sitePOSTs anyway.
Webhook receiver
Same as the Express example. The receiver must use the raw body, not parsed JSON, when computing the HMAC.