Release 0.11.0: trust device for 30 days

This commit is contained in:
Ben Stull
2026-05-28 03:39:28 -07:00
parent de28272914
commit abd3626ce3
14 changed files with 1600 additions and 29 deletions
+113
View File
@@ -197,6 +197,119 @@ consent infrastructure is wired so item #13 (v0.15.0) can read from
(because their `cookie_consent` row does not yet exist); their
current sessions remain valid.
## 0.11.0 — 2026-05-28
**Minor — schema migration required; no new env vars.** This release
ships the "trust this device for 30 days" gesture (roadmap item #9,
SPEC §6.2). After a successful OTC or passcode sign-in, the user
can check a single checkbox to mint a server-issued opaque
device-trust token; the token rides as a long-lived HttpOnly +
Secure + SameSite=Lax cookie, and the matching row's hash lives in a
new `device_trust` table. On a subsequent visit, the cookie is
presented at `POST /auth/device-trust/start` — if a non-expired,
non-revoked row matches, the session is re-established without
another OTC / passcode roundtrip. A new `/settings/notifications`
"Trusted devices" section lists active rows (created-at, last-seen,
expiry, rough UA label) with per-row "Revoke" and a "Revoke all
devices" button. The cookie is "essential" per the v0.13.0 cookie-
consent contract — it is part of authentication, not analytics — and
is set regardless of the user's analytics / other-cookies choice.
The session model gains a cookie, not a session-store change: the
existing `rfc_session` cookie still carries the in-flight session
state; the new `rfc_device_trust` cookie is consulted only by
`/auth/device-trust/start` to bootstrap a fresh session on a return
visit. The raw token only ever lives in the outbound `Set-Cookie`
header and the inbound `Cookie` header; server-side storage is the
bcrypt hash; constant-time comparison via `bcrypt.checkpw` on the
candidate walk. The raw token is never logged.
### Added
- **`device_trust` table** (`backend/migrations/017_device_trust.sql`).
Per-row id, `user_id` (FK with cascade), `device_token_hash`
(bcrypt at rest, unique index documents the no-collision
invariant), `created_at`, `expires_at` (`created_at + 30 days`),
`user_agent` (verbatim, app-layer-truncated to 1024 chars),
`last_seen_at` (refreshed on every successful lookup), `revoked_at`
(NULL means active). Secondary index on `(user_id, revoked_at)` so
the /settings list query is a covering walk.
- **`backend/app/device_trust.py`** — sibling of `otc.py` and
`passcode.py`. Carries `issue(user_id, user_agent)`,
`lookup(raw_token)`, `list_for_user(user_id)`, `revoke(user_id,
row_id)`, and `revoke_all(user_id)`. The 30-day window and the
cookie name (`rfc_device_trust`) live as module-level constants;
env-ifying them is a §19.2 candidate.
- **`§17` endpoints**
- `POST /auth/device-trust/start` — anonymous-reachable. Reads the
`rfc_device_trust` cookie; on a hit, signs the user in. On a
miss (expired, revoked, or unknown), clears the stale cookie and
returns 401.
- `GET /api/auth/me/devices` — list active trusted devices for
the signed-in user.
- `DELETE /api/auth/me/devices/{id}` — revoke a single row.
User-id scope enforced in SQL so a hostile client cannot
revoke another user's row by guessing ids.
- `DELETE /api/auth/me/devices` — revoke every active row.
- **OTC and passcode verify bodies** gain an optional
`trust_device: bool` field (default false). When true and verify
succeeds, the endpoint mints a fresh device-trust row and sets
the cookie on the response. Pre-v0.11.0 clients that omit the
field continue to behave as before.
- **Login.jsx** gains a "Trust this device for 30 days" checkbox
on both the OTC and passcode verify steps, plus a silent on-mount
call to `POST /auth/device-trust/start` so a returning user with
a valid cookie skips the email step entirely. A failure is
intentionally invisible — the user proceeds to the normal email
step.
- **`/settings/notifications` "Trusted devices" section** — lists
active rows with per-row "Revoke" + a "Revoke all devices" button
(with a `confirm()` prompt because the gesture is broad). The
surface intentionally does not single out the row whose cookie
the current request carries so a user can revoke "this device"
alongside any other from one place.
### Changed
- **`backend/app/main.py`** — the OTC and passcode verify endpoints
now also accept the `trust_device` flag and accept an injected
`Response` so they can attach the cookie. Two helpers
(`_set_device_trust_cookie`, `_clear_device_trust_cookie`) carry
the cookie attribute set in one place so the contract is
consistent across endpoints. The `Response` import is added
alongside the existing FastAPI re-exports.
- **`backend/app/api.py`** — imports `device_trust as device_trust_mod`
alongside `auth`/`db`; mounts the three `/api/auth/me/devices*`
endpoints immediately after `/api/auth/me/beta-request` so the
auth-shaped neighborhood stays clustered.
- **`frontend/src/api.js`** — exports `startDeviceTrust()`,
`listMyDevices()`, `revokeMyDevice(id)`, `revokeAllMyDevices()`.
`verifyOtc` and `verifyPasscode` accept an optional
`{ trustDevice }` argument that rides on the POST body.
### Upgrade steps (from 0.10.0)
- You **MUST** apply schema migration `017_device_trust.sql`. The
migration creates a single new table with one secondary index;
the framework runs migrations automatically at process start, so
no manual step is required beyond restarting the backend so the
migration runner picks the file up.
- You **MUST** rebuild the frontend and restart the backend after
upgrading. `frontend/package.json#version` and `VERSION` both
move to `0.11.0` and the new `Set-Cookie` shape requires the
backend to be on the matching version.
- You **MUST** serve the deployment over HTTPS. The
`rfc_device_trust` cookie is set with `Secure=True` — a
cleartext deployment will never receive the cookie back from
the browser, so the trust gesture will appear to silently fail.
Production OHM deployments already serve over HTTPS; local
development against `http://localhost` is unaffected (no cookie
is set, the OTC/passcode paths continue to work).
- You **MAY** announce the new feature to your users. Existing
signed-in sessions are unaffected — the device-trust cookie is
opt-in on the next sign-in, and a user who never checks the box
keeps the v0.10.0 behavior verbatim.
## 0.10.0 — 2026-05-28
**Minor — schema migration required; new auth path is additive.**