Campus One
SSOExamples

Next.js + Flask

Next.js frontend with a Python/Flask backend handling OIDC

Use this when your API is Flask and your frontend is Next.js. Flask owns the session cookie; Next.js just calls Flask endpoints.

We use authlib because it handles OIDC discovery, PKCE, and id_token verification (including JWKS rotation) without extra plumbing.

There is no login page or "Sign in" button. A Next.js middleware drives the auto sign-in: it silently bootstraps the session from Campus One when the student is already signed in there, and only forces an interactive redirect for strictly protected routes. See Automatic & silent sign-in.

Install

pip install Flask authlib Flask-Session "requests<3"

Server (Flask)

# app.py
import os
import secrets
from flask import Flask, redirect, request, session, jsonify
from flask_session import Session
from authlib.integrations.flask_client import OAuth
from authlib.jose import jwt
import hmac, hashlib

app = Flask(__name__)
app.config["SECRET_KEY"] = os.environ["SESSION_SECRET"]
app.config["SESSION_TYPE"] = "filesystem"
app.config["SESSION_COOKIE_HTTPONLY"] = True
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
Session(app)

oauth = OAuth(app)
oauth.register(
    name="campus_one",
    client_id=os.environ["CAMPUS_ONE_CLIENT_ID"],
    client_secret=os.environ["CAMPUS_ONE_CLIENT_SECRET"],
    server_metadata_url="https://auth.campusone.com.ng/api/auth/.well-known/openid-configuration",
    client_kwargs={
        "scope": "openid profile email academic roles offline_access",
        "code_challenge_method": "S256",  # PKCE required by Campus One
    },
)

# OIDC errors Campus One returns when a silent (prompt=none) request can't
# complete without UI — i.e. the visitor has no Campus One session.
SILENT_ERRORS = {
    "login_required",
    "interaction_required",
    "consent_required",
    "account_selection_required",
}

@app.route("/auth/login")
def login():
    redirect_uri = f"{os.environ['APP_URL']}/auth/callback"
    # `?prompt=none` makes this a *silent* attempt: Campus One answers
    # immediately whether or not a session exists, so anonymous visitors never
    # see a login screen. Omit it to force interactive sign-in.
    silent = request.args.get("prompt") == "none"
    session["silent"] = silent
    session["next"] = request.args.get("next", "/")
    # authlib generates the PKCE verifier + nonce and stores them in the session.
    kwargs = {"prompt": "none"} if silent else {}
    return oauth.campus_one.authorize_redirect(redirect_uri, **kwargs)

@app.route("/auth/callback")
def callback():
    was_silent = session.pop("silent", False)
    next_path = session.pop("next", "/")

    # A silent attempt for a visitor with no Campus One session returns an OIDC
    # error instead of a code. Treat it as "anonymous": set a short-lived marker
    # so the middleware stops retrying, and render the public view.
    error = request.args.get("error")
    if error:
        if was_silent and error in SILENT_ERRORS:
            resp = redirect(f"{os.environ['APP_URL']}{next_path}")
            resp.set_cookie("c1_anon", "1", max_age=300, httponly=True, samesite="Lax")
            return resp
        return f"Sign-in failed: {error}", 401

    token = oauth.campus_one.authorize_access_token()
    # `authorize_access_token` already verified iss/aud/exp/signature.
    user_info = token.get("userinfo") or oauth.campus_one.userinfo(token=token)

    session["user"] = {
        "sub": user_info["sub"],
        "email": user_info["email"],
        "name": user_info.get("name"),
        "role": user_info.get("role"),
        "roles": user_info.get("roles"),
        "student_id": user_info.get("student_id"),
    }
    resp = redirect(f"{os.environ['APP_URL']}{next_path}")
    resp.delete_cookie("c1_anon")  # signed in now — allow future silent retries
    return resp

@app.route("/auth/me")
def me():
    user = session.get("user")
    if not user:
        return jsonify({"error": "Not signed in"}), 401
    return jsonify(user)

@app.route("/auth/logout", methods=["POST"])
def logout():
    session.clear()
    return jsonify({"ok": True})

# --- Role gate ---------------------------------------------------------------

def require_role(*allowed):
    def wrap(fn):
        from functools import wraps
        @wraps(fn)
        def inner(*a, **kw):
            user = session.get("user")
            if not user or user.get("role") not in allowed:
                return jsonify({"error": "Forbidden"}), 403
            return fn(*a, **kw)
        return inner
    return wrap

@app.route("/admin/reports")
@require_role("admin", "staff")
def reports():
    return jsonify({"message": "secret stuff"})

# --- Webhooks ----------------------------------------------------------------

@app.route("/webhooks/campus-one", methods=["POST"])
def campus_one_webhook():
    secret = os.environ["CAMPUS_ONE_WEBHOOK_SECRET"]
    raw = request.get_data()  # raw bytes — DO NOT use request.json here

    received = request.headers.get("X-Campus-One-Signature", "")
    expected = "sha256=" + hmac.new(secret.encode(), raw, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(received, expected):
        return "Invalid signature", 401

    event = request.get_json()
    if event["event"] == "user.role_changed":
        # Mark sessions stale, or update your local user table here.
        app.logger.info("Role changed: %s", event["data"])
    return "ok"

if __name__ == "__main__":
    app.run(port=4000)

Frontend (Next.js)

Identical to the Express example — the Next.js side is decoupled from the backend language. Use the same middleware.ts to drive the silent/auto sign-in (point NEXT_PUBLIC_API_URL at your Flask server; the Flask session cookie is named session, so check req.cookies.has("session") in the middleware instead of connect.sid).

Protected pages then just read the session — the middleware guarantees a signed-in user before they render:

// app/dashboard/page.tsx
import { cookies } from "next/headers";

export default async function Dashboard() {
  const res = await fetch(`${process.env.API_URL}/auth/me`, {
    headers: { cookie: cookies().toString() },
    cache: "no-store",
  });
  const user = await res.json();
  return <h1>Hello {user.name} ({user.role})</h1>;
}

Gotchas specific to Flask

  • CSRF: authorize_redirect writes the PKCE verifier + nonce to the session. If your Next.js app is on a different origin, make sure cookies are still sent on the /auth/callback redirect (same-site, top-level navigation is fine; an iframe is not).
  • Workers: if you run Flask behind Gunicorn with multiple workers and a default filesystem session store, sticky sessions or a shared store (Redis, database) are required so the worker that handles /auth/callback can find the verifier written by /auth/login.
  • Raw body for webhooks: request.get_data(), not request.get_json(), so the signature matches Campus One's HMAC.

On this page