Add opt-in OIDC sign-in with session cookie and roles
Auth is enabled only when issuer, client id and client secret are all set; otherwise the dashboard stays an open LAN dashboard. Provider endpoints are discovered from <issuer>/.well-known/openid-configuration via Authlib, so no provider-specific code is needed. - auth.py: /auth/login, /auth/authorize, /auth/callback, /auth/logout (RP-initiated logout when the issuer advertises end_session_endpoint) - users.py: atomic, lock-guarded data/users.json keyed by the OIDC sub - role resolution: admin claim wins, else first user bootstraps as admin, else viewer - api.py/app.py: session middleware and require_login/require_admin gating on every route except /health, /auth/* and /static - config.py/main.py: UWUDASH_OIDC_* / UWUDASH_SESSION_* env vars plus --oidc-issuer and --public-url flags - templates/login.html, static app.js/styles.css: sign-in page and settings UI
This commit is contained in:
1 parent
2e78143812
commit
3187b8cf05
15 files changed
+1070
-41
No files matched your search
@@ -31,6 +31,8 @@ uv run uwudash-mk3 --reload
|
||||
| `--port` | `8000` | bind port |
|
||||
| `--data-dir` | `data` | directory holding `services.json` |
|
||||
| `--services-file` | `<data-dir>/services.json` | explicit store path |
|
||||
| `--oidc-issuer` | unset | OIDC issuer; enables sign-in together with the client env vars |
|
||||
| `--public-url` | request origin | external base URL used for the OIDC `redirect_uri` |
|
||||
| `--reload` | off | uvicorn reload on source changes |
|
||||
|
||||
### Environment variables
|
||||
@@ -43,9 +45,76 @@ uv run uwudash-mk3 --reload
|
||||
| `UWUDASH_SERVICES_FILE` | `data/services.json` | services store path |
|
||||
| `UWUDASH_PROXY_TIMEOUT` | `30.0` | upstream request timeout (seconds) |
|
||||
| `UWUDASH_STRIP_FRAME_OPTIONS` | `true` | strip `X-Frame-Options` and CSP `frame-ancestors` from proxied responses |
|
||||
| `UWUDASH_OIDC_ISSUER` | unset | OIDC issuer, e.g. `https://id.example.com` (auth stays off unless issuer, client id and secret are all set) |
|
||||
| `UWUDASH_OIDC_CLIENT_ID` | unset | OIDC client id |
|
||||
| `UWUDASH_OIDC_CLIENT_SECRET` | unset | OIDC client secret |
|
||||
| `UWUDASH_OIDC_SCOPE` | `openid email profile` | requested scope |
|
||||
| `UWUDASH_OIDC_ADMIN_CLAIM` | unset | claim to inspect for admin group membership, e.g. `groups` |
|
||||
| `UWUDASH_OIDC_ADMIN_VALUES` | `admin` | comma-separated claim values that grant `admin` |
|
||||
| `UWUDASH_SESSION_SECRET` | random per start | signed-session cookie secret (set it, or sessions die on restart) |
|
||||
| `UWUDASH_SESSION_TTL` | `3600` | session lifetime in seconds |
|
||||
| `UWUDASH_USERS_FILE` | `<data-dir>/users.json` | user store path |
|
||||
| `UWUDASH_PUBLIC_URL` | request origin | external base URL for the OIDC `redirect_uri` |
|
||||
|
||||
CLI flags override environment variables.
|
||||
|
||||
## Authentication (opt-in)
|
||||
|
||||
With no issuer set the dashboard is open, exactly as before: no `/auth/*` routes
|
||||
are registered and every API route is reachable from the LAN.
|
||||
|
||||
Setting `UWUDASH_OIDC_ISSUER` + `UWUDASH_OIDC_CLIENT_ID` + `UWUDASH_OIDC_CLIENT_SECRET`
|
||||
turns sign-in on. The provider is discovered from
|
||||
`<issuer>/.well-known/openid-configuration` (Authlib reads the authorize, token,
|
||||
JWKS and `end_session_endpoint` URLs), so nothing here is provider-specific -
|
||||
Keycloak, Authentik, Auth0 and the like all work.
|
||||
|
||||
```bash
|
||||
export UWUDASH_OIDC_ISSUER=https://id.example.com
|
||||
export UWUDASH_OIDC_CLIENT_ID=uwudash
|
||||
export UWUDASH_OIDC_CLIENT_SECRET=...
|
||||
export UWUDASH_OIDC_ADMIN_CLAIM=groups
|
||||
export UWUDASH_OIDC_ADMIN_VALUES=dash-admin
|
||||
export UWUDASH_SESSION_SECRET=$(openssl rand -hex 32)
|
||||
uv run uwudash-mk3 --public-url https://dash.example.com
|
||||
```
|
||||
|
||||
Flow: `/` with no session redirects to `/auth/login` → `Sign in` →
|
||||
`/auth/authorize` → the provider → `/auth/callback` validates the `id_token`
|
||||
against the issuer's JWKS → a signed `session` cookie (`HttpOnly`, `SameSite=lax`,
|
||||
`max_age=UWUDASH_SESSION_TTL`) holds the user id → `/`.
|
||||
|
||||
Once auth is on, every route except `/health`, `/auth/*`, `/static` and FastAPI's
|
||||
own `/docs` / `/openapi.json` requires a session: unauthenticated requests get
|
||||
`401` (API) or a redirect to `/auth/login` (the HTML page).
|
||||
|
||||
### Roles
|
||||
|
||||
| Role | Can |
|
||||
| --- | --- |
|
||||
| `admin` | everything a viewer can, plus create/update/delete services and list or change user roles |
|
||||
| `viewer` | read the service list, open services through the proxy, see their own profile |
|
||||
|
||||
The role is decided at **first** login and never changed by logging in again -
|
||||
promotions and demotions go through `PATCH /api/users/{id}` (or the Settings page):
|
||||
|
||||
1. if `UWUDASH_OIDC_ADMIN_CLAIM` is set and the token's claim (scalar or list)
|
||||
contains one of `UWUDASH_OIDC_ADMIN_VALUES` → `admin`;
|
||||
2. otherwise, if the user store is empty → `admin` (the first person to log in
|
||||
bootstraps the dashboard);
|
||||
3. otherwise → `viewer`.
|
||||
|
||||
Users are stored in `data/users.json` (same atomic-write format as
|
||||
`data/services.json`), keyed by the OIDC `sub`; `email` and `name` are refreshed
|
||||
on each login.
|
||||
|
||||
### Signing out
|
||||
|
||||
`/auth/logout` (GET or POST) always clears the local session. If the issuer
|
||||
advertises an `end_session_endpoint`, it then does RP-initiated logout with the
|
||||
saved `id_token` and returns to `/auth/login` afterwards; otherwise it returns to
|
||||
`/auth/login` directly.
|
||||
|
||||
## Services
|
||||
|
||||
Services live in `data/services.json`:
|
||||
@@ -88,14 +157,23 @@ Only the upstream **origin** (`scheme://host:port`) is mapped onto the dashboard
|
||||
So a service with `url = http://internal-host:8080/sub/page` is served at
|
||||
`/proxy/<id>/sub/page`, and any link the upstream page emits to
|
||||
`http://internal-host:8080/...` or `//internal-host:8080/...` is rewritten to
|
||||
`/proxy/<id>/...`. Relative links need no rewriting.
|
||||
`/proxy/<id>/...`. Root-relative links (`/foo/bar`) are pulled back onto the
|
||||
proxy prefix too - they would otherwise resolve against the dashboard and 404.
|
||||
Links relative to the current page (`foo/bar`) need no rewriting.
|
||||
|
||||
Details:
|
||||
|
||||
- `Location` headers on `3xx` responses are rewritten back onto the proxy prefix;
|
||||
redirects are **not** followed, so the browser stays on the proxy.
|
||||
- `text/html` responses are buffered and rewritten; everything else (JSON, CSS,
|
||||
images, downloads) is streamed through unchanged.
|
||||
- `text/html` responses are buffered and rewritten. `application/json` is
|
||||
rewritten as well, but only values that start with the service's own base path:
|
||||
single-page apps read their base path from an API config (Sonarr's
|
||||
`initialize.json` returns `urlBase`/`apiRoot`), while plain data fields such as
|
||||
filesystem paths are left alone. Everything else (CSS, images, downloads) is
|
||||
streamed through unchanged.
|
||||
- Upstream compression (`gzip`, `deflate`, `br`) is decoded by httpx, so
|
||||
`Content-Encoding` and `Content-Length` are never forwarded - the body the
|
||||
browser receives is always uncompressed and header-consistent.
|
||||
- `X-Frame-Options` and CSP `frame-ancestors` are stripped from proxied responses
|
||||
so the iframe can render (disable with `UWUDASH_STRIP_FRAME_OPTIONS=false`).
|
||||
- Hop-by-hop request/response headers are dropped; per-service `headers` are merged
|
||||
@@ -113,7 +191,14 @@ Details:
|
||||
| `PATCH` / `PUT` | `/api/services/{id}` | update |
|
||||
| `DELETE` | `/api/services/{id}` | delete |
|
||||
| `GET` | `/api/services/{id}/health` | upstream reachability check |
|
||||
| `GET` | `/api/config` | effective config |
|
||||
| `GET` | `/api/config` | effective config (never includes the OIDC secret) |
|
||||
| `GET` | `/api/me` | current user (only when auth is enabled) |
|
||||
| `GET` | `/api/users` | list users - admin only |
|
||||
| `PATCH` | `/api/users/{id}` | change a role - admin only |
|
||||
| `GET` | `/auth/login` | sign-in page |
|
||||
| `GET` | `/auth/authorize` | start the OIDC flow |
|
||||
| `GET` | `/auth/callback` | provider redirect target |
|
||||
| `GET` / `POST` | `/auth/logout` | clear the session, then RP-initiated logout |
|
||||
| `GET` | `/health` | dashboard health |
|
||||
| `ANY` | `/proxy/{id}` and `/proxy/{id}/{path}` | reverse proxy |
|
||||
|
||||
@@ -122,19 +207,28 @@ Details:
|
||||
```
|
||||
src/uwudash_mk3/
|
||||
config.py env-driven Config dataclass
|
||||
models.py Service / ServiceInput / ServicePatch + URL helpers
|
||||
models.py Service / ServiceInput / ServicePatch / User / UserPatch + URL helpers
|
||||
store.py JSON file store (atomic writes, lock)
|
||||
users.py JSON user store (same pattern as store.py)
|
||||
auth.py OIDC client, login/callback/logout, role dependencies
|
||||
proxy.py reverse proxy: header filtering, HTML/redirect rewriting
|
||||
api.py /api routes
|
||||
app.py FastAPI factory, static mount, proxy routes
|
||||
app.py FastAPI factory, session middleware, static mount, proxy routes
|
||||
main.py CLI entrypoint
|
||||
templates/ index.html
|
||||
templates/ index.html, login.html
|
||||
static/ styles.css, app.js
|
||||
```
|
||||
|
||||
## Limitations
|
||||
|
||||
- No authentication on the dashboard itself - it is meant for a trusted LAN.
|
||||
- No WebSocket proxying.
|
||||
- Authentication is opt-in OIDC (see above). Without an issuer the dashboard is
|
||||
still open by design - it is meant for a trusted LAN. Sign-in gates the
|
||||
dashboard, not the upstream services: the proxy does not forward the dashboard
|
||||
session, so each service still needs its own login or a per-service `headers`
|
||||
token.
|
||||
- Roles are decided at first login and only change through the admin API; there
|
||||
is no group sync after that, and no per-service permissions.
|
||||
- No WebSocket proxying. Apps that use WebSockets for live updates (Sonarr's
|
||||
SignalR) fall back to SSE/long-polling through the proxy.
|
||||
- Upstream cookies work, but they are scoped to the dashboard origin, so
|
||||
cross-service cookie sharing is possible if two services share a proxy host.
|
||||
Reference in new issue
Block a user