Release 0.11.0: trust device for 30 days
This commit is contained in:
+113
@@ -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.**
|
||||
|
||||
Reference in New Issue
Block a user