Skip to main content

OIDC Custom Claims

kuploy-hub acts as the OpenID Connect identity provider for first-party products (leeram-business, future kuploy-cloud). Beyond the standard OIDC sub, email, email_verified, name, and picture claims, the hub returns the custom claims listed below on its userinfo endpoint and inside the signed id_token.

Discovery URL: ${KUPLOY_HUB_URL}/api/auth/.well-known/openid-configuration.

kuploy_hub_admin

TypeWhen emittedSource
booleanEvery userinfo responseemail.role === "admin" on the hub

Asserts that the signed-in user is a hub-level admin (the operator of this kuploy-hub deployment, not a tenant member). Downstream products should treat the claim as a break-glass override that grants instance- admin authority on any of their self-hosted deployments — analogous to how the hub admin already manages tenants, licenses, and instances from the admin dashboard.

The claim is namespaced (kuploy_*) to avoid colliding with downstream products that use a generic is_admin boolean for their own roles.

Example userinfo response

{
"sub": "usr_01J...",
"email": "ops@kuploy.app",
"email_verified": true,
"name": "Ops",
"kuploy_hub_admin": true
}

Consuming the claim

Read the claim live from the userinfo endpoint at gate time, not from a mirror persisted on the consumer's user table. The hub is the single source of truth for email.role === "admin"; mirroring the boolean at sign-in caches a stale answer for the entire session lifetime (Better Auth's default is 30 days), so demoting a hub admin wouldn't take effect on downstream deployments until the user signs in again.

Live fetch closes that gap to the access-token TTL (typically ~1h, further bounded by token refresh). Better Auth's genericOAuth plugin auto-wires refreshAccessToken against the discovered token_endpoint, so auth.api.getAccessToken returns a valid access_token without per-product refresh logic.

Request offline_access so a refresh_token is issued at sign-in:

genericOAuth({
config: [{
providerId: "kuploy",
discoveryUrl: `${KUPLOY_HUB_URL}/api/auth/.well-known/openid-configuration`,
clientId: KUPLOY_OIDC_CLIENT_ID,
clientSecret: KUPLOY_OIDC_CLIENT_SECRET,
scopes: ["openid", "profile", "email", "offline_access"],
pkce: true, // Kuploy's oidcProvider has requirePKCE: true.
}],
}),

Then check the claim at every gate evaluation. Wrap in cache() so a single render (e.g. layout link visibility + page guard) shares one round-trip:

import "server-only";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { cache } from "react";

export const isCurrentUserHubAdmin = cache(async (): Promise<boolean> => {
const KUPLOY_HUB_URL = process.env.KUPLOY_HUB_URL;
if (!KUPLOY_HUB_URL) return false;

const hdrs = await headers();
const session = await auth.api.getSession({ headers: hdrs });
if (!session?.user) return false;

let accessToken: string | undefined;
try {
const tokens = await auth.api.getAccessToken({
body: { providerId: "kuploy" },
headers: hdrs,
});
accessToken = tokens.accessToken;
} catch {
return false; // No kuploy account, refresh exhausted, etc.
}
if (!accessToken) return false;

try {
const res = await fetch(`${KUPLOY_HUB_URL}/api/auth/oauth2/userinfo`, {
headers: { Authorization: `Bearer ${accessToken}` },
cache: "no-store",
});
if (!res.ok) return false;
const data = (await res.json()) as { kuploy_hub_admin?: unknown };
return data.kuploy_hub_admin === true;
} catch {
return false;
}
});

For routes that should be invisible (not just redirected) to unauthorized users, gate with notFound() rather than redirect — the URL appears non-existent to anyone who isn't already qualified:

import { notFound } from "next/navigation";

export async function requireHubAdmin(): Promise<void> {
if (!(await isCurrentUserHubAdmin())) notFound();
}

RP-Initiated Logout

Better Auth's local signOut only kills the session row + cookie on the consumer's domain. The hub's session at kuploy.app lives in a separate cookie jar (different origin), so a subsequent SSO sign-in silently re-auths against the still-active hub session — the user sees no real sign-out.

The OIDC standard fix is RP-Initiated Logout. The hub publishes end_session_endpoint in its discovery doc (${KUPLOY_HUB_URL}/api/auth/oauth2/endsession) and the consumer redirects the browser there after the local sign-out. The hub validates id_token_hint, deletes its own session row, clears the kuploy.app cookie, and bounces the user back to post_logout_redirect_uri.

Consumer-side wiring

Read the user's stored idToken (Better Auth persists it on the account row from the SSO sign-in), call local signOut, then redirect to the hub's endsession endpoint:

"use server";
import { auth } from "@/lib/auth";
import { db } from "@your/db";
import { account } from "@your/db/schema";
import { and, desc, eq } from "drizzle-orm";
import { headers } from "next/headers";
import { redirect } from "next/navigation";

export async function signOutAction(): Promise<void> {
const hdrs = await headers();
const session = await auth.api.getSession({ headers: hdrs });

let idToken: string | null = null;
if (session?.user?.id) {
const [row] = await db
.select({ idToken: account.idToken })
.from(account)
.where(and(eq(account.userId, session.user.id), eq(account.providerId, "kuploy")))
.orderBy(desc(account.updatedAt))
.limit(1);
idToken = row?.idToken ?? null;
}

await auth.api.signOut({ headers: hdrs });

const KUPLOY_HUB_URL = process.env.KUPLOY_HUB_URL;
const APP_URL = process.env.NEXT_PUBLIC_APP_URL;
if (idToken && KUPLOY_HUB_URL && APP_URL) {
const url = new URL(`${KUPLOY_HUB_URL}/api/auth/oauth2/endsession`);
url.searchParams.set("id_token_hint", idToken);
url.searchParams.set("post_logout_redirect_uri", `${APP_URL}/`);
redirect(url.toString());
}

redirect("/sign-in");
}

Email+password users have no account row for the kuploy provider, so idToken is null and we fall through to a local-only sign-out — nothing to ask the hub to terminate.

Hub-side requirement

The hub validates post_logout_redirect_uri against the trusted client's redirectUrls array — the same array used for OAuth callbacks. Add the post-logout URL alongside the callback URL when registering the client:

// kuploy-hub: KUPLOY_OIDC_TRUSTED_CLIENTS
{
"clientId": "leeram-business",
// ...
"redirectUrls": [
"https://business.leeram.today/api/auth/oauth2/callback/kuploy",
"https://business.leeram.today/" // post_logout_redirect_uri
]
}

Without that entry, the hub returns invalid_request and the endsession redirect fails — the user lands on a hub-side error page instead of bouncing back to the consumer.