# 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: ```python 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).