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.
The hub surfaces two distinct authorities, and conflating them is the mistake to avoid: one says "this person runs kuploy", the other says "this person owns the tenant your deployment is licensed to". Only the second one should gate your operator surfaces.
is_tenant_owner_of
| Type | When emitted | Source |
|---|---|---|
string[] | Every userinfo response | Tenants the user is an owner of |
The tenant IDs the signed-in user owns. This is the claim a consumer
gates its operator surfaces on: resolve the tenant your deployment is
licensed to (the hub stamps tenantId on every successful license
sync), then check that this array contains it.
Gating this way means an independent operator runs their own deployment without being granted any authority on kuploy.app — and Kuploy staff do not silently inherit operator powers on a customer's deployment.
kuploy_hub_admin
| Type | When emitted | Source |
|---|---|---|
boolean | Every userinfo response | The user is Kuploy platform staff |
Asserts that the signed-in user is a hub-level admin — the operator of kuploy.app itself, not a tenant member. It is scoped to genuinely platform-wide concerns on the hub (all-tenants visibility, hub admin routes).
is_tenant_owner_ofDo not gate a deployment's operator surfaces on this claim. Staff who don't own the licensed tenant must be refused, which is the entire point of the split. On the Kuploy-internal deployment the two overlap, so a mistake here stays invisible until the first non-Kuploy operator.
Both claims are namespaced (kuploy_* / is_tenant_*) to avoid
colliding with downstream products that use a generic is_admin or
is_owner boolean for their own roles.
Example userinfo response
{
"sub": "usr_01J...",
"email": "ops@example.com",
"email_verified": true,
"name": "Ops",
"kuploy_hub_admin": false,
"is_tenant_owner_of": ["tnt_01J..."]
}
Consuming the claim
Read the claims 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; mirroring at sign-in caches a stale answer for the entire session lifetime (Better Auth's default is 30 days), so removing tenant ownership 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 isOperatorOfThisDeployment = 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 { is_tenant_owner_of?: unknown };
const owned = Array.isArray(data.is_tenant_owner_of)
? data.is_tenant_owner_of.filter((v): v is string => typeof v === "string")
: [];
// The tenant THIS deployment is licensed to, from the cached
// license payload the hub stamps on every successful sync.
const licensedTenantId = await getLicensedTenantId();
return !!licensedTenantId && owned.includes(licensedTenantId);
} catch {
return false;
}
});
Every failure mode — no linked account, refresh exhausted, hub
unreachable, license not yet synced — must return false. Default-deny
is the only safe answer when you cannot tell who is asking. Log each one
distinctly, though: they all produce the same 404 otherwise, and an
operator locked out with no signal is very hard to debug.
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 requireInstanceAdmin(): Promise<void> {
if (!(await isOperatorOfThisDeployment())) 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": [
// Better Auth < 1.7 — see the version note below.
"https://leeram.co/api/auth/oauth2/callback/kuploy",
"https://leeram.co/" // post_logout_redirect_uri
]
}
The /api/auth/oauth2/callback/ form above is Better Auth < 1.7, which
is what leeram-business runs. From 1.7 the generic-OAuth plugin registers
providers against the core callback/:id route, so a newer consumer —
growthops, for one — calls back on /api/auth/callback/<providerId> with
no oauth2 segment. Registering the wrong one gives redirect_uri_mismatch,
which looks like a credential problem and is a path problem.
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.