HISTORICAL campaign record for Plenum issue #373 — adding authentication to the web UI (port 8099) and the MCP server (port 9099). #373 SHIPPED (Phases 1-5), and OIDC single sign-on (#464) shipped as a follow-up; both ports are no longer unauthenticated by default. For CURRENT auth behavior, load `plenum-architecture-contract` (component map: `auth.py`/`oidc.py`) or read `docs/auth.md` directly — do NOT use this file to design or re-plan auth, it is design-rationale archaeology only. Load thi...
Scanned 9/11/2026
Install to Claude Code
npx -y skills add dhruvb14/smart-thermostat-with-vents --skill plenum-auth-campaign --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Plenum Auth Campaign?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/dhruvb14-plenum-auth-campaign)More formats (shields.io, HTML) on the badges page.
---
name: plenum-auth-campaign
description: >-
HISTORICAL campaign record for Plenum issue #373 — adding authentication to
the web UI (port 8099) and the MCP server (port 9099). #373 SHIPPED (Phases
1-5), and OIDC single sign-on (#464) shipped as a follow-up; both ports are
no longer unauthenticated by default. For CURRENT auth behavior, load
`plenum-architecture-contract` (component map: `auth.py`/`oidc.py`) or read
`docs/auth.md` directly — do NOT use this file to design or re-plan auth,
it is design-rationale archaeology only. Load this when you need to know WHY
a shipped auth decision was made, what alternatives were rejected and why,
or what (if anything) diverged between the plan and what actually shipped.
NOT for general change-gating (load plenum-change-control) or the shipped
MCP transport internals (plenum-architecture-contract).
---
# Plenum Auth Campaign (issue #373) — SHIPPED, historical record
> ## ✅ #373 SHIPPED — this file is now a historical design record, not a plan
>
> **Re-verified 2026-09-01 against v0.35.0.** Issue #373 (auth for the web UI
> and MCP server) is done, not a candidate. `smart_vent/backend/auth.py` and
> `smart_vent/backend/oidc.py` exist and are wired in; `config.yaml` ships
> `require_auth` (default **true**) plus the `oidc_*` options. OIDC single
> sign-on (#464) also shipped as a follow-up, closing the two gaps in the
> #373 password path (no MFA; requires a Supervisor) — see `oidc.py`'s module
> docstring. **`docs/auth.md` is the current reference for shipped behavior**
> (including its "Operational notes & known limitations" section for what's
> genuinely still open — mainly no login rate-limiting yet); the component map
> in `plenum-architecture-contract` §1 and its Known-weak-points §4 carry the
> short version. Do not use this skill to re-plan, re-litigate, or "start"
> #373 — an agent that does so is redoing shipped work.
>
> **Plan-vs-shipped, at a glance** (from a quick diff against the "Locked
> design" section below, not an exhaustive phase-by-phase audit):
> - **UI = Solution A (trust-boundary split)** — shipped as planned: ingress
> is transport-classified (peer IP == Supervisor **AND**
> `X-Remote-User-Id`) and auto-trusted; direct port requires a credential.
> - **A.1 (direct-port credential) = Supervisor `/auth`** — shipped as
> planned, plus the #464 OIDC path that the original campaign only sketched
> as "Solution B". OIDC ended up scoped narrower than the original Solution B
> (web UI only, replaces the password route when configured) rather than a
> general HA-delegated OAuth story.
> - **A.2 (session model) = HttpOnly/Secure/SameSite=Strict cookie** — shipped
> as planned.
> - **MCP (Solution C) = minted opaque bearer tokens, scoped read/write/destructive,
> enforced at both 9099 and the REST loopback** — shipped as planned, per
> `docs/auth.md`'s MCP section and `plenum-architecture-contract`.
> - **Backwards-compat default** — the campaign's OPEN DECISION recommended
> defaulting `require_auth` **false** for one release then flipping. What
> shipped instead defaults it **true** from the start (see the "Locked
> decisions" status box below, which superseded that open decision before
> implementation began).
>
> The rest of this file — Phase 0 recon, the solution menu, the numbered
> phases, the validation matrix — is kept for its design rationale (why the
> peer-IP check, why bearer-not-cookie for MCP, why hashed-not-plaintext
> tokens) and is still worth reading if you need to understand a shipped
> decision. Treat every "OPEN DECISION" tag below as **resolved** by the
> "Locked decisions" status box just under this one; treat every EXPECTED/
> curl-matrix code table as describing pre-#373 baseline behavior, not
> today's.
> ## ⚡ Last in-flight handoff before shipping (2026-07-14, branch `feature/auth-373`, PR #463)
>
> **Phases 1–5 are now implemented on the branch** (PR #463). The design was
> locked at the decision gate; the implementation followed it. Remaining work is
> real-Supervisor verification (impossible in dev — see the constraint note) and
> whatever review turns up. Full detail in the "Locked design" and "Numbered
> implementation phases" sections below — the short version:
>
> **Implemented (Phases 2–5):**
> - **Phase 2** — `auth_middleware` wired between `security_headers` and `csrf`,
> gated by `require_auth`; stateless HMAC-signed session cookies (`session.py`,
> secret persisted outside `app.db`); `require_auth` config.yaml + run.sh + env;
> `/api/auth/status` probe. Flag-off pass-through preserves legacy behavior.
> - **Phase 3** — `POST /api/auth/login` (Supervisor `/auth` → HttpOnly session
> cookie) + `/logout`; React login screen + auth gate + logout control (all UI
> gated on `require_auth`, so the auth-off E2E stack and goldens are unchanged).
> - **Phase 4** — MCP minted bearer tokens (`mcp_tokens` table, SHA-256-hashed,
> scoped read/write/destructive); dual-layer enforcement (bearer authn at 9099,
> scope authz at the REST loopback via `X-Plenum-Scope`); `/api/mcp/tokens`
> CRUD + token-management UI; `/api/backup|restore|restart` + token management
> are destructive-scoped.
> - **Phase 5** — `docs/auth.md` (threat model + matrix), `docs/mcp.md` updated,
> README. `docker-compose.test.yml` sets `REQUIRE_AUTH=false` so the E2E stack
> (no Supervisor → all-direct) keeps working; auth is covered by backend tests.
>
> **Verification:** backend suite green (1212 passed, ~94.5%; note an occasional
> coverage.py+asyncio measurement flake that a re-run clears), frontend 375
> passed, ruff + mypy clean. NOT verified against a live Supervisor.
>
> Historical detail below (kept for provenance):
>
> **Locked decisions (supersede every OPEN DECISION tag below):**
> - **UI = Solution A** (trust-boundary split). Ingress is *always* trusted and
> **must never be broken**; we do NOT care about the ingress user's role (any
> HA user via ingress = trusted). Only **direct-port** + **MCP** get the new
> auth.
> - **Ingress detection = unspoofable transport check** — `auth.is_ingress_request`:
> TCP peer IP == Supervisor **AND** `X-Remote-User-Id` header present. Peer IP
> is the load-bearing signal (the header alone is forgeable by any sibling
> add-on on the flat, unisolated hassio network). Verified from HA Core +
> Supervisor source and the "App security" dev doc — see updated Phase-0.3/0.4
> below. **NOT yet verified against a live Supervisor** (no HA in dev).
> - **Direct-port credential (A.1) = HA `/auth` backend** (`auth_api: true`,
> `POST http://supervisor/auth`). No new password store.
> - **Session (A.2) = HttpOnly + Secure + SameSite=Strict cookie** for the
> browser; bearer tokens only for programmatic/MCP.
> - **MCP (Solution C) = minted opaque bearer tokens** in the Settings UI,
> hashed at rest, scoped `read`/`write`/`destructive`, enforced at BOTH 9099
> and the loopback REST layer; still behind the `mcp_enabled` 503 gate.
> - **Rollout = `require_auth` config option, default TRUE.** Because ingress is
> always trusted, "default on" does NOT break HA-ingress users — it only forces
> auth on external/direct ports + MCP. Setting it false restores legacy open
> behavior (escape hatch).
>
> **Progress against the numbered phases below:**
> - **Phase 1 (Decision gate): CLEARED** — owner's locked choices above.
> - **Phase 2 (Auth middleware skeleton): PARTIAL** — the trust classifier and
> CSRF hardening landed, but the middleware is **not yet wired** into
> `middlewares=[...]`, so nothing returns 401 yet (running instance/MCP
> unaffected; no lock-out before the login path exists). Remaining Phase-2
> work: wire the middleware behind `require_auth`, and unit-test the flag-off
> pass-through.
> - **Phases 3–5: NOT STARTED.**
>
> **Commits on `feature/auth-373`:**
> - `f495a12` — CSRF middleware baseline (pre-existing WIP, preserved).
> - `3f9e8f8` — `backend/auth.py` (`is_ingress_request` + `resolve_supervisor_ip`)
> with the spoof-rejection test matrix (`backend/tests/test_auth.py`, 100% cov);
> CSRF hardening: the forgeable static `X-Plenum-Source: mcp` loopback marker
> replaced by a per-process random token (`app["internal_token"]`, constant-time
> compared, threaded to the MCP dispatcher); "CSRF-only, never auth" boundary
> comment. Full backend suite green (1137 passed, 94.27%).
>
> **Dev-environment constraint (important for the next session):** local dev is
> NOT attached to Home Assistant — there is no Supervisor, so **ingress paths
> cannot be exercised here at all**. `resolve_supervisor_ip()` returns None
> locally and every request is treated as direct. You can run the app in Docker
> with env vars for direct-port/MCP testing, but ingress-trust + the `/auth`
> login flow can only be truly verified on a real HAOS/supervised install (or in
> CI with a Supervisor stub). Do not claim ingress behavior is verified from a
> local run.
>
> **Next steps (skill numbering):** finish **Phase 2** (wire the middleware
> behind `require_auth`, flag-off pass-through test) → **Phase 3** (direct-port
> login: HA `/auth` → HttpOnly cookie; `require_auth` config + run.sh + UI
> parity) → **Phase 4** (MCP minted tokens) → **Phase 5** (validation, docs,
> goldens). Carryover functional holes to fix along the way: reverse-proxy Host
> mismatch (CSRF Origin vs `request.host`; affects the owner's own
> `plenum.bhavsar.dev`) and live-confirm ingress WS carries an exempt header.
**This was a candidate campaign plan; it is now a shipped-feature record.**
Issue #373 shipped (see the status box at the top of this file) — every
**OPEN DECISION** tag below was resolved by the owner's locked choices before
implementation, and implementation itself is done, not in progress. The
"candidate plan" framing and the OPEN DECISION tags are kept verbatim below as
the historical record of what was considered and why; do not read them as
still-open questions, and do not route new "should we add auth" discussion
through this file — check `docs/auth.md` first, it may already answer it. Any
*new* auth work (e.g. login rate-limiting, the one item `docs/auth.md` flags
as still genuinely open) should route through **plenum-change-control** like
any other change, not restart this campaign's phase numbering. **NOTE
(2026-07-13):** the decision gate is now CLEARED — the status
box above records the owner's locked choices, which override the OPEN DECISION
tags throughout this document.
A Sonnet-class session should be able to advance this campaign phase by phase
without supervision by (a) running the Phase-0 recon to establish today's
behavior as ground truth, then (b) walking numbered phases, each with an entry
gate, exact steps, and EXPECTED observations with branch-outs.
## When NOT to use this skill
- **Understanding CURRENT auth behavior, or making a new auth change** → this
skill is historical rationale, not the live reference — load
**plenum-architecture-contract** (component map + Known-weak-points) or read
`docs/auth.md` directly.
- General "how do I gate a change / what CI must pass" → **plenum-change-control**.
- Shipped MCP transport internals / loopback design rationale → **plenum-architecture-contract** (owns the MCP loopback invariant) and **plenum-failure-archaeology** (the default-port-mapping revert #387).
- Where a config knob lives / add-a-knob parity → **plenum-config-and-flags**.
- How to write tests CI accepts / coverage gates → **plenum-validation-and-qa**.
- Running the stack to execute the curl matrix → **plenum-run-and-operate** / **plenum-build-and-env** (note: the matrix in this file describes pre-#373 baseline behavior, not today's expected codes).
## The problem in one paragraph
Both the web UI (`0.0.0.0:8099`) and the MCP server (`0.0.0.0:9099`) are
**completely unauthenticated**. Access control today is entirely "HA ingress sits
in front." If a user maps the raw host port (`config.yaml` `ports:` — both
default to `null` = unpublished), *anyone who can reach that port has full
read/write control*, including destructive endpoints (`/api/restart`,
`/api/restore`, `/api/backup` which downloads the whole SQLite DB). `docs/mcp.md`
already warns "The MCP endpoint is unauthenticated." #373 wants: ingress → HA
account auto-mapped to admin, bypass login; direct port → require auth (local
account or OAuth); MCP → OAuth 2.1 / bearer validation.
## Verified ground truth (as of 2026-07, v0.22.1) — read before designing
Everything here was verified against the repo. Line numbers drift — re-verify with
the commands in Provenance.
| Fact | Where | Note |
|---|---|---|
| ~~No auth middleware exists~~ **STALE as of `feature/auth-373`.** Middlewares are now `[security_headers_middleware, csrf_middleware]`; `backend/auth.py` holds the ingress classifier but is **not yet in the middleware chain**. | `smart_vent/backend/main.py` | The auth middleware still needs wiring (Phase 2). Insert it between `security_headers` and `csrf` so 401s still get security headers and auth rejects before CSRF logic runs. |
| Web UI binds **`0.0.0.0:8099`** | `main.py` `web.TCPSite(runner, "0.0.0.0", PORT)`; `PORT=8099` | Both ingress traffic AND direct-port traffic land on this same listener. |
| MCP binds **`0.0.0.0:9099`** | `main.py` `_start_mcp_server` → `uvicorn.Config(host="0.0.0.0", port=MCP_PORT)`; `MCP_PORT=9099` | Separate ASGI (Starlette + uvicorn) process-in-process. |
| MCP dispatches back to REST over **loopback** `http://127.0.0.1:8099` | `main.py` `base_url`; `mcp_http.dispatch_tool` | So any MCP auth added at the ASGI layer is bypassed if 8099 is directly reachable. Auth must live at the REST layer too, not only at 9099. |
| MCP `/mcp` is gated by `mcp_enabled` toggle → returns **503** when off | `mcp_http.build_asgi_app` `handle_mcp`; `scheduler.get_mcp_enabled` | Default off (`system_settings` key `mcp_enabled` default `"0"`). |
| Both ports default **`null`** (unpublished) in the add-on manifest | `config.yaml` `ports: {8099/tcp: null, 9099/tcp: null}` | The default-mapped-port regression was reverted in `c65d35d` (#387). Do NOT re-map by default. |
| Add-on already declares **`auth_api: true`**, `hassio_api: true`, `homeassistant_api: true` | `config.yaml` | `auth_api: true` means the add-on may call the Supervisor `/auth` endpoint to validate HA usernames/passwords — enables a "log in with your HA account" flow without a new identity store. This is a big lever for #373. |
| `SUPERVISOR_TOKEN` is injected into the container | `run.sh` (logs presence/absence) | The add-on holds a supervisor token; it can call `http://supervisor/...`. |
| Destructive endpoints exist and are unprotected | `routes.py`: `POST /api/restart`, `POST /api/restore`, `GET /api/backup` | `/api/backup` streams the **entire app.db** to the caller — any secret stored in the DB is exfiltrated by one unauthenticated GET. |
| MCP exposes **100% of the REST surface** as tools | `mcp_openapi.build_tool_specs` (one tool per documented `/api/` op) | So "MCP scopes" == "REST endpoint scopes." There is no separate MCP-only capability set. |
| Temp helpers live in `backend/units.py` | `to_f`/`delta_to_f`/`from_f` (imported as `_to_f` etc in `routes.py`) | Older CLAUDE.md copies said `routes.py`; corrected 2026-07-05. Irrelevant to auth but noted so you trust the repo. |
| Settings-cog UI already toggles MCP | `frontend/src/App.tsx` (`McpContext`, `toggleMcp`), `api.ts` `setMcpEnabled` → `POST /api/system/mcp` | Any new auth toggle follows this exact pattern (100%-UI rule). |
**Formerly-UNVERIFIED items — now RESOLVED from HA Core + Supervisor source and
the "App security" dev doc (2026-07-13). Still not exercised against a live
Supervisor (no HA in dev) — confirm with one ingress hit vs one direct hit
before trusting in production, but the mechanism is settled:**
- **Ingress reaches the add-on from the Supervisor container**, which holds a
fixed address on the flat hassio Docker bridge (`supervisor/docker/network.py`
allocates the Supervisor at network index [2]; resolvable via the `supervisor`
hostname). The bridge has **no add-on isolation** — every add-on can open a
socket to every other add-on's port. So the peer IP is the one signal a
sibling container cannot forge; a source-based ingress detector IS viable.
- **Headers the Supervisor sets on a validated ingress request** (from
`supervisor/api/ingress.py` `_init_header`, names in `supervisor/const.py`):
`X-Remote-User-Id`, `X-Remote-User-Name`, `X-Remote-User-Display-Name`. It sets
them **only after** validating the ingress session cookie (`ingress_session`),
and it **strips any client-supplied copies** of those three before re-deriving
them — but only on the ingress path. On the **direct** port the client controls
all headers. HA Core additionally sets `X-Ingress-Path` + `X-Hass-Source:
core.ingress` (undocumented; the `X-Remote-User-Id` triplet is the documented
add-on-facing contract, so key on it). **`is_admin`/`is_owner` are NOT
forwarded** — ingress headers say *which* user, never their role. (Moot here:
owner decided any ingress user is trusted.)
- **The trust rule (implemented in `backend/auth.py`):** trusted-ingress ==
peer IP == Supervisor **AND** `X-Remote-User-Id` present. Peer IP proves the
hop is really the Supervisor; the header proves this request took the ingress
path (not a watchdog probe). **Header presence alone is NEVER sufficient** —
that is the #373 spoofing trap, forgeable by any sibling add-on.
- **Supervisor `/auth` (enabled by `auth_api: true`)** is HA's documented add-on
auth backend: `POST http://supervisor/auth` with the HA username+password
validates against the HA user store (dev doc "App security" → "Use Home
Assistant user backend"). Confirmed as the direct-port login mechanism (A.1);
exact success/failure codes still want a live check.
The remaining genuinely-unverifiable-in-dev items: the exact resolved Supervisor
IP, and whether the **ingress WebSocket** upgrade carries `X-Ingress-Path` (HA
Core proxies WS on a separate code path). Both need a real supervised install.
---
## Phase 0 — Recon: establish TODAY's behavior as ground truth
**Entry gate:** none. Always run this first. Do not trust the table above blindly —
reproduce it. Do not write a line of auth code until Phase 0 is green.
### 0.1 Confirm no auth exists (code)
```bash
cd /home/user/smart-thermostat-with-vents/smart_vent
grep -n "middlewares=" backend/main.py # expect only security_headers_middleware
grep -rni "authorization\|bearer\|login\|session\|cookie\|@requires_auth" backend/api/routes.py
```
EXPECTED: the second grep returns **nothing auth-related** (only HA WS `auth`
handshake matches live in `ha_client.py`, not routes). If you find an existing
auth decorator → someone started this work; stop and reconcile with that branch.
### 0.2 curl matrix against a running stack
Bring up the container stack (see plenum-run-and-operate / plenum-build-and-env for
how; the compose file is `docker-compose.test.yml`, image var `PLENUM_IMAGE`). Then
run the matrix. `$H = http://localhost:8099`, `$M = http://localhost:9099`.
| # | Command | EXPECTED **today** | If different → branch |
|---|---|---|---|
| 1 | `curl -s -o /dev/null -w '%{http_code}' $H/api/system/status` | `200` (open) | If `401/403` → auth already added; stop, reconcile. |
| 2 | `curl -s -o /dev/null -w '%{http_code}' $H/api/backup` | `200` (streams DB!) | Proves the exfil risk is live. |
| 3 | `curl -s -o /dev/null -w '%{http_code}' -X POST $H/api/restart` | `200`-ish (restarts app) | Run last / against a throwaway stack — it restarts. |
| 4 | `curl -s -o /dev/null -w '%{http_code}' -H 'X-Ingress-Path: /foo' -H 'X-Remote-User-Id: admin' $H/api/system/status` | `200`, **same as #1** | Confirms client-supplied ingress headers are neither required nor validated — they're ignored today, and would be *trusted* only if a naive impl reads them. This is the spoofing baseline. |
| 5 | MCP **off** (default): `curl -s -o /dev/null -w '%{http_code}' -X POST $M/mcp -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'` | `503` (from `handle_mcp` gate) | If `200` → MCP is enabled in this stack; toggle it off via `POST $H/api/system/mcp {"mcp_enabled":false}` and retry. |
| 6 | MCP **on** (toggle via UI or `curl -X POST $H/api/system/mcp -d '{"mcp_enabled":true}'`), repeat #5 | `200` + tool list, **no credential required** | Proves MCP is fully open when enabled. |
| 7 | MCP on, call a write tool (e.g. the generated tool for `POST /api/system/enabled`) | succeeds unauthenticated | Confirms destructive MCP access is open. |
Record the actual codes. This matrix is later reused as the **success criterion**:
after auth ships, every cell's expected code changes according to the chosen design
(see "Validation: the auth test matrix").
### 0.3 Ingress trust experiment (the crux of #373)
The whole design hinges on: *can the app tell an ingress request from a direct-port
request in a way an attacker on the direct port cannot forge?* You cannot answer
this from headers alone (the direct-port client controls its own headers). Verify
the **transport-level** distinguisher:
```bash
# In a real HAOS / supervised environment (NOT the plain compose stack, which has
# no supervisor/ingress). Inside the addon container, watch inbound connections:
# - Hit the UI THROUGH HA ingress (browser via HA sidebar), note source IP.
# - Hit the raw published port directly, note source IP.
# Compare: ingress traffic arrives from the supervisor's hassio-network address
# (typically 172.30.32.x); direct traffic arrives from the docker gateway / host.
```
EXPECTED: ingress connections originate from the internal supervisor address;
direct connections do not. **If and only if** this holds is a source-based ingress
detector viable. If they are indistinguishable (e.g. both appear as the gateway
IP) → **ingress auto-admin is infeasible; every request must present a credential**
(branch to Solution C / token-only). Mark the result and carry it forward — it
gates Solution A entirely.
Also confirm which headers ingress actually sets, from inside the addon, by logging
`dict(request.headers)` on one endpoint during an ingress hit vs a direct hit.
EXPECTED: ingress hit carries `X-Ingress-Path` (and possibly HA user headers);
direct hit carries whatever the client chose. **The header is only trustworthy if
the connection is already proven to be ingress by transport** — never the reverse.
### 0.4 Verify the Supervisor auth capability
`auth_api: true` is declared. Confirm what it buys you:
```bash
# Inside the addon container (has SUPERVISOR_TOKEN):
curl -s -X POST http://supervisor/auth \
-H "Authorization: Bearer $SUPERVISOR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"username":"<ha-user>","password":"<pw>"}' -o /dev/null -w '%{http_code}'
```
EXPECTED (UNVERIFIED): `200` on valid creds, `401` otherwise — this is HA's
documented add-on auth backend. If it works, "log in with your HA account" needs
**no new password store**. If it does not exist/behave as expected in the running
Supervisor version → local-account store becomes necessary (OPEN DECISION below).
**Phase 0 exit gate:** you can state, with evidence, (1) today's curl-matrix codes,
(2) whether ingress is transport-distinguishable from direct, (3) whether the
Supervisor `/auth` endpoint validates credentials. Only then proceed.
---
## Solution menu (ranked) — pick ONE at the Phase-1 gate
Each option is a full design path with mandatory deliverables. **OPEN DECISION:
the owner has not chosen.** Recommendation: **A + C together** (trust-split for the
UI, self-contained bearer for MCP), because it preserves the ingress-bypass UX #373
explicitly wants and satisfies the MCP authorization spec, without adding an
external IdP dependency.
### Solution A — Trust-boundary split (RECOMMENDED for the web UI)
Ingress requests (proven unspoofable per Phase 0.3) are auto-authenticated as
admin and bypass login. Direct-port requests require a credential (local account
**or** OAuth — see sub-decision). One aiohttp auth middleware in front of
`/api/*` classifies the request:
1. Is this connection ingress? (transport-level check from Phase 0.3 — source
address on the hassio network, **not** a client header). If yes → admin,
proceed.
2. Else require a valid session cookie or bearer token; 401 otherwise.
**Obligations:**
- **Threat-model deliverable** (write it into `docs/` as a feature page per the
100%-UI/docs discipline): enumerate every surface × credential state.
- **Spoofing analysis (hard requirement):** you MUST prove the ingress
distinguisher cannot be forged from the direct port. State exactly what the
middleware keys on and why a direct-port client cannot reproduce it. If it keys
on any client-supplied header → **rejected, this is the #373 spoofing trap.**
- Backwards-compat opt-in flag (see OPEN DECISION below).
**OPEN DECISION A.1 — direct-port credential type.** Recommendation: **local
account validated via the Supervisor `/auth` endpoint** (reuse HA identities, no
new password DB) if Phase 0.4 confirms it works; fall back to a local account
store only if it does not.
**OPEN DECISION A.2 — session model.** Recommendation: **HttpOnly, Secure,
SameSite=Strict session cookie** for the browser UI (not a bearer in JS — avoids
XSS token theft; CSP is already locked down in `main.py`). Bearer tokens are for
programmatic/MCP clients (Solution C).
### Solution B — HA-delegated OAuth (Indie Auth / HA as provider)
Direct-port users complete an OAuth flow against Home Assistant itself; the add-on
validates the resulting token. Heavier; introduces redirect-URI handling behind
ingress path rewriting (fragile — `X-Ingress-Path` mangling). **Obligations:** all
of A's, plus a documented redirect-URI story that survives ingress path prefixes.
Recommendation: only if the owner wants true browser SSO and A.1's Supervisor-auth
path proves unavailable.
### Solution C — Self-issued OAuth 2.1 Authorization Server for MCP (RECOMMENDED for MCP)
Per the MCP authorization spec, the MCP server validates a **bearer token** on
every `/mcp` request (in addition to the existing `mcp_enabled` 503 gate). Plenum
either (i) acts as a minimal OAuth 2.1 AS issuing tokens, or (ii) validates
opaque tokens minted in the UI ("Generate MCP token"). Recommendation for v1:
**(ii) — long-lived opaque bearer tokens minted in the settings UI**, hashed at
rest; it satisfies "bearer token validation" without a full AS, and can grow into
a real AS later.
**Obligations:**
- Enforce the token at **both** the ASGI layer (9099) **and** at the REST loopback
layer — because MCP dispatches to `127.0.0.1:8099` (see ground-truth table). If
you only guard 9099, anyone on 8099 bypasses MCP auth entirely.
- **Scopes** (OPEN DECISION C.1): map to `read` / `write` / `destructive`. Since
MCP tools == REST endpoints, classify each endpoint. Minimum: `destructive`
covers `POST /api/restart`, `POST /api/restore`, `GET /api/backup`. Recommend a
per-token scope grant, default `read`.
- **Interaction with the `mcp_enabled` toggle:** auth is *in addition to* the
toggle, never instead of it. Off → 503 regardless of token.
- **Token storage vs /api/backup (hard requirement):** `GET /api/backup` streams
the entire `app.db`. If tokens (or their hashes) live in the DB, a backup
download exfiltrates them. Either (a) store only a slow salted hash (argon2/
bcrypt) so a leaked backup isn't trivially usable, AND put `/api/backup` behind
`destructive` scope; or (b) store tokens outside the DB. **Do NOT store raw
secrets in SQLite.** This is called out explicitly in #373.
---
## Known WRONG paths — fenced off (do not "rediscover" these)
| Anti-pattern | Why it's wrong |
|---|---|
| **Gate by port number alone** (e.g. "8099 = trusted, others = untrusted") | Both the UI and MCP bind `0.0.0.0`, and ingress + direct traffic share port 8099. Port tells you nothing about trust. |
| **Trust client-supplied `X-Ingress-Path` / `X-Remote-User-Id` on the direct port** | The direct-port client sets its own headers. Phase 0.2 #4 shows they're free-form. Ingress detection MUST be transport-level (Phase 0.3) and unspoofable. |
| **Auth only in the SPA** (React route guards / hide the login) | The API is the trust boundary. curl bypasses the SPA entirely (Phase 0.2). Auth lives in the aiohttp middleware. |
| **Guard only the 9099 ASGI layer for MCP** | MCP dispatches to REST over loopback `127.0.0.1:8099`; a direct 8099 hit bypasses it. Enforce at the REST layer. |
| **Break existing open-port users silently** | Backwards-compat is an explicit open question in #373. Flipping on mandatory auth bricks anyone relying on the raw port. Ship behind an **opt-in/migration flag** (OPEN DECISION below), default preserving current behavior until the owner decides the default flips. |
| **Store raw tokens/passwords in `app.db`** | `/api/backup` downloads the DB. Hash at rest + scope-protect backup, or store outside the DB. |
| **Re-map ports to non-null by default** to "make auth reachable" | Reverted in `c65d35d`/#387 (see plenum-failure-archaeology). Ports stay `null` by default. |
**OPEN DECISION — backwards compatibility default.** Recommendation: introduce a
`require_auth` system flag (config.yaml option + `system_settings` key + UI
toggle). Default `false` for one release (behavior unchanged; log a loud
deprecation warning when the raw port is published without auth), flip default to
`true` in a subsequent major with a migration note. The owner decides the flip
timing.
---
## Numbered implementation phases
Each phase: **entry gate → steps → EXPECTED → branch-outs.** Do not skip a gate.
### Phase 1 — Decision gate (no code)
**Entry gate:** Phase 0 exit gate satisfied.
**Steps:** Present Phase-0 evidence + this solution menu to the owner. Get an
explicit choice for: the UI approach (A/B), the MCP approach (C variant), A.1
credential type, A.2 session model, C.1 scopes, and the backwards-compat default.
**EXPECTED:** owner selects. **Branch:** if owner defers → stop; this is a design
issue, not an implementation ticket. Do not proceed on assumption.
### Phase 2 — Auth middleware skeleton (UI, behind flag OFF)
**Entry gate:** Phase 1 decisions recorded; `require_auth` default = off.
**Steps:**
- Add an aiohttp middleware to the `middlewares=[...]` list in `main.py`, ordered
so it runs *before* handlers but composes with `security_headers_middleware`.
- When `require_auth` is off → pass through (no behavior change — this keeps
existing users working and keeps the curl matrix green).
- Implement the ingress transport-check helper (per Phase 0.3), unit-tested for
the spoof case: a request with forged `X-Ingress-*` headers but a non-ingress
source MUST classify as untrusted.
**EXPECTED:** full existing test suite + curl matrix unchanged (flag off).
**Branch:** any pre-existing test flips → the middleware is leaking behavior when
off; fix before continuing.
### Phase 3 — Credential path (direct port)
**Entry gate:** Phase 2 merged.
**Steps:** implement A.1 (Supervisor `/auth` login → session cookie, or local
store) + login endpoint + login page. Wire the session model (A.2). Add
`require_auth` as a `config.yaml` option **and** `run.sh` `bashio::config` read
(parity enforced by `test_addon_config.py`) **and** a UI toggle + helper text
(100%-UI rule). See plenum-config-and-flags for the add-a-knob checklist.
**EXPECTED:** with flag ON, direct-port unauthenticated requests → 401; ingress
requests → 200 (admin); valid login → 200.
**Branch:** ingress requests get 401 → your transport check is wrong or too strict;
revisit Phase 0.3 evidence.
### Phase 4 — MCP bearer validation (Solution C)
**Entry gate:** Phase 3 merged; C variant chosen.
**Steps:** enforce bearer at the ASGI `handle_mcp` (after the `mcp_enabled` 503
gate) AND at the REST loopback boundary. Mint/hash tokens; scope-check
`read`/`write`/`destructive`. Put `/api/backup`, `/api/restore`, `/api/restart`
behind `destructive`. Add token-management UI (mint/revoke/scope) — 100%-UI rule.
**EXPECTED:** MCP on + no token → 401; on + read token + write tool → 403; on +
write token → 200; off → 503 regardless.
**Branch:** a write tool succeeds with a read token → scope enforcement is at the
wrong layer (must be REST-side, since dispatch is loopback).
### Phase 5 — Validation & promotion
Route all of this through **plenum-change-control** (classification, CI gates,
reviewer evidence) and **plenum-validation-and-qa** (how to add tests CI accepts;
coverage gates — backend 93.9 as of 2026-07, full table in its §6,
re-verify). Do not restate those here.
- Backend: pytest integration tests for every matrix cell in both `require_auth`
states and both temperature units (Celsius-mode pattern lives in
plenum-validation-and-qa).
- E2E: a login round-trip; a spoofed-header-on-direct-port test that MUST 401.
- Docs: threat-model + auth feature page in `docs/`; update `docs/mcp.md` (remove
the "unauthenticated" warning once C lands); README/CHANGELOG per
plenum-docs-and-writing.
- Visual regression: any new login page / settings toggle regenerates goldens
(both F and C legs) — see plenum-ci-and-release.
---
## Validation: the auth test matrix (the measurable success criterion)
The campaign is "done" when this matrix passes as automated tests. Fill the
EXPECTED column at Phase 1 from the chosen design; the values below assume
A+C with `require_auth` ON.
| Surface | Credential state | EXPECTED code |
|---|---|---|
| `GET /api/system/status` via ingress | none (auto-admin) | 200 |
| `GET /api/system/status` direct | none | 401 |
| `GET /api/system/status` direct | valid session | 200 |
| `GET /api/system/status` direct | forged `X-Ingress-*` headers | **401** (spoof rejected) |
| `GET /api/backup` direct | non-destructive cred | 403 |
| `GET /api/backup` direct | destructive cred / admin | 200 |
| `POST /api/restart` direct | non-destructive cred | 403 |
| `POST /mcp` | `mcp_enabled` off | 503 (regardless of token) |
| `POST /mcp` tools/list | on, no bearer | 401 |
| `POST /mcp` read tool | on, read token | 200 |
| `POST /mcp` write tool | on, read token | 403 |
| `POST /mcp` write tool | on, write token | 200 |
| `POST /mcp` destructive tool | on, write (not destructive) token | 403 |
| Any surface | `require_auth` OFF (compat mode) | same as today's Phase-0 matrix |
The last row is the backwards-compat guarantee: with the flag off, the Phase-0
baseline codes must be reproduced exactly.
## Provenance and maintenance
Everything below this line, and the numbered-phases/solution-menu body above,
was written and last checked `(as of 2026-07, v0.22.1)` while #373 was still
in flight — kept verbatim as the historical record, so its EXPECTED/curl-code
tables describe **pre-shipment** behavior, not today's. The status box at the
top of this file is the part re-verified 2026-09-01 against v0.35.0; re-verify
*that* with:
- Auth shipped: `ls smart_vent/backend/auth.py smart_vent/backend/oidc.py` (both exist); `grep -n "require_auth" smart_vent/config.yaml` → `require_auth: true` (bool option, default true).
- Middleware wired (no longer a skeleton): `grep -n "middlewares=\|_resolve_require_auth\|auth_middleware" smart_vent/backend/main.py`.
- Current behavior + residual known-limitations: `docs/auth.md`, especially its "Operational notes & known limitations" section.
- OIDC follow-up (#464) shipped: `grep -n "oidc_" smart_vent/config.yaml`; `smart_vent/backend/oidc.py` module docstring.
The pre-shipment facts below are otherwise unchanged from the 2026-07 audit —
re-verify any of them the same way if you need pre-#373 history, but treat
them as archaeology, not current state:
- Bind addresses/ports: `grep -n "TCPSite\|uvicorn.Config\|PORT =\|MCP_PORT =" smart_vent/backend/main.py`.
- MCP loopback base URL: `grep -n "base_url" smart_vent/backend/main.py`.
- MCP 503 gate: `grep -n "503\|is_enabled" smart_vent/backend/mcp_http.py`.
- Ports default null: `grep -n "9099\|8099" smart_vent/config.yaml`; add-on auth caps: `grep -n "auth_api\|hassio_api\|homeassistant_api" smart_vent/config.yaml`.
- Destructive endpoints: `grep -n "api/restart\|api/backup\|api/restore" smart_vent/backend/api/routes.py` — now scope-gated, see `docs/auth.md`'s MCP section.
- MCP toggle UI/API: `grep -n "mcp\|Mcp" smart_vent/frontend/src/App.tsx smart_vent/frontend/src/api.ts`.
- Coverage gates (drift): backend in `smart_vent/pyproject.toml`; frontend in `smart_vent/frontend/vite.config.ts`.
- **Formerly-UNVERIFIED items** (Supervisor `/auth` behavior, ingress source IP, exact ingress headers): `docs/auth.md`'s "Operational notes & known limitations" confirms these are *still* not exercised against a live Supervisor even post-ship — the mechanism is implemented and unit/integration-tested, but the real-Supervisor confirmation this section calls for has apparently not happened yet. Don't claim it has without checking `docs/auth.md` again.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!