126 lines
5.6 KiB
Markdown
126 lines
5.6 KiB
Markdown
# 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).
|