Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 13 additions & 10 deletions db/migrations/0033_sessions.sql
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,26 @@
-- Design:
-- * One row per issued access token (login mints a `jti`; `/auth/refresh`
-- mints a fresh `jti` and a new row, so refresh rotates the session id).
-- * `revoked_at IS NOT NULL` ⇒ the token is dead. The `AuthUser` extractor
-- consults a small in-memory revocation cache (see `state.rs`) so the check
-- is not a per-request DB round-trip; the cache is refreshed on any revoke.
-- * `revoked_at IS NOT NULL` ⇒ the token is dead, and so is a token whose row
-- is ABSENT (deleting a user cascades their rows away). The `AuthUser`
-- extractor consults small in-memory caches (see `state.rs`) so neither
-- check is a per-request DB round-trip; they are refreshed on any revoke or
-- user change, and an unknown `jti` is resolved against the DB on the spot
-- so a token minted a moment ago is never spuriously rejected.
-- * `expires_at` lets a housekeeping sweep prune long-dead rows (the table is
-- otherwise unbounded for the 10-year tokens). Pruning is best-effort and
-- NOT required for correctness — an expired token is already rejected by the
-- JWT `exp` check regardless of whether its row still exists.
-- * `last_seen_at` is updated opportunistically (best-effort, throttled) so the
-- "your sessions" UI can show device activity; it is not on the hot auth path.
--
-- Back-compat: tokens issued BEFORE this migration carry no `jti` claim and thus
-- have no session row. The extractor treats a `jti`-less token as "legacy, not
-- revocable" and lets it through on signature+exp alone (unchanged behaviour) —
-- so deploying this does NOT force a global re-login. An OPTIONAL admin action
-- ("revoke all pre-existing sessions") can invalidate those legacy tokens by
-- switching the extractor to reject `jti`-less tokens; that switch is a config
-- flag / server-setting, left to the owner (see RELEASE-PLAN P0-SESSIONS).
-- Back-compat: this table predates the first public release, so every token any
-- released client has ever held carries a `jti` and has a row here. The
-- extractor therefore REQUIRES both: a token with no `jti`, or one whose row is
-- gone, is refused. (The originally-planned opt-in "reject legacy tokens" switch
-- was dropped as unnecessary — there are no legacy tokens to keep working, and a
-- token that can never be signed out is exactly what this table exists to
-- prevent.)
--
-- Fully idempotent (IF NOT EXISTS) so it is safe to (re-)apply on a long-lived
-- database, matching every other migration here.
Expand Down
13 changes: 13 additions & 0 deletions docs-site/docs/admin-console/users-and-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,16 @@ and will be held to exactly that boundary.

Because access is enforced by the server, not the app, a scoped user stays scoped
no matter which client they use or how they connect.

## Signing devices out

Signed-in devices stay in step with the account. Give someone a new password, or
change their role, and every device they were signed in on is signed out; they
sign back in with the new details. Remove the account and its devices lose access
straight away. Changing only which extra cameras a person has is different: they
stay signed in, and the new camera list applies to their very next request.

There is also a **Sign out everywhere** button in each user's editor, for when
you want to end every session for that account without changing anything else, a
lost phone being the usual reason. If you use it on your own account you will be
signed out of the console too.
1 change: 1 addition & 0 deletions docs/COMPONENT-MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ a "new camera capability" is usually also a "new/changed API endpoint" and a
|---|---|---|
| Server | `services/api/src/auth.rs`, `auth_mw.rs`, `roles.rs`, `rate_limit.rs`, `cors.rs`; sessions (migration `0033`), roles (migration `0028`) | Golden rule 1 in full: secure by default, no new `0.0.0.0` binds, no widened port exposure, no invented/logged secrets |
| Media tokens | scoped `?token=` mint (`auth.rs` media_token_routes) and every client's media-URL builder: android `MediaTokenCache.kt`/`MediaUrls.kt`, desktop `app.js`, iOS `Networking/` | Media must never carry the bearer JWT. A media token is only as weak as the WEAKEST guarantee of everything it can reach, and this is now enforced as a CLASS, not per-endpoint (audit 2026-08, follow-up to #516): the DEFAULT extractor `auth_mw::AuthUser` **rejects** media-token principals (403), so a new endpoint is safe by default. ONLY a genuine single-camera media-byte read (segments/low.mp4, filmstrip frames, camera stills, clip video/thumb, event snapshots, plate crops, live fMP4/WebRTC) opts back in via `auth_mw::MediaOrFullUser`. Anything whose response carries a credential/long-lived URL, spans more than the token's one camera, or mutates state MUST take `AuthUser` (or `AdminUser`/`FullSessionUser`) — never `MediaOrFullUser`. Adding a new `MediaOrFullUser` endpoint is a security-review event: confirm a client actually fetches it with `?token=` (see DECISIONS 2026-08-06) |
| Session lifecycle | `services/api/src/auth_mw.rs` (the `jti` liveness + grants resolution), `state.rs` (`session_cache`, `revoked_jtis`), `config_routes.rs` `update_user`/`delete_user`, `services/common/src/db.rs` session helpers; pinned by `services/api/tests/session_lifecycle.rs` | A token can outlive the account behind it (a remembered mobile login lasts years), so anything that changes a user must reach tokens already handed out. Invariants: a `jti` is resolved POSITIVELY against `sessions` (absent row ⇒ 401, since deleting a user cascades its rows away), a login token with no `jti` is refused, per-user camera grants come from the user row and never from `claims.camera_ids`, and a password/role change ends that user's sessions (all but the acting one when an admin edits their own account). Any new user-mutating route must do the same. See `docs/DECISIONS.md` 2026-09-07 |
| CORS scope | `services/api/src/cors.rs` (`cors::compose` in `main.rs`) | The permissive layer must never reach `/auth`. The carve-out works by axum's "a layer covers only routes registered before it" rule, so route order in `compose` is load-bearing; `auth_rbac.rs` has both the assertion and a control test |
| Client auth flows | `admin.html` login, desktop token SSO into embedded `/admin`, android `feature/auth` + `SecureStore.kt`, iOS `Features/Auth` | Token shape/lifetime changes break clients quietly |
| Wizard/seed | `SEED_ADMIN_*` envs, `auth::seed_admin_if_absent` | First-boot path |
Expand Down
65 changes: 65 additions & 0 deletions docs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,71 @@ revisit.

---

## 2026-09-07, Session validity is a positive cached lookup per `jti`, not a cached revoked-set alone; per-user camera grants are read from the user row

**Context.** `sessions` (migration 0033) made a token revocable, and the
`AuthUser` extractor checked it against an in-memory set of REVOKED `jti`s
refreshed on write. That set answers "was this session signed out". It cannot
answer "was this ever a session at all", and `sessions.user_id` is
`ON DELETE CASCADE`, so removing an account made its rows disappear rather than
be flagged. A token for a removed account therefore read as "not revoked" and
kept working for the rest of its `exp`, which for a remembered mobile login is
years. Separately, a user's per-user camera grants were baked into the token at
login and unioned with the role's cameras at request time, so editing them did
nothing until that user signed in again.

**Decision.** The extractor now resolves each `jti` POSITIVELY: a small
`AppState` cache maps `jti` to "session row present and not revoked, plus the
owning user's `users.camera_ids`", populated by one joined query on a miss and
cleared on any user change. An unknown `jti` is resolved against the DB then and
there, never assumed either way, and the resolved grants replace
`claims.camera_ids` in the role-union. A login token with no `jti` is refused
outright (the table predates the first public release, so no supported client
holds one). The existing revoked-set cache is kept as the fast path for
revocation; the new cache answers existence.

**Rejected:**

- *Caching the LIVE set instead.* A session minted a millisecond ago on another
API replica would not be in this replica's set until the TTL lapsed, so a user
who just signed in would be spuriously signed out. A false 401 right after
login is worse than a revoke that lands a few seconds late.
- *Querying `sessions` on every request.* Correct but puts a DB round trip on
the hot path of every authenticated request, including the media reads a video
wall issues continuously. The repo's established pattern (`roles_cache`,
`revoked_jtis`) is cache-the-truth, refresh-on-write.
- *Also ending sessions when only the extra-cameras list changes.* Once the
grants are read from the row on each request, the new set is in force on that
user's very next request, so signing them out adds nothing but disruption
(a viewer loses their wall because an admin granted them one more camera).
A password or role change still ends every session: those replace the
credential or what the account may do.
- *Keeping the opt-in "reject legacy `jti`-less tokens" switch* the 0033
migration sketched. There are no legacy tokens to keep working, and a
configurable switch for "accept credentials that can never be signed out" is a
setting nobody should choose.

**Trades knowingly accepted:**

- A cold `jti` costs one query, so a burst of first-time-seen sessions (an API
restart with many clients reconnecting) costs one query each, once.
- Across replicas, a user edit made on another replica lands within the cache
TTL rather than instantly; a revoke still lands within the shorter revocation
TTL. Single-process installs (the norm) see both immediately.
- A scoped media token minted just before an account was removed keeps working
for the rest of its short life. It is one camera, media bytes only, minutes,
which is the property the media token was designed around.
- A supplied password always counts as a change, even if it is the same string:
hashes are salted, so old and new are never comparable.

**Revisit if:** an install runs enough distinct concurrent sessions that the
per-`jti` cache is a memory or miss-rate problem (then key the cache by user and
carry a session generation counter), or Crumb grows a real multi-replica
deployment story where cache TTLs are too coarse (then push invalidation between
replicas, for example over the existing DB with a notify channel).

---

## 2026-08-10, Home Assistant `climate` (thermostat/HVAC setpoint) control is out of scope

**Context.** #442 introduced value-setting HA controls. Light dimming
Expand Down
25 changes: 25 additions & 0 deletions services/api/src/admin.html
Original file line number Diff line number Diff line change
Expand Up @@ -9776,6 +9776,7 @@
</div>
<div class="rp-head-actions">
<button class="primary" onclick="saveUser('${u.id}')">Save</button>
<button class="ghost" onclick="signOutUser('${esc(u.id)}')" title="End this account's signed-in sessions on every device">Sign out everywhere</button>
<button class="danger-ghost" onclick="delUser('${esc(u.id)}')" ${isLastAdmin?'disabled title="Cannot remove the last administrator"':''}>Remove</button>
</div>
</div>
Expand All @@ -9789,6 +9790,7 @@
${userRoleOptions(u.role_id, u.role === 'admin')}
</select>
<div class="card-hint" style="margin-top:4px">Capabilities and the role's base cameras come from the role. <a href="#" onclick="event.preventDefault();newRole()">Add a role</a> for fine-grained permissions. You can also grant this user extra cameras below.</div>
<div class="card-hint" style="margin-top:4px">Setting a new password or changing the role signs this account out on its other devices. Use <b>Sign out everywhere</b> to end every session now, for example if a phone was lost.</div>
${cameraAccessUI('ue', u.camera_ids || [])}
${isLastAdmin?'<div class="info-note" style="margin-top:10px">This is the only administrator. Create another admin before you can remove or demote this account.</div>':''}
<div id="ue-msg" class="form-msg"></div>
Expand Down Expand Up @@ -9839,6 +9841,29 @@
renderUser(id);
} catch (e) { setMsg('ue-msg', e.message, 'err'); }
}
/* ── SIGN OUT EVERYWHERE: end every session this account holds ──────────────
DELETE /auth/users/:id/sessions (admin-gated). Every device holding a token
for this account is signed out at once; they can sign back in with the same
password unless it was also changed. Signing YOURSELF out here ends this
console session too, so say so before doing it. */
async function signOutUser(id) {
const u = USERS.find(x => x.id === id);
const name = u ? u.username : id;
const me = await api('/auth/me').catch(() => null);
const warn = (me && me.id === id)
? `Sign out every device for "${name}"? That includes this console, you will have to sign in again.`
: `Sign out every device for "${name}"?`;
if (!confirm(warn)) return;
try {
const r = await api('/auth/users/' + encodeURIComponent(id) + '/sessions', { method: 'DELETE' });
const n = (r && typeof r.revoked === 'number') ? r.revoked : 0;
toast(n === 1 ? `Signed out 1 session for "${name}".` : `Signed out ${n} sessions for "${name}".`);
} catch (e) {
setMsg('ue-msg', e.message, 'err');
toast(e.message, 'err', 5000);
}
}

/* ── REMOVE: user (refuse removing the last administrator; show API reason) ── */
async function delUser(id) {
// Resolve the name from the loaded model by UUID (avoids passing admin-set
Expand Down
Loading
Loading