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
| Type | When emitted | Source |
|---|---|---|
boolean | Every userinfo response | email.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.