Campus One
SSOExamples

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:

  1. 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.
  2. 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.signInWithIdToken to 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:

FieldValue
Provider Identifiercampus-one — referenced in the SDK as custom:campus-one
Display NameCampus One
Configuration MethodAuto-discovery (recommended)
Issuer URLhttps://auth.campusone.com.ng
Discovery URLhttps://auth.campusone.com.ng/api/auth/.well-known/openid-configuration
Client IDyour CAMPUS_ONE_CLIENT_ID
Client Secretyour CAMPUS_ONE_CLIENT_SECRET
Scopesopenid, email, profile, academic, roles, offline_access (comma-separated)
Allow users without emailOff

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 hit https://auth.campusone.com.ng/.well-known/openid-configuration (which 404s) and the save would fail. Paste the full …/api/auth/.well-known/openid-configuration path 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");
}

On this page