Skip to main content

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 genericOAuth plugin. 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 redirectUrls or endsession rejects the request with invalid_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

  1. Sign in on the consumer via the "Continue with Kuploy" button.
  2. 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.
  3. 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:

  1. Reads the user's stored idToken from the kuploy account row (Better Auth persists it after the OAuth dance).

  2. Calls auth.api.signOut(...) to clear the local session + cookie.

  3. 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_id is required: id_tokens expire after ~1 hour while consumer sessions can last much longer, so the stored idToken may be expired by the time the user clicks Sign Out. With client_id present, the hub validates post_logout_redirect_uri against the trusted client's redirectUrls even when the hint can't be verified.

  4. The hub deletes its own session row, clears the kuploy.app session cookie, and 302s back to post_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.