Campus One
SSOExamples

Next.js + Go

Next.js frontend with a Go backend using coreos/go-oidc

Use this when your API is Go. We use coreos/go-oidc for the OIDC client and golang.org/x/oauth2 for the token exchange — both handle PKCE and signature verification correctly.

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

Install

go get github.com/coreos/go-oidc/v3/oidc
go get golang.org/x/oauth2
go get github.com/gorilla/sessions

Server (Go)

// main.go
package main

import (
	"context"
	"crypto/hmac"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"io"
	"log"
	"net/http"
	"os"

	"github.com/coreos/go-oidc/v3/oidc"
	"github.com/gorilla/sessions"
	"golang.org/x/oauth2"
)

var (
	provider     *oidc.Provider
	oauthConfig  oauth2.Config
	verifier     *oidc.IDTokenVerifier
	store        = sessions.NewCookieStore([]byte(os.Getenv("SESSION_SECRET")))
)

func main() {
	ctx := context.Background()
	var err error
	provider, err = oidc.NewProvider(ctx, "https://auth.campusone.com.ng")
	if err != nil {
		log.Fatal(err)
	}

	oauthConfig = oauth2.Config{
		ClientID:     os.Getenv("CAMPUS_ONE_CLIENT_ID"),
		ClientSecret: os.Getenv("CAMPUS_ONE_CLIENT_SECRET"),
		RedirectURL:  os.Getenv("APP_URL") + "/auth/callback",
		Endpoint:     provider.Endpoint(),
		Scopes: []string{
			oidc.ScopeOpenID, "profile", "email",
			"academic", "roles", "offline_access",
		},
	}
	verifier = provider.Verifier(&oidc.Config{ClientID: oauthConfig.ClientID})

	http.HandleFunc("/auth/login", login)
	http.HandleFunc("/auth/callback", callback)
	http.HandleFunc("/auth/me", me)
	http.HandleFunc("/webhooks/campus-one", webhook)
	log.Fatal(http.ListenAndServe(":4000", nil))
}

// --- helpers -----------------------------------------------------------------

func randURL(n int) string {
	b := make([]byte, n)
	rand.Read(b)
	return base64.RawURLEncoding.EncodeToString(b)
}

func pkce() (verifier, challenge string) {
	verifier = randURL(32)
	sum := sha256.Sum256([]byte(verifier))
	challenge = base64.RawURLEncoding.EncodeToString(sum[:])
	return
}

// --- routes ------------------------------------------------------------------

// silentErrors are the OIDC errors Campus One returns when a prompt=none
// request can't complete without UI — i.e. the visitor has no Campus One session.
var silentErrors = map[string]bool{
	"login_required":            true,
	"interaction_required":      true,
	"consent_required":          true,
	"account_selection_required": true,
}

func login(w http.ResponseWriter, r *http.Request) {
	state := randURL(16)
	v, c := pkce()

	// `?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 := r.URL.Query().Get("prompt") == "none"
	next := r.URL.Query().Get("next")
	if next == "" {
		next = "/"
	}

	sess, _ := store.Get(r, "c1")
	sess.Values["state"] = state
	sess.Values["verifier"] = v
	sess.Values["silent"] = silent
	sess.Values["next"] = next
	sess.Save(r, w)

	opts := []oauth2.AuthCodeOption{
		oauth2.SetAuthURLParam("code_challenge", c),
		oauth2.SetAuthURLParam("code_challenge_method", "S256"),
	}
	if silent {
		opts = append(opts, oauth2.SetAuthURLParam("prompt", "none"))
	}
	http.Redirect(w, r, oauthConfig.AuthCodeURL(state, opts...), http.StatusFound)
}

type claims struct {
	Sub       string   `json:"sub"`
	Email     string   `json:"email"`
	Name      string   `json:"name"`
	Role      string   `json:"role"`
	Roles     []string `json:"roles,omitempty"`
	StudentID string   `json:"student_id,omitempty"`
}

func callback(w http.ResponseWriter, r *http.Request) {
	sess, _ := store.Get(r, "c1")
	expectedState, _ := sess.Values["state"].(string)
	v, _ := sess.Values["verifier"].(string)
	wasSilent, _ := sess.Values["silent"].(bool)
	next, _ := sess.Values["next"].(string)
	if next == "" {
		next = "/"
	}
	appURL := os.Getenv("APP_URL")

	// 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 send them to the public view.
	if oidcErr := r.URL.Query().Get("error"); oidcErr != "" {
		if wasSilent && silentErrors[oidcErr] {
			http.SetCookie(w, &http.Cookie{
				Name: "c1_anon", Value: "1", Path: "/",
				MaxAge: 300, HttpOnly: true, SameSite: http.SameSiteLaxMode,
			})
			http.Redirect(w, r, appURL+next, http.StatusFound)
			return
		}
		http.Error(w, "Sign-in failed: "+oidcErr, http.StatusUnauthorized)
		return
	}

	if r.URL.Query().Get("state") != expectedState {
		http.Error(w, "Invalid state", http.StatusBadRequest)
		return
	}

	tok, err := oauthConfig.Exchange(r.Context(), r.URL.Query().Get("code"),
		oauth2.SetAuthURLParam("code_verifier", v),
	)
	if err != nil {
		http.Error(w, "Token exchange failed: "+err.Error(), http.StatusUnauthorized)
		return
	}

	rawID, ok := tok.Extra("id_token").(string)
	if !ok {
		http.Error(w, "No id_token", http.StatusInternalServerError)
		return
	}

	// Verifies iss, aud, exp, and signature against the JWKS.
	idTok, err := verifier.Verify(r.Context(), rawID)
	if err != nil {
		http.Error(w, "Invalid id_token: "+err.Error(), http.StatusUnauthorized)
		return
	}
	var c claims
	if err := idTok.Claims(&c); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	sess.Values["user"], _ = json.Marshal(c)
	delete(sess.Values, "state")
	delete(sess.Values, "verifier")
	delete(sess.Values, "silent")
	delete(sess.Values, "next")
	sess.Save(r, w)

	// Signed in now — clear the anonymous marker so future silent retries work.
	http.SetCookie(w, &http.Cookie{Name: "c1_anon", Value: "", Path: "/", MaxAge: -1})
	http.Redirect(w, r, appURL+next, http.StatusFound)
}

func me(w http.ResponseWriter, r *http.Request) {
	sess, _ := store.Get(r, "c1")
	raw, ok := sess.Values["user"].([]byte)
	if !ok {
		http.Error(w, "Not signed in", http.StatusUnauthorized)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	w.Write(raw)
}

// --- webhooks ----------------------------------------------------------------

func webhook(w http.ResponseWriter, r *http.Request) {
	body, _ := io.ReadAll(r.Body)
	mac := hmac.New(sha256.New, []byte(os.Getenv("CAMPUS_ONE_WEBHOOK_SECRET")))
	mac.Write(body)
	expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))

	if !hmac.Equal([]byte(expected), []byte(r.Header.Get("X-Campus-One-Signature"))) {
		http.Error(w, "Invalid signature", http.StatusUnauthorized)
		return
	}

	var event struct {
		Event string                 `json:"event"`
		Data  map[string]interface{} `json:"data"`
	}
	json.Unmarshal(body, &event)

	if event.Event == "user.role_changed" {
		log.Printf("Role changed: %v", event.Data)
	}
	w.Write([]byte("ok"))
}

Frontend (Next.js)

Same as the other backend examples — Next.js never touches the OIDC flow directly. Use the Express middleware.ts to drive the silent/auto sign-in, pointing NEXT_PUBLIC_API_URL at your Go server. The gorilla/sessions cookie is named c1, so check req.cookies.has("c1") in the middleware instead of connect.sid. There is no login page or button — protected pages render straight from the session the middleware established.

Role middleware in Go

func requireRole(allowed ...string) func(http.HandlerFunc) http.HandlerFunc {
	return func(next http.HandlerFunc) http.HandlerFunc {
		return func(w http.ResponseWriter, r *http.Request) {
			sess, _ := store.Get(r, "c1")
			raw, ok := sess.Values["user"].([]byte)
			if !ok {
				http.Error(w, "Forbidden", http.StatusForbidden)
				return
			}
			var c claims
			json.Unmarshal(raw, &c)
			for _, a := range allowed {
				if a == c.Role {
					next(w, r)
					return
				}
			}
			http.Error(w, "Forbidden", http.StatusForbidden)
		}
	}
}

// Usage
http.HandleFunc("/admin/reports", requireRole("admin", "staff")(reports))

Gotchas

  • idTok.Claims(&c) deserialises into your struct. If you also need claims that aren't in your struct, capture them with map[string]any instead.
  • gorilla/sessions cookie store has a ~4kb limit. For larger sessions use the filesystem/redis store.
  • io.ReadAll(r.Body) must run before anything else touches the body, or the HMAC won't match.

On this page