Files
docs/sso-migration.md
T

5.6 KiB

SSO migration plan — proxyAuth → OIDC

Migrating our two custom apps (member-dashboard, admin-panel) off the legacy proxyAuth addon to OpenID Connect, matching the rest of the stack and Cloudron 10's direction.

Status: planned, not started. Both apps work today on proxyAuth; this is future-proofing done deliberately while the membership is small.

Why

  • Cloudron 10 is moving packages from LDAP/proxyAuth → OIDC so that "only Cloudron sees the password." Our two custom apps are on the legacy path (proxyAuth); the portal and all App Store apps are already OIDC.
  • proxyAuth is the minority path now (11 packages vs 93 on OIDC). Migrating removes a dependency on a mechanism Cloudron is deprioritizing.

The core technical fact (shapes the whole plan)

proxyAuth and OIDC are fundamentally different auth models — this is a real code change, not a manifest swap.

  • proxyAuth puts an auth wall in front of the app. Cloudron handles login and injects the authenticated user's username via a header (X-Remote-User / X-Forwarded-User). The app just reads the header.
  • OIDC does not inject a header. Cloudron exports CLOUDRON_OIDC_* env vars (CLOUDRON_OIDC_ISSUER, ..._CLIENT_ID, ..._CLIENT_SECRET, ..._DISCOVERY_URL, ..._TOKEN_ENDPOINT, ..._PROFILE_ENDPOINT, etc.) and the app must implement the full authorization-code flow:
    1. redirect to CLOUDRON_OIDC_AUTH_ENDPOINT,
    2. receive the code at loginRedirectUri (e.g. /auth/openid/callback),
    3. exchange it at CLOUDRON_OIDC_TOKEN_ENDPOINT,
    4. validate the ID token (RS256, keys from CLOUDRON_OIDC_KEYS_ENDPOINT),
    5. establish an app-side session cookie.

So each app needs an OIDC client implementation (redirect, callback, token exchange, session) where today it only reads a header.

Current state of the two apps

Both read identity the same way — a small header-union helper:

def get_identity(request):
    for header in ("x-remote-user", "x-forwarded-user", "x-auth-request-user",
                   "x-auth-request-email", "x-forwarded-email"):
        val = request.headers.get(header)
        if val:
            return val.strip()
    return ""
  • member-dashboard: passes that identity (a Cloudron username, e.g. ntnsndr) to the portal's /broker/* as X-Member-User. The portal resolves username → email (it holds the Cloudron admin token).
  • admin-panel: checks the username against an ADMIN_USERNAMES allowlist, then calls the portal's /admin/* with X-Admin-Token.

Open question to resolve first (before code)

The portal's broker resolves username → email, but OIDC's userinfo / ID token may return the email directly (or the username, or both). Which claim Cloudron's OIDC profile endpoint exposes determines whether the dashboard keeps sending X-Member-User (username) or switches to X-Member-Email (email). Both work today (the broker accepts either and normalizes to email), but we should confirm what the ID token actually contains rather than assume.

Action: once the OIDC client is wired in a test app, log the decoded ID token claims and note whether email, preferred_username, and/or sub are present. Then pick the identity field accordingly.

Migration steps

Phase 1 — add OIDC plumbing to each app (no manifest change yet)

For each app, add a small OIDC client that:

  1. Reads CLOUDRON_OIDC_* env vars.
  2. On an unauthenticated request, redirects to the auth endpoint.
  3. Handles the callback (loginRedirectUri) — exchange code for ID token, validate signature + issuer + audience.
  4. Sets a signed session cookie; reads it on subsequent requests.
  5. Replaces get_identity() with "read identity from the validated session" (falling back to the header during the transition).

The dashboard's /logout link and the panel's ADMIN_USERNAMES allowlist stay; only the identity source changes.

Phase 2 — swap the manifest addon

Replace "proxyAuth": {} with "oidc": { "loginRedirectUri": "/auth/openid/callback", "logoutRedirectUri": "/" } in each CloudronManifest.json, then redeploy.

Phase 3 — verify

  • A member can log into dashboard.inference.coop and see their usage/keys.
  • A non-admin is denied on panel.inference.coop; an admin gets in.
  • The broker still resolves identity correctly (usage/keys load).
  • Logout works and re-presents the login.

Phase 4 — remove the header fallback

Once OIDC is verified, drop the X-Remote-User/X-Forwarded-User fallback so the apps no longer trust a spoofable header (a header the app now controls is fine internally, but a client-supplied X-Remote-User would otherwise be trusted if the wall were ever bypassed).

Rollback

Until Phase 4, the header fallback means we can revert the manifest to proxyAuth and redeploy with a single change. Keep that path open until verification passes.

Notes / gotchas

  • The portal itself is already OIDC (via the oidc addon) but does not actually implement a client — its oidc block has loginRedirectUri: "/", and it's effectively used for the admin/broker token surfaces, not member login. Don't pattern-match on the portal; the dashboard/panel need a real client implementation.
  • admin-panel's allowlist is orthogonal to SSO — ADMIN_USERNAMES must still gate which authenticated users reach the panel. OIDC only changes how we learn who the user is.
  • Do the dashboard first, the panel second (the dashboard has more members relying on it, so it validates the pattern; the panel is admin-only and lower traffic, so a bug there is less visible).