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 (
.../api/auth/oauth2/callback/<providerId>) — where the hub returns the authorization code after sign-in. - 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 kuploy_hub_admin 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.
Hub-admin authority on consumer products
A hub-level admin (email.role = "admin" on kuploy-hub) is recognised
on every downstream consumer through the
kuploy_hub_admin OIDC claim. leeram-business
uses this to gate license-management actions
(requireInstanceAdmin) without per-deployment opt-in. Demoting a hub
admin 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.