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.

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​

TypeWhen emittedSource
string[]Every userinfo responseTenants 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​

TypeWhen emittedSource
booleanEvery userinfo responseThe 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).

Not a superset of is_tenant_owner_of

Do 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 callback path is version-specific

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.