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/sessionsServer (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 withmap[string]anyinstead.gorilla/sessionscookie 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.