SSO with kuploy-hub
kuploy-hub doubles as the OpenID Connect identity provider for first-party products — leeram-business today, kuploy-cloud after migration. Sign-in on those products redirects to kuploy.app, the user authenticates once, and the consumer trusts the hub's session via standard OIDC.
This page is for operators registering a downstream product as an SSO client of kuploy-hub. For the claim semantics and consumer-side code patterns, see OIDC Custom Claims.
What you need
- kuploy-hub running, reachable at a stable URL (e.g.
https://kuploy.app). - A consumer product that uses Better Auth's
genericOAuthplugin. leeram-business is the reference implementation.
Step 1 — Register the trusted client on the hub
Trusted clients are seeded from the KUPLOY_OIDC_TRUSTED_CLIENTS
environment variable on the hub. JSON array, one entry per consumer:
KUPLOY_OIDC_TRUSTED_CLIENTS='[
{
"clientId": "leeram-business",
"clientSecret": "<generated; matches the consumer side>",
"name": "Leeram Business",
"redirectUrls": [
"https://business.example.com/api/auth/oauth2/callback/kuploy",
"https://business.example.com/"
],
"skipConsent": true
}
]'
redirectUrls is validated for two distinct destinations:
-
The OAuth callback — where the hub returns the authorization code after sign-in. Its path depends on the consumer's Better Auth version, and getting it wrong is the most common way this fails:
consumer callback path Better Auth < 1.7 (e.g. leeram-business) .../api/auth/oauth2/callback/<providerId>Better Auth >= 1.7 (e.g. growthops) .../api/auth/callback/<providerId>The generic-OAuth plugin used to expose its own callback endpoint; from 1.7 it registers providers against the core
callback/:idroute instead — its own source says "no plugin-specific endpoints needed". Copying an older client's entry therefore yieldsredirect_uri_mismatch, which reads like a bad client secret and is not. Check the consumer'sbetter-authversion before registering. -
The post-logout redirect (e.g. the consumer's home page) — where the hub bounces the browser back after RP-Initiated Logout. Both must appear in
redirectUrlsorendsessionrejects the request withinvalid_request.
skipConsent: true is appropriate for first-party clients (you own
both ends). Leave it false for any third-party SSO consumer.
After updating the env var, redeploy the hub so it picks up the new client list.
Step 2 — Configure the consumer
Set these on the consumer deployment (leeram-business in this example):
KUPLOY_HUB_URL=https://kuploy.app
KUPLOY_OIDC_CLIENT_ID=leeram-business
KUPLOY_OIDC_CLIENT_SECRET=<same value the hub holds>
NEXT_PUBLIC_APP_URL=https://business.example.com
The consumer's Better Auth config requests the standard scopes plus
offline_access so a refresh_token is issued at sign-in (needed by
later live userinfo fetches and by the live operator check):
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-hub's oidcProvider has requirePKCE: true
}],
}),
Step 3 — Verify the round-trip
- Sign in on the consumer via the "Continue with Kuploy" button.
- You should be redirected to
${KUPLOY_HUB_URL}/sign-in, authenticate (or pass through if already signed in), and bounce back to the consumer with an active session. - On the consumer, Sign Out triggers RP-Initiated Logout
— the browser is sent to
${KUPLOY_HUB_URL}/api/auth/oauth2/endsession, the hub clears its own session, and the user lands on the consumer's home page.
Sign-out behaviour
A local sign-out on the consumer only kills the consumer's session
cookie. The hub's session at kuploy.app lives on a separate domain
and is unaffected — so a subsequent "Continue with Kuploy" silently
re-auths against the still-active hub session and the user is back in
the consumer within ~200ms. That's the bug RP-Initiated Logout fixes.
The consumer's sign-out flow does, in order:
-
Reads the user's stored
idTokenfrom the kuployaccountrow (Better Auth persists it after the OAuth dance). -
Calls
auth.api.signOut(...)to clear the local session + cookie. -
Redirects the browser to:
${KUPLOY_HUB_URL}/api/auth/oauth2/endsession
?client_id=${KUPLOY_OIDC_CLIENT_ID}
&id_token_hint=<idToken>
&post_logout_redirect_uri=${NEXT_PUBLIC_APP_URL}/client_idis required: id_tokens expire after ~1 hour while consumer sessions can last much longer, so the storedidTokenmay be expired by the time the user clicks Sign Out. Withclient_idpresent, the hub validatespost_logout_redirect_uriagainst the trusted client'sredirectUrlseven when the hint can't be verified. -
The hub deletes its own session row, clears the
kuploy.appsession cookie, and 302s back topost_logout_redirect_uri.
After this, both cookie jars are empty. A subsequent "Continue with Kuploy" actually requires re-entering credentials.
Operator authority on consumer products
A consumer product recognises the owner of the tenant its license
belongs to as the operator of that deployment, through the
is_tenant_owner_of OIDC claim. leeram-business
uses this to gate license-management actions (requireInstanceAdmin):
it resolves the tenant from the license payload, then checks that the
signed-in user's claim contains it.
Kuploy platform staff are a separate claim (kuploy_hub_admin) and are
not a superset — staff who don't own your tenant have no operator
powers on your deployment, and you need no staff role to run your own.
Removing tenant ownership takes effect on every consumer within the access-token TTL (~1 hour) — the consumer fetches the claim live on each gate evaluation rather than caching it.
Troubleshooting
invalid_request: client_id is required when using post_logout_redirect_uri without a valid id_token_hint
The hub couldn't verify id_token_hint (most often because the stored
token has expired) and no explicit client_id was supplied. Make sure
the consumer sends client_id in addition to id_token_hint on the
endsession URL — see Sign-out behaviour.
invalid_request: post_logout_redirect_uri is not registered for this client
Add the post-logout URL (e.g. https://business.example.com/) to the
trusted client's redirectUrls array in
KUPLOY_OIDC_TRUSTED_CLIENTS and redeploy the hub.
error=account_not_linked after a successful Kuploy sign-in
The consumer rejected linking the OAuth account to a pre-existing
local user (default Better Auth behaviour). Set
account.accountLinking.trustedProviders: ["kuploy"] in the
consumer's betterAuth({...}) config — safe because Kuploy is the
identity source you control.
"Continue with Kuploy" round-trips silently — user is signed in instantly
That's the hub's session cookie still being valid. To force
re-authentication for testing, sign out of kuploy.app directly (or
clear cookies for that domain), then retry. RP-Initiated Logout
handles this automatically in normal use.