Edit history

SSO/OIDC login is completely broken in self-hosted monolith mode has not been edited, so there are no earlier versions.

Current version | Original by Rex
Show

SSO/OIDC login is completely broken in self-hosted monolith mode

Description

I used Claude to find and track all of its fixes, but I do not want to push AI generated code into a public repo, and felt it would be better to solve the issues in the way the project owners want to. Hell these may be by design I dont know, but I think it points to valuable details to get the self-hosting side up and running faster. I (Claude HA) have successfully deployed Fluxer in self-hosted monolith mode (Docker Compose, refactor branch) and configured SSO via the admin panel with a Zitadel OIDC provider. SSO login does not work at all — there are 5 interacting bugs that prevent it from functioning. I've documented each below with symptoms and root cause analysis.

Environment

  • Branch: refactor
  • Deployment: Docker Compose, monolith mode, built from source
  • Reverse proxy: Traefik with Cloudflare-terminated TLS
  • OIDC provider: Zitadel (but these bugs are provider-agnostic)
  • SSO configured via: Admin panel at /admin (instance settings)

Bug 1: SSO callback route not in standalone route list — infinite login loop

Symptom: After authenticating with the OIDC provider, the browser redirects to /auth/sso/callback?code=...&state=... but immediately redirects back to /login, creating an infinite loop. Root cause: fluxer_app/src/router/components/RootComponent.tsx has an isStandaloneRoute check that determines which routes render without requiring authentication. The SSO callback path (/auth/sso/callback) is not in this list, so the router treats the unauthenticated user returning from the OIDC provider as needing to log in, redirecting them away before the callback can exchange the authorization code. Expected: /auth/sso/ paths should be in the standalone route list alongside other auth-related routes like CONNECTION_CALLBACK.

Bug 2: Token exchange sends empty/malformed body — "grant_type missing" 400 error

Symptom: Even if Bug 1 is fixed, the OIDC token exchange fails with HTTP 400. The OIDC provider responds with "grant_type missing" or similar. Looking at the request, the body is "{}" (a JSON-stringified empty object) despite being sent with Content-Type: application/x-www-form-urlencoded. Root cause: In packages/api/src/auth/services/SsoService.tsx, the exchangeCode() method constructs a URLSearchParams object with grant_type, code, redirect_uri, client_id, and code_verifier, then passes it as the request body. However, FetchUtils.resolveRequestBody() does not have a handler for URLSearchParams instances — it falls through to JSON.stringify(), which serializes URLSearchParams as "{}". The OIDC provider receives an empty body. Expected: The URLSearchParams should be converted to a URL-encoded string (via .toString()) before being passed to the fetch utility, or FetchUtils.resolveRequestBody() should handle URLSearchParams instances.

Bug 3: Client secret never loaded for token exchange — "empty client secret" 400 error

Symptom: Even if Bug 2 is fixed, the token exchange still fails. The OIDC provider responds with "empty client secret" or "invalid_client". No Authorization header is sent with the token request. Root cause: In packages/api/src/auth/services/SsoService.tsx, the getResolvedConfig() method calls getSsoConfig() without passing {includeSecret: true}. The getSsoConfig() method defaults to excluding the client secret from the returned config object, so config.clientSecret is always undefined. When exchangeCode() tries to build the Basic auth header, it gets undefined and no Authorization header is sent. Expected: getResolvedConfig() should call getSsoConfig({includeSecret: true}) since the resolved config is used for server-side token exchange which requires the client secret.

Bug 4: 30-second timeout masks the actual error message

Symptom: When SSO fails (due to any of the above bugs), the SsoCallbackPage briefly shows the real error message (e.g., "grant_type missing"), but after 30 seconds it gets overwritten with "SSO sign-in timed out." This makes debugging very difficult because the useful error is only visible for a few seconds. Root cause: In fluxer_app/src/components/pages/SsoCallbackPage.tsx, a 30-second timeout is set via setTimeout. The timeout is only cleared in the React useEffect cleanup function. When the SSO complete request fails quickly (e.g., 400 error), the catch block sets the real error message, but the still-pending timeout fires later and overwrites it with the generic timeout message. The clearTimeout needs to be called in the error and success paths of the async function, not just in the React cleanup.

Bug 5: SSO users classified as "unclaimed" — all features restricted

Symptom: After fixing Bugs 1-4, SSO login finally works. However, the SSO user cannot do anything: profile updates fail with "Unclaimed Accounts can only set email via token", guild invite toggling fails, messages may be restricted, and many other features are blocked. Root cause: In packages/api/src/models/User.tsx, the isUnclaimedAccount() method returns true when passwordHash === null && !isBot. SSO users are provisioned in SsoService.provisionUserFromClaims() with password_hash: null (they authenticate via OIDC, not password). This means every SSO user is incorrectly classified as an "unclaimed" (demo/preview) account. The isUnclaimedAccount() check is used in ~15 places across the codebase to restrict features. SSO-provisioned users receive sso and sso:{providerId} traits at creation time, so the fix would be to also check that the user doesn't have the sso trait before classifying them as unclaimed. Expected: isUnclaimedAccount() should exclude users with SSO traits, since they are fully authenticated users who simply don't have a local password.

Steps to Reproduce

  1. Clone refactor branch, build from source, deploy in monolith mode
  2. Configure SSO via admin panel (/admin > instance settings) with any OIDC provider
  3. Set issuer URL, client ID, client secret (endpoints auto-discovered from .well-known/openid-configuration)
  4. Try to log in via SSO — observe infinite redirect loop (Bug 1)
  5. Fix Bug 1, retry — observe 400 error with "grant_type missing" (Bug 2)
  6. Fix Bug 2, retry — observe 400 error with "empty client secret" (Bug 3)
  7. Fix Bugs 1-3, retry — SSO login succeeds but error messages are masked by timeout (Bug 4)
  8. SSO user can log in but cannot use most features (Bug 5)

Impact

SSO/OIDC is a critical feature for self-hosted deployments where organizations want centralized authentication. Currently it is completely non-functional — 5 bugs must be fixed before a single SSO login can succeed.

Reference

Full deployment guide with all 20 gotchas: https://gist.github.com/PaulMColeman/e7ef82e05035b24300d2ea1954527f10