Next.js + Supabase
Federate Campus One into Supabase Auth as a custom OIDC provider
Use this pattern when Supabase Auth already owns your user table and you want students to sign in with Campus One while Supabase issues your app's session.
Both options below use the auto sign-in flow — no login page, no "Sign in" button. The app silently checks for an existing Campus One session (prompt=none) and signs the student in transparently when one exists, falling through to the public view otherwise. See Automatic & silent sign-in.
There are two ways to integrate. Pick the one that matches your Supabase plan:
- Supabase as a relying party (recommended). Configure Campus One as a custom OIDC provider in your Supabase project. Supabase handles the OIDC dance and creates a Supabase user record on first sign-in.
- Custom callback bridge. If your Supabase tier doesn't allow arbitrary OIDC providers, sign in with Campus One on a Next.js route, then call
supabase.auth.signInWithIdTokento upgrade the Campus One id_token into a Supabase session.
Option 1: Supabase as a relying party
1. Register the provider in Supabase
In the Supabase Dashboard go to Authentication → Providers, scroll down to the Custom Auth Providers section, and click New provider. Fill in the Create Custom Auth Provider form:
| Field | Value |
|---|---|
| Provider Identifier | campus-one — referenced in the SDK as custom:campus-one |
| Display Name | Campus One |
| Configuration Method | Auto-discovery (recommended) |
| Issuer URL | https://auth.campusone.com.ng |
| Discovery URL | https://auth.campusone.com.ng/api/auth/.well-known/openid-configuration |
| Client ID | your CAMPUS_ONE_CLIENT_ID |
| Client Secret | your CAMPUS_ONE_CLIENT_SECRET |
| Scopes | openid, email, profile, academic, roles, offline_access (comma-separated) |
| Allow users without email | Off |
Click Create and enable provider.
[!IMPORTANT] Set the Discovery URL explicitly — don't leave it blank. Auto-discovery defaults to
{issuer}/.well-known/openid-configuration, but Campus One serves its OpenID configuration under the/api/auth/base path. Blank discovery would hithttps://auth.campusone.com.ng/.well-known/openid-configuration(which 404s) and the save would fail. Paste the full…/api/auth/.well-known/openid-configurationpath so discovery resolves when you save.
After the provider is created, copy the callback URL Supabase shows (usually https://<project>.supabase.co/auth/v1/callback) and add it to your Campus One app's Redirect URLs in the developer dashboard.
2. Auto sign-in from Next.js
No button. A small client component runs on mount: if there's no Supabase session, it makes a one-shot silent OAuth attempt (prompt=none). Students already signed in to Campus One get a session transparently; anonymous visitors fall through, guarded by a localStorage flag so the silent attempt doesn't loop.
// components/campus-one-auto-signin.tsx
"use client";
import { useEffect } from "react";
import { createBrowserClient } from "@supabase/ssr";
const TRIED_KEY = "c1_silent_tried";
export function CampusOneAutoSignIn() {
useEffect(() => {
const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
supabase.auth.getSession().then(({ data: { session } }) => {
if (session) return; // already signed in
if (localStorage.getItem(TRIED_KEY)) return; // not signed in to Campus One
localStorage.setItem(TRIED_KEY, "1");
supabase.auth.signInWithOAuth({
// Custom providers are referenced with the `custom:` prefix — this must
// match the Provider Identifier you set above.
provider: "custom:campus-one",
options: {
redirectTo: `${window.location.origin}/auth/callback`,
scopes: "openid profile email academic roles",
// Silent: Campus One answers immediately instead of rendering a login
// screen. If there's no session it redirects back with an error, which
// Supabase surfaces on the callback — leave the visitor anonymous.
queryParams: { prompt: "none" },
},
});
});
}, []);
return null;
}Render <CampusOneAutoSignIn /> once in your root layout.tsx. Clear the c1_silent_tried flag on a successful sign-in (an onAuthStateChange("SIGNED_IN", …) listener is the easiest place) so a later visit can silently restore the session.
3. Read Campus One claims from the Supabase session
Supabase forwards custom claims into user.user_metadata.custom_claims (the exact path depends on your Supabase version — check user_metadata first):
// app/dashboard/page.tsx
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export default async function Dashboard() {
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{ cookies: { get: (name) => cookies().get(name)?.value } }
);
const { data: { user } } = await supabase.auth.getUser();
const role = user?.user_metadata?.role ?? "student";
const studentId = user?.user_metadata?.student_id;
return (
<div>
<h1>Hello {user?.user_metadata?.name}</h1>
<p>Role: {role}</p>
<p>Student ID: {studentId}</p>
</div>
);
}Option 2: Custom bridge route
For tiers that don't expose arbitrary OIDC providers, run the OIDC flow yourself and then hand the id_token to Supabase. Drive it the same way as the other backends — a middleware that hits /api/auth/login?prompt=none for a silent attempt and drops the prompt to force interactive sign-in on protected routes.
1. Start the flow
// app/api/auth/login/route.ts
import { randomBytes, createHash } from "crypto";
import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
const b64url = (b: Buffer) =>
b.toString("base64").replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
export async function GET(req: NextRequest) {
const state = b64url(randomBytes(16));
const verifier = b64url(randomBytes(32));
const challenge = b64url(createHash("sha256").update(verifier).digest());
const silent = req.nextUrl.searchParams.get("prompt") === "none";
const next = req.nextUrl.searchParams.get("next") ?? "/dashboard";
const jar = cookies();
jar.set("c1_state", state, { httpOnly: true, sameSite: "lax", secure: true });
jar.set("c1_verifier", verifier, { httpOnly: true, sameSite: "lax", secure: true });
jar.set("c1_silent", silent ? "1" : "0", { httpOnly: true, sameSite: "lax", secure: true });
jar.set("c1_next", next, { httpOnly: true, sameSite: "lax", secure: true });
const url = new URL("https://auth.campusone.com.ng/api/auth/oauth2/authorize");
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", process.env.CAMPUS_ONE_CLIENT_ID!);
url.searchParams.set("redirect_uri", `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/callback`);
url.searchParams.set("scope", "openid profile email academic roles");
url.searchParams.set("state", state);
url.searchParams.set("code_challenge", challenge);
url.searchParams.set("code_challenge_method", "S256");
// Silent check: Campus One returns immediately without rendering a login UI.
if (silent) {
url.searchParams.set("prompt", "none");
}
return NextResponse.redirect(url);
}2. Exchange + hand off to Supabase
// app/api/auth/callback/route.ts
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { createServerClient } from "@supabase/ssr";
const SILENT_ERRORS = new Set([
"login_required",
"interaction_required",
"consent_required",
"account_selection_required",
]);
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const code = searchParams.get("code");
const state = searchParams.get("state");
const error = searchParams.get("error");
const jar = cookies();
const wasSilent = jar.get("c1_silent")?.value === "1";
const next = jar.get("c1_next")?.value ?? "/dashboard";
// Silent attempt + no Campus One session → OIDC error, not a code. Mark the
// browser anonymous and return to the app's public view instead of erroring.
if (error) {
const res = NextResponse.redirect(new URL(wasSilent ? next : "/", req.url));
if (wasSilent && SILENT_ERRORS.has(error)) {
res.cookies.set("c1_anon", "1", { maxAge: 300, sameSite: "lax", secure: true });
}
return res;
}
const expectedState = jar.get("c1_state")?.value;
const verifier = jar.get("c1_verifier")?.value;
if (!code || !state || state !== expectedState || !verifier) {
return new NextResponse("Invalid state", { status: 400 });
}
const tokenRes = await fetch("https://auth.campusone.com.ng/api/auth/oauth2/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/callback`,
client_id: process.env.CAMPUS_ONE_CLIENT_ID!,
client_secret: process.env.CAMPUS_ONE_CLIENT_SECRET!,
code_verifier: verifier,
}),
});
if (!tokenRes.ok) {
return new NextResponse(`Token exchange failed: ${await tokenRes.text()}`, { status: 401 });
}
const { id_token } = (await tokenRes.json()) as { id_token: string };
// Bridge to Supabase. Supabase verifies the id_token against your configured
// OIDC provider — register Campus One as the custom provider in Option 1 even
// if you never trigger its built-in redirect flow. The provider id must match
// that identifier, including the `custom:` prefix.
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{ cookies: { get: (n) => cookies().get(n)?.value, set: () => {}, remove: () => {} } }
);
await supabase.auth.signInWithIdToken({ provider: "custom:campus-one", token: id_token });
const res = NextResponse.redirect(new URL(next, req.url));
for (const name of ["c1_state", "c1_verifier", "c1_silent", "c1_next"]) {
res.cookies.delete(name);
}
res.cookies.delete("c1_anon"); // signed in now — allow future silent retries
return res;
}Receiving webhooks
Use a Supabase Edge Function or a Next.js route handler to receive Campus One webhooks. The handler must run on the server side to access CAMPUS_ONE_WEBHOOK_SECRET.
// app/api/webhooks/campus-one/route.ts
import { createHmac, timingSafeEqual } from "crypto";
import { createClient } from "@supabase/supabase-js";
export async function POST(req: Request) {
const raw = await req.text();
const sig = req.headers.get("x-campus-one-signature") ?? "";
const expected = `sha256=${createHmac("sha256", process.env.CAMPUS_ONE_WEBHOOK_SECRET!)
.update(raw)
.digest("hex")}`;
const a = Buffer.from(sig);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return new Response("Invalid signature", { status: 401 });
}
const event = JSON.parse(raw) as { event: string; data: Record<string, unknown> };
// Mirror role changes into Supabase so RLS policies stay in sync.
if (event.event === "user.role_changed") {
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
await supabase
.from("profiles")
.update({ role: event.data.new_role })
.eq("campus_one_id", event.data.user_id);
}
return new Response("ok");
}