Campus One
SSO

OpenID Connect

Set up OIDC single sign-on with Campus One

OpenID Connect (OIDC) is the recommended protocol for integrating with Campus One. It is built on OAuth 2.0 and returns a signed ID token (JWT) that your app can trust without a round-trip to Campus One.

Campus One acts as the OpenID Provider at https://auth.campusone.com.ng. Your app is the Relying Party.

1. Register your app

Sign in to the developer dashboard at https://app.campusone.com.ng/developer/apps and create a new app:

FieldValue for an app hosted at ct.campusone.com.ng
ProtocolOIDC
Redirect URLshttps://ct.campusone.com.ng/api/auth/callback/campus-one (and any other callback URLs your framework uses)

Once the app is created, open the Sign-in tab of the app drawer to copy:

  • CAMPUS_ONE_CLIENT_ID — the clientId shown at the top of the tab.
  • CAMPUS_ONE_CLIENT_SECRET — click Reveal and copy it. It is shown once; rotate via the Rotate client secret action if lost.
  • CAMPUS_ONE_WEBHOOK_SECRET — see webhooks. Found on the Webhooks tab of the same drawer.

Keep the client and webhook secrets out of client-side bundles and version control.

Localhost & Staging Environment Testing

You can fully test the OIDC integration in your local environment or deployed staging sites before your app has been given an official campusone.com.ng production subdomain.

[!NOTE] Pending status apps are fully functional: OIDC authorization, user consent screens, and token generation are completely active even when your app is in Pending status. You do not need to wait for admin approval to start testing.

1. Multi-App Strategy (Staging vs. Production Keys)

We recommend registering two separate applications under your developer account:

  • My App (Staging/Dev): Configured with your local or staging callback URLs. This provides you with dedicated test keys (Client ID and Client Secret) to use during active development.
  • My App (Production): Created once you are ready to coordinate with Campus One admins to deploy onto a production campusone.com.ng subdomain. This provides you with your production keys.

2. Registering Callback URLs

You can register any valid URL (including localhost or custom staging domains) under the Redirect URLs list of your test app:

  • Localhost: http://localhost:3000/api/auth/callback/campus-one (or the port/path your framework uses).
  • Deployed Staging: https://staging.myapp.com/api/auth/callback/campus-one or https://my-app-branch.vercel.app/api/auth/callback/campus-one.

3. How the Authentication Layer Handles Localhost

To make local testing completely seamless, the authentication server dynamically detects development setups:

  • When standard HTTP localhost is used for testing, cross-subdomain cookie scopes are automatically bypassed.
  • Secure cookie attributes are relaxed (secure: false) so that your local session remains active and you don't encounter cookie rejection warnings in your browser.

2. Discovery + endpoints

Most OIDC libraries auto-configure from the discovery URL:

https://auth.campusone.com.ng/api/auth/.well-known/openid-configuration

The discovery document advertises the following endpoints:

EndpointURL
Authorizationhttps://auth.campusone.com.ng/api/auth/oauth2/authorize
Token exchangehttps://auth.campusone.com.ng/api/auth/oauth2/token
User infohttps://auth.campusone.com.ng/api/auth/oauth2/userinfo
JWKS (public keys)https://auth.campusone.com.ng/api/auth/jwks

The issuer (iss) advertised in id_tokens is https://auth.campusone.com.ng.

3. Scopes and claims

Request scopes in the scope parameter of the authorization URL:

ScopeReturns on the id_token / userinfoDefault
openidsub (required)Always
profilename, picture, preferred_username, phone_numberYes
emailemail, email_verifiedYes
offline_accessReturns a refresh tokenNo — must be requested
academicstudent_id, study_level, level, final_year, faculty_id, department_id, academic_session, semesterYes
calendarPermission to read the user's timetableNo — must be requested
notificationsPermission to send the user notificationsYes
rolesroles (array of every role assigned to the user)No — must be requested

Role claims

Every id_token includes a top-level role claim with the user's primary role (one of admin, student, staff, developer, employer, consultant, therapist, founder, mentor, alumni, auditor). This claim is always present whenever the openid scope is granted — your app can authorise users from the id_token alone without an extra /userinfo call.

If a user has multiple roles, the additional roles are exposed as the roles claim only when the roles scope is requested and granted. Request roles if your app needs to make authorization decisions based on more than the primary role.

Example payload (decoded id_token):

{
  "iss": "https://auth.campusone.com.ng",
  "aud": "your-client-id",
  "sub": "user_abc123",
  "email": "256240001@nileuniversity.edu.ng",
  "email_verified": true,
  "name": "Aisha Mohammed",
  "role": "student",
  "roles": ["student", "mentor"],
  "student_id": "256240001",
  "study_level": "undergraduate",
  "level": 300,
  "final_year": false,
  "faculty_id": "fac_eng",
  "department_id": "dept_cs",
  "academic_session": "2025/2026",
  "semester": "harmattan",
  "exp": 1735689600,
  "iat": 1735686000
}

Session, semester & level progression

The academic scope also carries the platform-wide academic calendar and the student's standing within it:

  • academic_session — the current session, e.g. "2025/2026".
  • semester — the current semester: "harmattan" (First, Sept–Feb) or "rain" (Second, mid-Feb–mid-July).
  • final_yeartrue once the student's level reaches their department's maximum level.

[!NOTE] Student level (100–500) advances by 100 automatically at the start of the Harmattan semester each year (e.g. a 200-level student becomes 300-level). Students already at their department's maximum level stay there with final_year: true. Don't cache level or final_year indefinitely — re-read them from the token on each sign-in so your app reflects the current session.

4. Authorization request

Campus One requires PKCE on all clients (OAuth 2.1). Generate a code_verifier and code_challenge, then redirect the user to:

https://auth.campusone.com.ng/api/auth/oauth2/authorize
  ?response_type=code
  &client_id=YOUR_CLIENT_ID
  &redirect_uri=https://ct.campusone.com.ng/api/auth/callback/campus-one
  &scope=openid%20profile%20email%20academic%20roles%20offline_access
  &state=RANDOM_STATE_VALUE
  &code_challenge=BASE64URL_SHA256_OF_VERIFIER
  &code_challenge_method=S256

Always include state (CSRF protection). The user will be shown the Campus One consent screen on first authorisation; trusted internal apps can skip consent by toggling the Trusted app switch in the developer dashboard.

Automatic & silent sign-in

Campus One is a single sign-on platform: a student authenticates once and then moves between connected apps without logging in again. Your app should lean into this — don't build your own login, signup, or password-reset pages. Redirect unauthenticated visitors straight to the authorize endpoint above and let Campus One do the rest.

How automatic login works. Campus One maintains the student's session. When your app sends them to /api/auth/oauth2/authorize and a valid Campus One session already exists, Campus One does not prompt for credentials — it immediately issues an authorization code and redirects back to your redirect_uri. The student experiences an instant, transparent sign-in.

  • Shared session on subdomains. In production the Campus One session cookie is scoped to .campusone.com.ng, so apps hosted on a *.campusone.com.ng subdomain share it directly.
  • Apps on other domains still get automatic login: the redirect to auth.campusone.com.ng carries the Campus One session cookie, so the authorize step resolves without a login screen.
  • Consent is shown at most once. First authorisation shows the scope-consent dialog; afterwards it's remembered until your requested scopes change. Trusted apps skip it entirely.

Pattern: auto-redirect on protected routes. Rather than a "Sign in" page, have your route guard / middleware kick off the OIDC redirect whenever there's no local session. The user never sees a form.

Fully silent attempts with prompt=none. To check for an existing session without any visible redirect to a login screen, add prompt=none to the authorization request:

https://auth.campusone.com.ng/api/auth/oauth2/authorize
  ?response_type=code
  &client_id=YOUR_CLIENT_ID
  &redirect_uri=...
  &scope=openid%20profile%20email
  &state=...
  &code_challenge=...
  &code_challenge_method=S256
  &prompt=none
  • If the student has an active Campus One session, you get an authorization code back with no UI shown.
  • If they don't, Campus One redirects back with error=login_required (per the OIDC spec). For a strictly protected route, retry without prompt=none to start a normal interactive sign-in. For a public route, just render the anonymous view.

This is ideal for "silently restore the session on page load" flows in SPAs (often run inside a hidden iframe) without ever flashing a login screen.

[!IMPORTANT] Guard the silent attempt against loops. A prompt=none request for a visitor who isn't signed in to Campus One returns login_required every time — if you re-attempt it on the next page load you create a redirect loop. After a failed silent attempt, set a short-lived marker (a cookie or localStorage flag) and skip further silent attempts until it expires or the user signs in. Clear the marker on a successful sign-in so the session can be silently restored on a later visit. The integration examples all use a c1_anon marker for exactly this.

Alongside login_required, treat interaction_required, consent_required, and account_selection_required the same way — they all mean "can't proceed silently".

5. Token exchange

After consent, Campus One redirects to your redirect_uri with ?code=…&state=…. Exchange the code for tokens:

POST https://auth.campusone.com.ng/api/auth/oauth2/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=AUTHORIZATION_CODE
&redirect_uri=https://ct.campusone.com.ng/api/auth/callback/campus-one
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRET
&code_verifier=ORIGINAL_VERIFIER

Response:

{
  "access_token": "eyJ...",
  "id_token": "eyJ...",
  "refresh_token": "eyJ...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "openid profile email academic roles offline_access"
}

refresh_token is only returned if offline_access was in the granted scopes.

6. Validating the ID token

Decode and verify the JWT using the public keys from the JWKS endpoint:

import { createRemoteJWKSet, jwtVerify } from "jose";

const JWKS = createRemoteJWKSet(
  new URL("https://auth.campusone.com.ng/api/auth/jwks")
);

const { payload } = await jwtVerify(idToken, JWKS, {
  issuer: "https://auth.campusone.com.ng",
  audience: process.env.CAMPUS_ONE_CLIENT_ID,
});

console.log(payload.sub);        // unique user ID
console.log(payload.email);
console.log(payload.role);       // "student" | "staff" | ...
console.log(payload.student_id); // e.g. "256240001"

Always verify iss, aud, and exp.

7. Refreshing tokens

POST https://auth.campusone.com.ng/api/auth/oauth2/token
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token
&refresh_token=REFRESH_TOKEN
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRET

Refresh tokens expire after 7 days of inactivity or when the user revokes access.

Quick-start with a library

Most OIDC libraries work by pointing them at the discovery URL:

// next-auth (Auth.js)
import NextAuth from "next-auth";

export default NextAuth({
  providers: [
    {
      id: "campus-one",
      name: "Campus One",
      type: "oidc",
      issuer: "https://auth.campusone.com.ng",
      wellKnown:
        "https://auth.campusone.com.ng/api/auth/.well-known/openid-configuration",
      clientId: process.env.CAMPUS_ONE_CLIENT_ID,
      clientSecret: process.env.CAMPUS_ONE_CLIENT_SECRET,
      authorization: {
        params: { scope: "openid profile email academic roles offline_access" },
      },
    },
  ],
  callbacks: {
    async session({ session, token }) {
      // Surface the role claim on the session so route guards can read it.
      session.user.role = token.role as string | undefined;
      return session;
    },
  },
});
// better-auth (consumer side)
import { betterAuth } from "better-auth";
import { genericOAuth } from "better-auth/plugins/generic-oauth";

export const auth = betterAuth({
  plugins: [
    genericOAuth({
      config: [
        {
          providerId: "campus-one",
          discoveryUrl:
            "https://auth.campusone.com.ng/api/auth/.well-known/openid-configuration",
          clientId: process.env.CAMPUS_ONE_CLIENT_ID!,
          clientSecret: process.env.CAMPUS_ONE_CLIENT_SECRET!,
          scopes: ["openid", "profile", "email", "academic", "roles", "offline_access"],
          pkce: true,
        },
      ],
    }),
  ],
});

8. App Health Monitoring

To display your application's live status badge (Online vs Offline) to Campus One administrators and inside the student directories, you should ensure your application is reachable from the Campus One server.

Campus One dynamically checks your app's reachability by pinging your app's acsUrl or primary redirect URL using lightweight HTTP HEAD (or GET) requests. For the best experience, configure your server to respond swiftly with a 200 OK or any successful HTTP code.

Exposing a Custom Health Route

If you want to handle these checks specifically, we recommend configuring a lightweight endpoint on your server:

// Example using Node.js / Express
app.head("/api/health", (req, res) => {
  res.status(200).end();
});

app.get("/api/health", (req, res) => {
  res.status(200).json({ status: "healthy", timestamp: new Date() });
});

9. Handling User Disconnection

When a user clicks "Disconnect" inside their Profile Settings page on Campus One, Campus One instantly revokes all active OIDC consent records and deletes their OIDC access and refresh tokens.

To keep your application's local user records synchronized and log the user out gracefully when their access is revoked, we recommend two strategies:

A. Session Verification on Request (Token Validation)

Whenever your application performs server-side data fetching or routes a user to an authenticated page, verify the validity of their stored access_token by calling the userinfo endpoint /api/auth/oauth2/userinfo.

If Campus One returns a 401 Unauthorized (indicating the user has disconnected the app or their token has expired), you should immediately invalidate the user's local session and direct them to re-authenticate:

const res = await fetch("https://auth.campusone.com.ng/api/auth/oauth2/userinfo", {
  headers: {
    Authorization: `Bearer ${storedAccessToken}`,
  },
});

if (res.status === 401) {
  // Clear local session cookies / database session
  destroyLocalSession();
  
  // Redirect back to Campus One authorize endpoint
  redirectToSSO();
}

B. Listening to Revocation Webhooks (Coming Soon)

You can register a secure callback under the Webhooks tab of the developer drawer. Campus One will emit a user.disconnected event to your webhook endpoint when consent is revoked, letting you clean up user records asynchronously:

POST https://your-app.com/api/webhooks/campus-one
Content-Type: application/json
X-Campus-One-Signature: <hmac-signature>

{
  "event": "user.disconnected",
  "timestamp": "2026-05-21T18:41:23Z",
  "data": {
    "userId": "user_abc123",
    "clientId": "your-client-id"
  }
}

10. App-Specific Custom Roles

To support application-specific permissions alongside Nile's default global academic roles ("student", "staff", "admin"), Campus One lets developers declare and assign Custom Roles (e.g. "editor", "tutor", "moderator") for their registered integration.

Mapped Token Claims

When a student or staff member authorizes your app, their assigned custom roles are dynamically injected into their OIDC tokens whenever the client requests the roles scope:

  • custom_roles (Array of Strings): Contains only your app-specific roles assigned to this user.
  • roles (Array of Strings): Merges the user's primary academic role (e.g. "student") with their app-specific custom roles.

Example Token Payload

{
  "iss": "https://auth.campusone.com.ng",
  "sub": "user_abc123",
  "email": "student@nileuniversity.edu.ng",
  "role": "student",
  "roles": ["student", "editor", "moderator"],
  "custom_roles": ["editor", "moderator"]
}

Authorization Check Example

You can leverage these roles inside your route guards or API endpoints to restrict access based on the dynamic permissions parsed from the OIDC token:

// Route guard checking custom roles
function requireAppRole(requiredRole: string) {
  return (req, res, next) => {
    const claims = req.user; // decoded OIDC ID token claims
    
    const customRoles = claims.custom_roles || [];
    if (!customRoles.includes(requiredRole)) {
      return res.status(403).json({ 
        error: "Forbidden", 
        message: `This action requires the custom app role: ${requiredRole}` 
      });
    }
    
    next();
  };
}

// Usage in an Express route
app.post("/api/articles", requireAppRole("editor"), (req, res) => {
  res.json({ message: "Article created successfully!" });
});

The reserved unit_admin role

Alongside the custom roles you define, every Campus One app shares one reserved role with a fixed key: unit_admin. It is the recommended way to give a staff member elevated standing in your app without making them a platform administrator.

Unlike your own custom roles, unit_admin:

  • Has the same key for every app. You never declare it — it is always available in the Access tab of your app drawer, and you cannot create a custom role named unit_admin (the name is reserved).
  • Is sent to your app like any other role. When a unit admin signs in (with the roles scope), "unit_admin" appears in both the roles and custom_roles claims. Treat it as your app's highest-privilege role — show admin screens, allow destructive actions, etc.
  • Also grants Campus One access management. A unit admin can open the Managed access page in Campus One and grant, revoke, and assign your app's custom roles to other staff and students. This is the capability previously hidden behind a bare "admin" toggle that was never sent to your app.

[!NOTE] Only the app owner (you) or a Campus One platform admin can assign unit_admin. Once assigned, that unit admin can manage everyone else's roles for your app — but they cannot create or revoke other unit admins. This keeps a clear chain of authority: you mint unit admins; they run day-to-day access.

// A unit admin's id_token (roles scope granted)
{
  "sub": "user_xyz789",
  "email": "head.of.unit@nileuniversity.edu.ng",
  "role": "staff",
  "roles": ["staff", "editor", "unit_admin"],
  "custom_roles": ["editor", "unit_admin"]
}
// Gate your app's own admin area on the reserved role
const isUnitAdmin = (claims.custom_roles ?? []).includes("unit_admin");
if (isUnitAdmin) {
  // Render unit-admin dashboards, allow bulk actions, etc.
}

[!TIP] Use unit_admin for "department head / app manager" personas and your own custom roles (editor, tutor, …) for finer-grained, app-specific permissions. The two compose: a user can be both an editor and a unit_admin.


11. OIDC Client Last Sync Feature

Campus One tracks the exact timestamp when OIDC client details and user consent records are successfully synchronized with relying party credentials. This is vital for auditable compliance, security auditing, and verification of key rotations.

Feature Mechanics

  • Automatic Sync Hook: Whenever an application client requests an authorization token or requests userinfo with active credentials, the lastSyncAt field is automatically bumped on the database SsoConfig record.
  • Auditing Visibility: The last sync timestamp is reactively displayed on the developer portal app cards and drawer dashboards under the Last Sync status indicator.
  • Consent Revocation Verification: This timestamp helps developers verify if a user's session has successfully established a fresh OIDC handshake after credential rotation or scope updates.

On this page