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_redirectwrites 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/callbackredirect (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/callbackcan find the verifier written by/auth/login. - Raw body for webhooks:
request.get_data(), notrequest.get_json(), so the signature matches Campus One's HMAC.