Aller au contenu principal

Les revendications OIDC personnalisées

kuploy-hub tient le rôle de fournisseur d'identité OpenID Connect pour les produits maison (leeram-business, et kuploy-cloud à terme). Au-delà des revendications OIDC standard sub, email, email_verified, name et picture, le centre renvoie les revendications personnalisées ci-dessous, sur son point d'accès userinfo et à l'intérieur de l'id_token signé.

URL de découverte : ${KUPLOY_HUB_URL}/api/auth/.well-known/openid-configuration.

Le centre expose deux autorités distinctes, et les confondre est la méprise à éviter : l'une dit « cette personne exploite kuploy », l'autre dit « cette personne possède l'exploitant auquel votre déploiement est sous licence ». Seule la seconde doit garder vos surfaces d'exploitant.

is_tenant_owner_of​

TypeQuand elle est émiseSource
string[]À chaque réponse userinfoLes exploitants dont l'utilisateur est owner

Les identifiants d'exploitants que possède l'utilisateur connecté. C'est la revendication sur laquelle un consommateur adosse la garde de ses surfaces d'exploitant : déduisez l'exploitant auquel votre déploiement est sous licence — le centre apposant un tenantId à chaque synchronisation de licence réussie —, puis vérifiez que ce tableau le contient.

Garder ainsi signifie qu'un exploitant indépendant fait tourner son propre déploiement sans qu'aucune autorité ne lui soit accordée sur kuploy.app — et que l'équipe de Kuploy n'hérite pas silencieusement de pouvoirs d'exploitant sur le déploiement d'un client.

kuploy_hub_admin​

TypeQuand elle est émiseSource
booleanÀ chaque réponse userinfoL'utilisateur fait partie de l'équipe de la plateforme Kuploy

Affirme que l'utilisateur connecté est administrateur au niveau du centre — l'exploitant de kuploy.app lui-même, et non un membre d'un exploitant. Sa portée est circonscrite aux sujets réellement transversaux du centre : la visibilité sur tous les exploitants, les routes d'administration du centre.

Ce n'est pas un sur-ensemble de is_tenant_owner_of

N'adossez pas la garde des surfaces d'exploitant d'un déploiement à cette revendication. Un membre de l'équipe qui ne possède pas l'exploitant sous licence doit être refusé : c'est tout l'objet de cette séparation. Sur le déploiement interne de Kuploy, les deux se recouvrent — une erreur ici reste donc invisible jusqu'au premier exploitant extérieur à Kuploy.

Les deux revendications sont préfixées (kuploy_*, is_tenant_*), pour éviter toute collision avec les produits en aval qui se servent d'un booléen is_admin ou is_owner générique pour leurs propres rôles.

Un exemple de réponse userinfo​

{
"sub": "usr_01J...",
"email": "ops@example.com",
"email_verified": true,
"name": "Ops",
"kuploy_hub_admin": false,
"is_tenant_owner_of": ["tnt_01J..."]
}

Consommer la revendication​

Lisez les revendications en direct depuis le point d'accès userinfo au moment de la garde, et non depuis une copie conservée dans la table des utilisateurs du consommateur. Le centre est l'unique source de vérité ; recopier à la connexion met en cache une réponse périmée pour toute la durée de vie de la session — 30 jours, par défaut, avec Better Auth : retirer la propriété d'un exploitant ne prendrait donc effet sur les déploiements en aval qu'à la connexion suivante de l'utilisateur.

Une récupération en direct ramène cet écart à la durée de vie du jeton d'accès — typiquement une heure, encore bornée par le rafraîchissement du jeton. Le greffon genericOAuth de Better Auth câble tout seul refreshAccessToken sur le token_endpoint découvert : auth.api.getAccessToken renvoie donc un access_token valide, sans logique de rafraîchissement propre à chaque produit.

Demandez offline_access, pour qu'un refresh_token soit émis à la connexion :

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, // l'oidcProvider de Kuploy a requirePKCE: true.
}],
}),

Puis vérifiez la revendication à chaque évaluation de la garde. Enveloppez le tout dans cache(), pour qu'un même rendu — la visibilité d'un lien dans la mise en page, plus la garde de la page — partage un seul aller-retour :

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; // Pas de compte kuploy, rafraîchissement épuisé, 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")
: [];
// L'exploitant auquel CE déploiement est sous licence, d'après la
// charge utile de licence en cache que le centre appose à chaque
// synchronisation réussie.
const licensedTenantId = await getLicensedTenantId();
return !!licensedTenantId && owned.includes(licensedTenantId);
} catch {
return false;
}
});

Tous les modes de défaillance — aucun compte rattaché, rafraîchissement épuisé, centre injoignable, licence pas encore synchronisée — doivent renvoyer false. Le refus par défaut est la seule réponse prudente lorsque vous ne pouvez pas savoir qui demande. Journalisez-les distinctement, cependant : sans cela, ils produisent tous le même 404, et un exploitant enfermé dehors sans le moindre signal est très difficile à dépanner.

Pour les routes qui doivent être invisibles — et non simplement redirigées — aux utilisateurs non autorisés, gardez-les avec notFound() plutôt qu'une redirection : l'URL paraît alors inexistante à quiconque n'y a pas déjà droit.

import { notFound } from "next/navigation";

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

La déconnexion à l'initiative du client​

Le signOut local de Better Auth ne tue que la ligne de session et le cookie du domaine du consommateur. La session du centre, sur kuploy.app, vit dans un autre pot à cookies — une autre origine : une connexion SSO ultérieure réauthentifie donc silencieusement contre la session de centre toujours active, et l'utilisateur ne constate aucune véritable déconnexion.

Le correctif standard d'OIDC est la déconnexion à l'initiative du client. Le centre publie un end_session_endpoint dans son document de découverte (${KUPLOY_HUB_URL}/api/auth/oauth2/endsession), et le consommateur y redirige le navigateur après la déconnexion locale. Le centre valide id_token_hint, supprime sa propre ligne de session, efface le cookie de kuploy.app, et renvoie l'utilisateur vers post_logout_redirect_uri.

Le câblage côté consommateur​

Lisez l'idToken stocké de l'utilisateur — Better Auth le conserve sur la ligne account, depuis la connexion SSO —, appelez le signOut local, puis redirigez vers le point d'accès endsession du centre :

"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");
}

Les utilisateurs en adresse et mot de passe n'ont aucune ligne account pour le prestataire kuploy : idToken est donc nul, et l'on retombe sur une déconnexion purement locale — il n'y a rien à demander au centre de terminer.

L'exigence côté centre​

Le centre valide post_logout_redirect_uri contre le tableau redirectUrls du client de confiance — le même tableau que celui des rappels OAuth. Ajoutez l'URL de retour après déconnexion à côté de l'URL de rappel, lors de l'enregistrement du client :

// kuploy-hub : KUPLOY_OIDC_TRUSTED_CLIENTS
{
"clientId": "leeram-business",
// ...
"redirectUrls": [
// Better Auth < 1.7 — voir la note de version ci-dessous.
"https://leeram.co/api/auth/oauth2/callback/kuploy",
"https://leeram.co/" // post_logout_redirect_uri
]
}
Le chemin de rappel dépend de la version

La forme /api/auth/oauth2/callback/ ci-dessus est celle de Better Auth < 1.7, la version que fait tourner leeram-business. À partir de la 1.7, le greffon generic-oauth enregistre ses prestataires sur la route callback/:id du cœur : un consommateur plus récent — growthops, par exemple — rappelle donc sur /api/auth/callback/<providerId>, sans le segment oauth2. Enregistrer le mauvais chemin donne un redirect_uri_mismatch, qui ressemble à un problème de secret, et qui est un problème de chemin.

Sans cette entrée, le centre renvoie invalid_request et la redirection endsession échoue : l'utilisateur aboutit sur une page d'erreur du centre, au lieu de revenir sur le consommateur.