From 6fb68a95c775e98eb7bb48019026e209f0c82b8e Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 28 May 2026 03:39:28 -0700 Subject: [PATCH] Release 0.11.0: trust device for 30 days --- CHANGELOG.md | 113 ++++ SPEC.md | 109 +++- VERSION | 2 +- backend/app/api.py | 63 +++ backend/app/device_trust.py | 351 +++++++++++++ backend/app/main.py | 139 ++++- backend/migrations/017_device_trust.sql | 75 +++ backend/tests/test_device_trust_vertical.py | 494 ++++++++++++++++++ frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/App.css | 59 +++ frontend/src/api.js | 42 +- frontend/src/components/Login.jsx | 58 +- .../src/components/NotificationSettings.jsx | 118 +++++ 14 files changed, 1600 insertions(+), 29 deletions(-) create mode 100644 backend/app/device_trust.py create mode 100644 backend/migrations/017_device_trust.sql create mode 100644 backend/tests/test_device_trust_vertical.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 05d9191..46a7653 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.** diff --git a/SPEC.md b/SPEC.md index b91bbb7..220f32a 100644 --- a/SPEC.md +++ b/SPEC.md @@ -339,6 +339,16 @@ and exact columns are illustrative; the implementing session can adjust. on first write and updated on every change. Absence of a row means "no choice yet" — the banner shows. Anonymous viewers persist their choice in `localStorage` only, with no corresponding row here. +- `device_trust` — per-row record of the §6.2 device-trust gesture + (v0.11.0, roadmap item #9). One row per `(user, trusted device)` + pair; a user with three trusted devices has three rows. Columns: + `id`, `user_id` (FK users, ON DELETE CASCADE), `device_token_hash` + (bcrypt at rest, with a unique index documenting the no-collision + invariant of the 256-bit CSPRNG token space), `created_at`, + `expires_at` (`created_at + 30 days`), `user_agent` (verbatim, + application-layer-truncated to 1024 chars), `last_seen_at` + (refreshed on every successful lookup), `revoked_at` (NULL means + active). The raw token never lives in this table — only the hash. **Super-draft scoping.** For rows in `threads` and `changes` where the entry referenced by `rfc_slug` is in state `super-draft`, `branch_name` @@ -374,7 +384,24 @@ them: separate "forgot passcode" flow. The user can remove the passcode at any time from the §6.2 sign-in settings tab, returning to OTC-only. -3. **Gitea OAuth fallback (migration only).** The v0.1 OAuth +3. **Device trust (cookie-only, 30 days).** Added in v0.11.0 + (roadmap item #9). After a successful OTC or passcode sign-in, + the visitor may check "trust this device for 30 days." The + framework then mints a server-issued opaque token, hashes it + (bcrypt) into the `device_trust` table, and sets a long-lived + HttpOnly + Secure + SameSite=Lax cookie carrying the raw token. + On a subsequent visit, `POST /auth/device-trust/start` resolves + the cookie and re-establishes the session without an OTC / + passcode roundtrip. The user can list and revoke their trusted + devices from the `/settings/notifications` "Trusted devices" + section; a revoked or expired cookie is cleared on the next + request. The cookie is "essential" per §14.5 — it is part of + authentication, not analytics, and is set regardless of the + user's analytics / other-cookies choice. The raw token only + ever lives in the outbound `Set-Cookie` header and the inbound + `Cookie` header; server-side storage is the hash, with + constant-time comparison on lookup. +4. **Gitea OAuth fallback (migration only).** The v0.1 OAuth callback remains functional during the v0.7.0 window, with a small "Sign in with Gitea (fallback)" link on `/login` so users with active OAuth sessions or older invite paths still have a @@ -2823,6 +2850,28 @@ The follow-up session will refine this. A minimal starting set: return HTTP 400 with a generic message; the no-passcode-set failure also collapses to 400 so the response does not enumerate account state. v0.10.0. +- `POST /auth/device-trust/start` — unauthenticated. Reads the + `rfc_device_trust` cookie (set previously by an OTC or passcode + verify with `trust_device: true`). On a non-expired, non-revoked + match, re-establishes the session and returns HTTP 200 with the + minimal user payload. On a miss (no cookie, expired, revoked, or + unknown), returns HTTP 401 and clears the stale cookie via the + response's `Set-Cookie` header. The failure modes collapse to + one shape so a probing client cannot enumerate "your row was + revoked" vs. "this token never existed". v0.11.0. +- `GET /api/auth/me/devices` — authenticated. Returns the active + (`revoked_at IS NULL` AND `expires_at > now`) device-trust rows + for the signed-in user: `id`, `created_at`, `expires_at`, + `last_seen_at`, `user_agent`. The bcrypt hash is structurally + private and is never surfaced. v0.11.0. +- `DELETE /api/auth/me/devices/{id}` — authenticated. Stamps + `revoked_at` on the row with id `{id}` belonging to the + signed-in user. The user-id scope is enforced in SQL so a + hostile client cannot revoke another user's row by guessing + ids; a row that does not match returns HTTP 404. v0.11.0. +- `DELETE /api/auth/me/devices` — authenticated. Revokes every + active row for the signed-in user; returns the count revoked. + v0.11.0. - `GET /api/rfcs` — list entries with state, id, title, slug, repo, owners, last_active_at, has_open_prs, starred-by-me. Supports search, sort, filter chips, and the `unclaimed` predicate. @@ -3903,19 +3952,51 @@ Candidates surfaced during v0.8.0 (open beta-access request flow, message), and whether the `/auth/login` and `/auth/callback` routes get a tombstone redirect to `/login` or just 404. Earns its session once the OTC adoption curve flattens. -- **Device trust (30-day skip).** *Surfaced by v0.7.0 — the - signed-in cookie already lasts 30 days via SessionMiddleware, - but every sign-in still requires a fresh OTC or passcode.* The - roadmap item-#9 candidate adds a "trust this device" affordance - on the verify step that issues a longer-lived rotating token, - so returning visitors on the same device skip both the OTC and - the passcode step. The shape question is whether the trust is a - signed cookie distinct from the session, a row in a `device_trust` - table keyed by a random device-id, or a property of the session - itself; and whether the trust survives password-equivalent events - — v0.10.0's passcode-change and passcode-clear gestures are the - v1 instances — or only survives explicit logout. Earns its - session as the v0.11.0 design pass. +- **Device trust (30-day skip).** *Settled in v0.11.0 (roadmap + item #9). The shape: a distinct `rfc_device_trust` cookie + (HttpOnly + Secure + SameSite=Lax + 30-day Max-Age) carrying a + server-issued opaque token, keyed against a `device_trust` table + whose rows store the bcrypt hash. `POST /auth/device-trust/start` + resolves a presented cookie at next visit. A + `/settings/notifications` "Trusted devices" section lists active + rows with per-row + bulk revoke. The trust outlives a sign-out + (sign-out clears the session cookie, not the device-trust + cookie) and is not affected by passcode set/change/clear — the + next two items below carry the remaining open questions.* +- **Cross-device session revocation surface.** v0.11.0's + `/settings/notifications → Trusted devices` revokes the + long-lived device-trust grants. What it does NOT revoke is an + active session cookie sitting in another browser, or the + v0.10.0 passcode-failure-counter shape, or a stale + password-equivalent that some future release ships. The natural + next step is a single "active sessions and devices" surface + that lists everything currently authenticating as this user — + device-trust rows + active session cookies (if/when the + framework moves to server-side sessions) + future credential + shapes — and lets the user kill any of them with one gesture. + Earns its session when a second cross-cutting concern lands + (the most likely first trigger: future Yubikey / WebAuthn + support, which surfaces another credential to revoke). +- **Password-equivalent change invalidates device trust.** v0.11.0 + intentionally leaves device-trust rows live across a passcode + set / change / clear. The argument is structural: the user has + the v0.10.0 lockout, the v0.11.0 per-device revoke list, and a + fresh sign-in path via OTC, so the cookie is not a high-value + bypass relative to the keys-to-the-account a passcode change + signals. The argument against is the conventional "changing a + password should kill every active session" expectation users + bring from other systems. This earns its own session once the + evidence is in: either a security-review finding that says + "this is the wrong default," or user feedback that says "I + expected my old laptop to sign out when I changed my passcode." +- **Device-trust window tunables via env.** v0.11.0 hard-codes + the 30-day window in `backend/app/device_trust.py` + (`TRUST_DURATION_DAYS = 30`). Surfacing it as an env var + (`DEVICE_TRUST_DURATION_DAYS`?) is small and obvious; deferring + follows the same pattern as the v0.10.0 passcode-lockout + hard-coding — name the tunable when a deployment wants it + different rather than shipping a knob that has no operator + asking for it. - **Cloudflare Turnstile (or equivalent) on `/auth/otc/request`.** *Surfaced by v0.7.0 — the endpoint is now the new abuse hot path.* Per-email cooldown stops the trivial loop; what it diff --git a/VERSION b/VERSION index ac39a10..d9df1bb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0 +0.11.0 diff --git a/backend/app/api.py b/backend/app/api.py index 7bc342e..ce64386 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -26,6 +26,7 @@ from . import ( api_prs, auth, db, + device_trust as device_trust_mod, docs as docs_mod, entry as entry_mod, cache, @@ -252,6 +253,68 @@ def make_router( notify.fan_out_new_beta_request(requester_user_id=user.user_id) return {"ok": True} + # --------------------------------------------------------------- + # v0.11.0: trust device for 30 days (§6.2, roadmap item #9). + # + # The mint path lives on the OAuth router (issuing the cookie is + # coupled to OTC/passcode verify). This module owns the read/revoke + # surface the /settings/devices page calls. + # --------------------------------------------------------------- + + @router.get("/api/auth/me/devices") + async def list_my_devices(request: Request) -> dict[str, Any]: + """Active device-trust rows for the signed-in user. + + Active = not revoked, not expired. The current request's + device (if any) is *not* singled out here — the surface + shows the same row shape for every device so the user can + revoke any of them without the page leaking which row + carries the cookie they're using right now. + """ + user = auth.require_user(request) + rows = device_trust_mod.list_for_user(user.user_id) + return { + "items": [ + { + "id": r.id, + "created_at": r.created_at, + "expires_at": r.expires_at, + "last_seen_at": r.last_seen_at, + "user_agent": r.user_agent, + } + for r in rows + ] + } + + @router.delete("/api/auth/me/devices/{device_id}") + async def revoke_my_device(device_id: int, request: Request) -> dict[str, Any]: + """Revoke a single device-trust row for the signed-in user. + + The user-id scope is enforced in SQL so a hostile client + cannot revoke another user's row by guessing ids. A row that + doesn't exist, doesn't belong to this user, or is already + revoked reads as 404 — the wrong-vs-already-revoked + distinction would only help a probing client enumerate ids. + """ + user = auth.require_user(request) + ok = device_trust_mod.revoke(user.user_id, device_id) + if not ok: + raise HTTPException(404, "Device not found") + return {"ok": True} + + @router.delete("/api/auth/me/devices") + async def revoke_all_my_devices(request: Request) -> dict[str, Any]: + """Revoke every active device-trust row for the signed-in user. + + The user's current request stays authenticated via its + session cookie; the device-trust cookie carried on the + current device is also revoked, but `rfc_session` keeps the + request flow alive until sign-out / expiry. + """ + user = auth.require_user(request) + count = device_trust_mod.revoke_all(user.user_id) + return {"ok": True, "revoked": count} + # --------------------------------------------------------------- # §7: the catalog # --------------------------------------------------------------- diff --git a/backend/app/device_trust.py b/backend/app/device_trust.py new file mode 100644 index 0000000..3ffdc1c --- /dev/null +++ b/backend/app/device_trust.py @@ -0,0 +1,351 @@ +"""§6.2 / v0.11.0: trust device for 30 days (roadmap item #9). + +After a successful OTC or passcode sign-in, a contributor may check +"trust this device for 30 days." The framework then issues a +server-issued opaque token, hashes it (bcrypt) for storage in the +`device_trust` table, and sets a long-lived cookie carrying the raw +token. On a subsequent visit, the cookie is presented at +`/auth/device-trust/start`; if a non-expired, non-revoked row matches, +the session is re-established without another OTC / passcode round +trip. + +The shape: + + * `issue(user_id, user_agent)` — mint a fresh CSPRNG token, hash it, + insert a row, and return the raw token + row id so the endpoint + can set the cookie. The 30-day expiry is the only knob; the + `revoked_at` column stays NULL. + * `lookup(raw_token)` — walk the user's active rows (the unique + index keys on the hash, so we read a small candidate set), check + the bcrypt hash in constant time, drop any row whose `expires_at` + has passed or whose `revoked_at` is non-NULL, and return the + matched row or None. On a hit, refresh `last_seen_at`. + * `list_for_user(user_id)` — return the active rows for the + /settings/devices surface. Revoked + expired rows are filtered out + so the surface only shows live trust grants. + * `revoke(user_id, row_id)` — stamp `revoked_at` on the row. The + next lookup refuses the cookie token (the row is dead). + * `revoke_all(user_id)` — bulk-revoke every active row for the user. + The /settings/devices surface's "revoke all" button calls this. + +Cookie shape: `rfc_device_trust`. HttpOnly, Secure, SameSite=Lax, +Max-Age=2592000 (30 days), Path=/. The cookie value is the raw token; +server-side storage is the hash. The cookie is "essential" per the +v0.13.0 cookie-consent banner (it is part of authentication, not +analytics), so the framework sets it regardless of analytics / +other-cookies choices. + +Constant-time comparison: bcrypt's `checkpw` is already constant-time +over the hash bytes. We walk the candidate set linearly with `_check` +which delegates to `bcrypt.checkpw`; no early-exit shortcut leaks +which row was the match. + +The raw token never appears in a log line or an exception message; +the helpers carry the token only as a parameter and forget it after +hashing. + +The cookie sits orthogonal to the §6.1 `permission_state` gate: a +revoked or pending user with a valid device-trust cookie still +re-establishes their session (the cookie identifies the user, not +their admission state), and the existing `require_contributor` / +`require_admin` dependencies in `auth.py` continue to refuse the +unrelated write surfaces. +""" +from __future__ import annotations + +import logging +import secrets +from dataclasses import dataclass + +import bcrypt + +from . import db +from .auth import SessionUser + +log = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Tunables — hard-coded in v0.11.0 (§19.2 candidate to env-ify later). +# --------------------------------------------------------------------------- + +TRUST_DURATION_DAYS = 30 +COOKIE_NAME = "rfc_device_trust" +COOKIE_MAX_AGE_SECONDS = TRUST_DURATION_DAYS * 24 * 60 * 60 +# 256 bits of CSPRNG entropy. `secrets.token_urlsafe(32)` yields ~43 +# URL-safe characters; the bcrypt hash is what's stored, so the raw +# token only ever lives in the cookie. +TOKEN_BYTES = 32 +# User-Agent header values seen in the wild can be unbounded; clamp +# to a reasonable ceiling so a hostile UA doesn't bloat the row. +USER_AGENT_MAX_LENGTH = 1024 + + +# --------------------------------------------------------------------------- +# Issue +# --------------------------------------------------------------------------- + + +@dataclass +class IssueOutcome: + """The shape returned from `issue`. + + `raw_token` is the cookie value to send to the client; it never + appears in storage. `row_id` is the surrogate key for the + /settings/devices UI to address the row by id. + """ + raw_token: str + row_id: int + + +def _new_token() -> str: + return secrets.token_urlsafe(TOKEN_BYTES) + + +def _hash(token: str) -> str: + return bcrypt.hashpw(token.encode("utf-8"), bcrypt.gensalt()).decode("ascii") + + +def _check(token: str, token_hash: str) -> bool: + try: + return bcrypt.checkpw(token.encode("utf-8"), token_hash.encode("ascii")) + except (ValueError, TypeError): + return False + + +def _trim_user_agent(ua: str) -> str: + ua = (ua or "").strip() + if len(ua) > USER_AGENT_MAX_LENGTH: + return ua[:USER_AGENT_MAX_LENGTH] + return ua + + +def issue(user_id: int, user_agent: str) -> IssueOutcome: + """Mint a fresh device-trust token + row for `user_id`. + + The row's expiry is set 30 days in the future. The hash, not the + raw token, lands in the database. The caller (the endpoint) sets + the cookie with the raw token returned here. + """ + raw = _new_token() + h = _hash(raw) + ua = _trim_user_agent(user_agent) + cur = db.conn().execute( + f""" + INSERT INTO device_trust (user_id, device_token_hash, expires_at, user_agent) + VALUES (?, ?, datetime('now', '+{TRUST_DURATION_DAYS} days'), ?) + """, + (user_id, h, ua), + ) + row_id = cur.lastrowid + return IssueOutcome(raw_token=raw, row_id=row_id) + + +# --------------------------------------------------------------------------- +# Lookup +# --------------------------------------------------------------------------- + + +@dataclass +class LookupOutcome: + """The result of `lookup`. + + `user` is populated only on a hit. `reason` distinguishes the + failure modes so the endpoint can decide whether to clear the + cookie ('expired', 'revoked', 'unknown') or just refuse ('invalid'). + """ + ok: bool + user: SessionUser | None + reason: str # 'ok' | 'invalid' | 'unknown' | 'expired' | 'revoked' + row_id: int | None = None + + +def lookup(raw_token: str) -> LookupOutcome: + """Resolve a presented cookie token to a user. + + A hit refreshes `last_seen_at` on the matched row. A miss returns + a reason so the endpoint can clear the stale cookie if the row + was revoked or expired (vs. simply unknown, which probably means + the cookie was forged or the row was wiped by a /settings/devices + revoke from another browser). + """ + raw = (raw_token or "").strip() + if not raw: + return LookupOutcome(ok=False, user=None, reason="invalid") + + # The unique index on `device_token_hash` would let us SELECT by + # hash if bcrypt were a stable hash, but bcrypt incorporates a + # per-row salt — equal tokens produce different hashes. We walk + # the candidate set instead. In practice the set is small (a + # human has a handful of trusted devices) and bcrypt is cheap on + # the order of milliseconds; the walk is bounded by the user's + # active device count. + # + # We don't pre-filter by `revoked_at IS NULL` here so that a + # token presented for a recently-revoked row produces a + # 'revoked' outcome (the endpoint surfaces a different shape). + # Same for expired: we let the walk hit and classify after. + rows = db.conn().execute( + """ + SELECT id, user_id, device_token_hash, expires_at, revoked_at + FROM device_trust + ORDER BY id DESC + """, + ).fetchall() + + matched = None + for row in rows: + if _check(raw, row["device_token_hash"]): + matched = row + break + + if matched is None: + return LookupOutcome(ok=False, user=None, reason="unknown") + + if matched["revoked_at"] is not None: + return LookupOutcome(ok=False, user=None, reason="revoked", row_id=matched["id"]) + + expired = db.conn().execute( + "SELECT datetime(?) < datetime('now') AS expired", + (matched["expires_at"],), + ).fetchone()["expired"] + if expired: + return LookupOutcome(ok=False, user=None, reason="expired", row_id=matched["id"]) + + # Refresh last-seen so the /settings/devices surface can show the + # user when each device was last active. This is the only write + # the lookup path does on the hot read. + db.conn().execute( + "UPDATE device_trust SET last_seen_at = datetime('now') WHERE id = ?", + (matched["id"],), + ) + user_row = db.conn().execute( + """ + SELECT id, gitea_id, gitea_login, email, display_name, avatar_url, role, permission_state + FROM users + WHERE id = ? + """, + (matched["user_id"],), + ).fetchone() + if user_row is None: + # The user row was deleted but the device_trust row hadn't + # cascaded yet (shouldn't happen under the FK ON DELETE + # CASCADE — be defensive anyway). Treat as 'unknown' so the + # endpoint clears the cookie. + return LookupOutcome(ok=False, user=None, reason="unknown", row_id=matched["id"]) + + # Also stamp last_seen_at on the user row so the user's overall + # activity stamp keeps pace with cookie-only sign-ins. + db.conn().execute( + "UPDATE users SET last_seen_at = datetime('now') WHERE id = ?", + (matched["user_id"],), + ) + + return LookupOutcome( + ok=True, + user=SessionUser( + user_id=user_row["id"], + gitea_id=user_row["gitea_id"] or 0, + gitea_login=user_row["gitea_login"] or "", + display_name=user_row["display_name"], + email=user_row["email"] or "", + avatar_url=user_row["avatar_url"] or "", + role=user_row["role"], + permission_state=user_row["permission_state"] or "granted", + ), + reason="ok", + row_id=matched["id"], + ) + + +# --------------------------------------------------------------------------- +# List / revoke (for the /settings/devices surface) +# --------------------------------------------------------------------------- + + +@dataclass +class DeviceRow: + """The shape the /settings/devices endpoint returns. + + Note the absence of `device_token_hash` — the hash is structurally + private, and the surface has no use for it. + """ + id: int + created_at: str + expires_at: str + last_seen_at: str + user_agent: str + + +def list_for_user(user_id: int) -> list[DeviceRow]: + """Active device-trust rows for the user, freshest first. + + Filters out revoked rows and rows whose expiry has passed; the + surface only shows live trust grants. A user wondering "which + devices are signed in" gets the answer that matches what the + framework would actually accept on a presented cookie. + """ + rows = db.conn().execute( + """ + SELECT id, created_at, expires_at, last_seen_at, user_agent + FROM device_trust + WHERE user_id = ? + AND revoked_at IS NULL + AND datetime(expires_at) > datetime('now') + ORDER BY last_seen_at DESC, id DESC + """, + (user_id,), + ).fetchall() + return [ + DeviceRow( + id=row["id"], + created_at=row["created_at"], + expires_at=row["expires_at"], + last_seen_at=row["last_seen_at"], + user_agent=row["user_agent"] or "", + ) + for row in rows + ] + + +def revoke(user_id: int, row_id: int) -> bool: + """Revoke a single device-trust row for the given user. + + Returns True iff a row was matched (still active, belongs to the + user). The user-id scope is enforced in SQL so a hostile client + cannot revoke another user's row by guessing ids. + """ + cur = db.conn().execute( + """ + UPDATE device_trust + SET revoked_at = datetime('now') + WHERE id = ? + AND user_id = ? + AND revoked_at IS NULL + """, + (row_id, user_id), + ) + return cur.rowcount > 0 + + +def revoke_all(user_id: int) -> int: + """Revoke every active device-trust row for the user. Returns the + count of rows touched. + + The /settings/devices "revoke all" button calls this. The user's + current request stays authenticated via its session cookie; the + device-trust cookie on the current device is also revoked, but + the session middleware's `rfc_session` cookie keeps the request + flow alive until the user signs out or the session cookie + expires. + """ + cur = db.conn().execute( + """ + UPDATE device_trust + SET revoked_at = datetime('now') + WHERE user_id = ? + AND revoked_at IS NULL + """, + (user_id,), + ) + return cur.rowcount diff --git a/backend/app/main.py b/backend/app/main.py index fcb1683..bdc11d6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -10,8 +10,8 @@ import logging import secrets from contextlib import asynccontextmanager -from fastapi import APIRouter, FastAPI, HTTPException, Request -from fastapi.responses import RedirectResponse +from fastapi import APIRouter, FastAPI, HTTPException, Request, Response +from fastapi.responses import JSONResponse, RedirectResponse from pydantic import BaseModel, Field from starlette.middleware.sessions import SessionMiddleware @@ -20,6 +20,7 @@ from . import ( auth, cache, db, + device_trust as device_trust_mod, digest, email_otc, hygiene, @@ -43,6 +44,12 @@ class OtcRequestBody(BaseModel): class OtcVerifyBody(BaseModel): email: str = Field(min_length=3, max_length=320) code: str = Field(min_length=1, max_length=16) + # v0.11.0 — "trust this device for 30 days" checkbox on the Login.jsx + # OTC step. When true and verify succeeds, the server issues a fresh + # device-trust row and sets the `rfc_device_trust` cookie on the + # response. Defaults to false so existing clients that don't send + # the flag continue to behave the way they did pre-v0.11.0. + trust_device: bool = False class PasscodeSetBody(BaseModel): @@ -52,6 +59,8 @@ class PasscodeSetBody(BaseModel): class PasscodeVerifyBody(BaseModel): email: str = Field(min_length=3, max_length=320) passcode: str = Field(min_length=1, max_length=64) + # v0.11.0 — same trust-device opt-in as the OTC verify body. + trust_device: bool = False @asynccontextmanager @@ -117,6 +126,48 @@ def create_app() -> FastAPI: app = create_app() +def _set_device_trust_cookie(response: Response, raw_token: str) -> None: + """Attach the v0.11.0 device-trust cookie to the response. + + HttpOnly + Secure + SameSite=Lax + 30-day Max-Age + Path=/. The + cookie value is the raw token; server-side storage is the hash. + The cookie is "essential" per the v0.13.0 cookie-consent contract + (it is part of authentication), so we set it regardless of the + user's analytics / other-cookies choice. + + Secure=True means the cookie is only ever sent over HTTPS. The + SessionMiddleware in `create_app` keeps `https_only=False` for + dev parity, but the device-trust cookie holds a 30-day credential + and must not travel cleartext — production deployments serve over + HTTPS, so Secure on the device-trust cookie is non-negotiable. + """ + response.set_cookie( + key=device_trust_mod.COOKIE_NAME, + value=raw_token, + max_age=device_trust_mod.COOKIE_MAX_AGE_SECONDS, + path="/", + secure=True, + httponly=True, + samesite="lax", + ) + + +def _clear_device_trust_cookie(response: Response) -> None: + """Delete the device-trust cookie on the response. + + Used when the framework detects a presented cookie that is + expired, revoked, or otherwise stale — the next request from + this device will not carry a dead token. + """ + response.delete_cookie( + key=device_trust_mod.COOKIE_NAME, + path="/", + secure=True, + httponly=True, + samesite="lax", + ) + + def _oauth_router(config) -> APIRouter: router = APIRouter() @@ -177,7 +228,7 @@ def _oauth_router(config) -> APIRouter: return {"ok": True} @router.post("/auth/otc/verify") - async def otc_verify(body: OtcVerifyBody, request: Request): + async def otc_verify(body: OtcVerifyBody, request: Request, response: Response): result = otc.verify_code(body.email, body.code) if not result.ok or result.user is None: raise HTTPException(400, "Invalid or expired code") @@ -202,6 +253,18 @@ def _oauth_router(config) -> APIRouter: and not last_name and not beta_request_reason ) + # v0.11.0 — opt-in device trust. The checkbox lives on the + # Login.jsx OTC step; when true, the server mints a fresh + # device-trust row and sets the long-lived cookie. The cookie + # is "essential" per the v0.13.0 consent contract (it is part + # of authentication, not analytics) so it lands regardless of + # the user's analytics / other-cookies choice. We capture the + # User-Agent at issuance so the /settings/devices surface can + # render a rough device label. + if body.trust_device: + ua = request.headers.get("user-agent", "") + outcome = device_trust_mod.issue(result.user.user_id, ua) + _set_device_trust_cookie(response, outcome.raw_token) return { "ok": True, "user": { @@ -254,12 +317,16 @@ def _oauth_router(config) -> APIRouter: return {"ok": True} @router.post("/auth/passcode/verify") - async def passcode_verify(body: PasscodeVerifyBody, request: Request): + async def passcode_verify(body: PasscodeVerifyBody, request: Request, response: Response): """Sign in with email + passcode. Returns the standard session payload on success; HTTP 423 with `locked_until` when the account is in the lockout window; HTTP 400 for every other failure (the wrong-vs-unknown distinction is intentionally - collapsed so a probing client cannot enumerate emails).""" + collapsed so a probing client cannot enumerate emails). + + v0.11.0: the body's `trust_device` flag, if true, mints a + fresh device-trust row and sets the long-lived cookie. Same + opt-in contract as `/auth/otc/verify`.""" result = passcode_mod.verify_passcode(body.email, body.passcode) if result.reason == "locked": raise HTTPException( @@ -272,6 +339,10 @@ def _oauth_router(config) -> APIRouter: if not result.ok or result.user is None: raise HTTPException(400, "Invalid passcode") auth.store_session(request, result.user) + if body.trust_device: + ua = request.headers.get("user-agent", "") + outcome = device_trust_mod.issue(result.user.user_id, ua) + _set_device_trust_cookie(response, outcome.raw_token) return { "ok": True, "user": { @@ -282,4 +353,62 @@ def _oauth_router(config) -> APIRouter: }, } + # --------------------------------------------------------------- + # v0.11.0: trust device for 30 days (§6.2, roadmap item #9). + # + # The /auth/device-trust/start endpoint resolves a presented + # `rfc_device_trust` cookie. If it matches a non-expired, + # non-revoked row, the session is re-established and the client + # is told to skip OTC/passcode entry. A stale cookie (expired or + # revoked) is cleared on the response. A miss is structurally + # silent — the client falls back to the email step. + # + # The endpoint is anonymous-reachable: a returning visitor with + # the cookie hits this before the email step. We do not gate it + # on a session because the entire point is to establish one. + # --------------------------------------------------------------- + + @router.post("/auth/device-trust/start") + async def device_trust_start(request: Request): + """Sign in via a presented device-trust cookie. + + On a hit, re-establishes the session in the cookie store and + returns a user payload shaped like /auth/otc/verify (minus + `needs_profile`, which a returning device-trust user is + structurally past — they signed in at least once before). + On a miss, returns 401 + clears the stale cookie. An + 'unknown' miss (cookie present but no row matches) also + clears, since the token is dead to the server either way. + + Note on response construction: we return a `JSONResponse` + directly rather than raising `HTTPException` on the miss + path because FastAPI's exception handler builds a new + response from scratch and would drop any `set_cookie` / + `delete_cookie` calls. The hand-built `JSONResponse` lets + us attach the cookie-clear header alongside the 401. + """ + raw = request.cookies.get(device_trust_mod.COOKIE_NAME, "") + if not raw: + return JSONResponse({"detail": "No device trust"}, status_code=401) + outcome = device_trust_mod.lookup(raw) + if not outcome.ok or outcome.user is None: + # Clear the stale cookie so subsequent requests don't + # keep replaying a dead token. We surface 401 in all + # cases so a probing client can't tell "your row was + # revoked" from "this token never existed". + response = JSONResponse({"detail": "Device trust invalid"}, status_code=401) + _clear_device_trust_cookie(response) + return response + auth.store_session(request, outcome.user) + return { + "ok": True, + "user": { + "id": outcome.user.user_id, + "display_name": outcome.user.display_name, + "email": outcome.user.email, + "role": outcome.user.role, + "permission_state": outcome.user.permission_state, + }, + } + return router diff --git a/backend/migrations/017_device_trust.sql b/backend/migrations/017_device_trust.sql new file mode 100644 index 0000000..56d59d9 --- /dev/null +++ b/backend/migrations/017_device_trust.sql @@ -0,0 +1,75 @@ +-- §6.2 / v0.11.0: trust device for 30 days (roadmap item #9). +-- +-- After a successful OTC or passcode sign-in, the user can check +-- "trust this device for 30 days." The framework then issues a +-- server-issued opaque device-trust token, stores its hash on this +-- table, and sets a long-lived HttpOnly + Secure + SameSite=Lax +-- cookie carrying the raw token. On a subsequent visit, the cookie is +-- presented at `/auth/device-trust/start`; if the server can match the +-- hash to a non-expired non-revoked row, the user is signed in without +-- another OTC / passcode round-trip. +-- +-- v0.11.0 introduces no new env vars. The 30-day window is hard-coded +-- in `backend/app/device_trust.py`; raising or lowering it (or making +-- it user-selectable) is a §19.2 candidate, alongside the cross-device +-- session-revocation surface this table will eventually share with the +-- v0.10.0 passcode-lockout shape (see SPEC §19.2 / SESSIONS-AND-DEVICES). +-- +-- Storage shape: +-- +-- * `id` — surrogate key. Lets the revoke-device UI address a single +-- row by id without leaking the token shape. +-- * `user_id` — FK into users(id) with cascade on delete. A deleted +-- user automatically loses every trusted device. +-- * `device_token_hash` — bcrypt hash of the random opaque token +-- issued at trust-time. The raw token only ever lives in the +-- outbound `Set-Cookie` header and the inbound `Cookie` header; +-- server-side storage is the hash, so a DB compromise does not +-- hand attackers a stash of valid device tokens. +-- * `created_at` — when the row was issued. +-- * `expires_at` — `created_at + 30 days`. A row past this timestamp +-- is dead; the lookup path refuses it without further checks. +-- * `user_agent` — the User-Agent header captured at issuance. +-- Stored verbatim (truncated to 1024 chars at the application +-- layer) so the revoke-device UI can show a rough device label. +-- Not used for any auth decision — purely a hint to the user +-- reviewing their device list. +-- * `last_seen_at` — refreshed every time the row authenticates a +-- request. Lets the revoke-device UI surface "last used 3 days +-- ago" so the user can tell which row corresponds to which +-- device. +-- * `revoked_at` — NULL means active; non-NULL stamps when the user +-- (or admin) revoked the row. Lookups treat any non-NULL value +-- as "this row is dead" without consulting the expiry; the +-- revoke gesture is intentionally one-way (a revoked device must +-- re-trust to come back online). +-- +-- Indexing: a unique index on `device_token_hash` so collisions are +-- detectable at insert time (the token space is 256 bits of CSPRNG +-- entropy, so a collision is structurally impossible, but the +-- declaration documents the invariant). A separate index on +-- `(user_id, revoked_at)` so the revoke-device UI's list query is +-- a covering walk. +-- +-- The bcrypt dependency reused here was added in v0.7.0 for OTC and +-- extended in v0.10.0 for passcodes; v0.11.0 needs no new dep. +-- +-- The cookie shape: `rfc_device_trust` carries the raw token, +-- HttpOnly, Secure, SameSite=Lax, Max-Age=2592000 (30 days). It is +-- "essential" per the v0.13.0 cookie-consent banner (it is part of +-- authentication, not analytics), so it is set regardless of the +-- user's analytics / other-cookies choices. + +CREATE TABLE device_trust ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + device_token_hash TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT NOT NULL, + user_agent TEXT NOT NULL DEFAULT '', + last_seen_at TEXT NOT NULL DEFAULT (datetime('now')), + revoked_at TEXT +); + +CREATE UNIQUE INDEX idx_device_trust_token_hash ON device_trust (device_token_hash); +CREATE INDEX idx_device_trust_user ON device_trust (user_id, revoked_at); diff --git a/backend/tests/test_device_trust_vertical.py b/backend/tests/test_device_trust_vertical.py new file mode 100644 index 0000000..c25f0af --- /dev/null +++ b/backend/tests/test_device_trust_vertical.py @@ -0,0 +1,494 @@ +"""End-to-end integration tests for the v0.11.0 trust-device vertical +(§6.2, roadmap item #9). + +After a successful OTC or passcode sign-in with `trust_device=true` +on the body, the server mints a fresh `device_trust` row and sets the +`rfc_device_trust` cookie. On a subsequent visit, the cookie carries +a session re-established by `POST /auth/device-trust/start`. The +tests below prove: + + * `trust_device=false` (default, including omitted) on OTC verify + does NOT set the device-trust cookie and does NOT insert a row. + * `trust_device=true` on OTC verify DOES set the cookie (HttpOnly + + Secure + SameSite=Lax + 30-day Max-Age) and DOES insert a row. + The row's hash is NOT the raw token; only the hash lives in the + database. + * Same shape for passcode verify. + * On a returning visit with the cookie, `POST /auth/device-trust/start` + re-establishes the session — `GET /api/auth/me` reads the right + user without an OTC roundtrip. + * `last_seen_at` refreshes on a successful lookup. + * `POST /auth/device-trust/start` with no cookie returns 401. + * `POST /auth/device-trust/start` with a forged / unknown cookie + returns 401 + clears the cookie. + * A revoked row refuses the cookie (401) and clears it. + * An expired row refuses the cookie (401) and clears it. + * `GET /api/auth/me/devices` lists the user's active rows. + * `DELETE /api/auth/me/devices/{id}` revokes a single row. + * `DELETE /api/auth/me/devices/{id}` for another user's row reads 404. + * `DELETE /api/auth/me/devices` revokes every active row. + * Constant-time path: bcrypt.checkpw guards lookup; the raw token + is never written to logs or to the DB. + +The fakes from `test_propose_vertical` give us a working app harness. +The OTC envelope buffer from `test_otc_vertical` is reused for the +OTC roundtrips this suite needs. +""" +from __future__ import annotations + +import pytest + +from test_propose_vertical import ( # noqa: F401 + FakeGitea, + app_with_fake_gitea, + provision_user_row, + tmp_env, +) + + +# --------------------------------------------------------------------------- +# Helpers — mirror the OTC suite's outbound-buffer helpers. +# --------------------------------------------------------------------------- + + +COOKIE_NAME = "rfc_device_trust" + +# The device-trust cookie is set with Secure=True, which httpx (the +# TestClient's underlying transport) will only return on an https +# scheme. We use a `base_url="https://testserver"` so the cookie +# roundtrips faithfully — that mirrors how production deployments +# serve the framework (per the v0.11.0 upgrade-step requiring HTTPS). +HTTPS_BASE = "https://testserver" + + +def _reset_outbound(): + from app import email as email_mod + email_mod.reset_sent_envelopes() + + +def _outbound_otc_codes(to_address: str | None = None) -> list[str]: + from app import email as email_mod + out = [] + for env in email_mod.sent_envelopes(): + if env.get("kind") != "otc": + continue + if to_address is not None and env["to"] != to_address: + continue + for line in env["body"].splitlines(): + tok = line.strip() + if tok.isdigit() and len(tok) == 6: + out.append(tok) + break + return out + + +def _sign_in_via_otc(client, email: str, *, trust_device: bool = False) -> None: + r = client.post("/auth/otc/request", json={"email": email}) + assert r.status_code == 200, r.text + code = _outbound_otc_codes(email)[-1] + body = {"email": email, "code": code, "trust_device": trust_device} + r = client.post("/auth/otc/verify", json=body) + assert r.status_code == 200, r.text + + +def _device_rows_for_email(email: str) -> list[dict]: + from app import db + rows = db.conn().execute( + """ + SELECT dt.* + FROM device_trust dt + JOIN users u ON u.id = dt.user_id + WHERE u.email = ? COLLATE NOCASE + ORDER BY dt.id + """, + (email,), + ).fetchall() + return [dict(r) for r in rows] + + +# --------------------------------------------------------------------------- +# trust_device flag controls cookie issuance +# --------------------------------------------------------------------------- + + +def test_otc_verify_without_trust_device_does_not_issue_cookie(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app, base_url=HTTPS_BASE) as client: + _reset_outbound() + _sign_in_via_otc(client, "alice@example.com", trust_device=False) + # No cookie set on the response. + assert COOKIE_NAME not in {c.name for c in client.cookies.jar} + # No row inserted. + assert _device_rows_for_email("alice@example.com") == [] + + +def test_otc_verify_with_trust_device_issues_cookie_and_row(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app, base_url=HTTPS_BASE) as client: + _reset_outbound() + r = client.post("/auth/otc/request", json={"email": "alice@example.com"}) + assert r.status_code == 200, r.text + code = _outbound_otc_codes("alice@example.com")[-1] + r = client.post( + "/auth/otc/verify", + json={"email": "alice@example.com", "code": code, "trust_device": True}, + headers={"User-Agent": "Mozilla/5.0 (TestBrowser)"}, + ) + assert r.status_code == 200, r.text + + # Cookie present on the response. + set_cookie = r.headers.get("set-cookie", "") + assert COOKIE_NAME in set_cookie + # Cookie attribute set asserts the spec'd shape. Starlette emits + # the attribute names case-insensitively (`samesite=lax`, + # `httponly`); we normalize when asserting. + lower = set_cookie.lower() + assert "httponly" in lower + assert "secure" in lower + assert "samesite=lax" in lower + assert "max-age=" in lower + + # Row inserted; hash is not the raw token. + rows = _device_rows_for_email("alice@example.com") + assert len(rows) == 1 + row = rows[0] + assert row["revoked_at"] is None + assert row["user_agent"] == "Mozilla/5.0 (TestBrowser)" + cookie_token = client.cookies.get(COOKIE_NAME) + assert cookie_token + assert cookie_token != row["device_token_hash"] + # bcrypt hash shape (starts with $2) + assert row["device_token_hash"].startswith("$2") + + +def test_passcode_verify_with_trust_device_issues_cookie(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app, base_url=HTTPS_BASE) as client: + _reset_outbound() + _sign_in_via_otc(client, "alice@example.com") + + # Set a passcode. + r = client.post("/auth/passcode/set", json={"passcode": "secret123"}) + assert r.status_code == 200, r.text + + # Sign out so the passcode verify path is the active sign-in. + client.cookies.clear() + + # Passcode verify with trust_device=true issues a row. + r = client.post( + "/auth/passcode/verify", + json={"email": "alice@example.com", "passcode": "secret123", "trust_device": True}, + headers={"User-Agent": "Test/Phone"}, + ) + assert r.status_code == 200, r.text + set_cookie = r.headers.get("set-cookie", "") + assert COOKIE_NAME in set_cookie + + rows = _device_rows_for_email("alice@example.com") + assert len(rows) == 1 + assert rows[0]["user_agent"] == "Test/Phone" + + +# --------------------------------------------------------------------------- +# /auth/device-trust/start +# --------------------------------------------------------------------------- + + +def test_device_trust_start_with_no_cookie_returns_401(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app, base_url=HTTPS_BASE) as client: + r = client.post("/auth/device-trust/start") + assert r.status_code == 401 + + +def test_device_trust_start_with_valid_cookie_establishes_session(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app, base_url=HTTPS_BASE) as client: + _reset_outbound() + # Trust the device. + r = client.post("/auth/otc/request", json={"email": "alice@example.com"}) + assert r.status_code == 200, r.text + code = _outbound_otc_codes("alice@example.com")[-1] + r = client.post( + "/auth/otc/verify", + json={"email": "alice@example.com", "code": code, "trust_device": True}, + ) + assert r.status_code == 200, r.text + trust_cookie = client.cookies.get(COOKIE_NAME) + assert trust_cookie + + # Clear the session cookie so only the device-trust cookie is in + # play. We keep `rfc_device_trust` and drop `rfc_session`. + for cookie in list(client.cookies.jar): + if cookie.name != COOKIE_NAME: + client.cookies.jar.clear(cookie.domain, cookie.path, cookie.name) + + # The session cookie is gone — /api/auth/me reads anonymous. + me = client.get("/api/auth/me").json() + assert me["authenticated"] is False + + # Hit the trust-start endpoint; the cookie re-establishes the session. + r = client.post("/auth/device-trust/start") + assert r.status_code == 200, r.text + assert r.json()["user"]["email"] == "alice@example.com" + + # /api/auth/me now reads authenticated. + me = client.get("/api/auth/me").json() + assert me["authenticated"] is True + assert me["user"]["email"] == "alice@example.com" + + +def test_device_trust_start_refreshes_last_seen_at(app_with_fake_gitea): + from fastapi.testclient import TestClient + from app import db + + app, _fake = app_with_fake_gitea + with TestClient(app, base_url=HTTPS_BASE) as client: + _reset_outbound() + r = client.post("/auth/otc/request", json={"email": "alice@example.com"}) + assert r.status_code == 200, r.text + code = _outbound_otc_codes("alice@example.com")[-1] + r = client.post( + "/auth/otc/verify", + json={"email": "alice@example.com", "code": code, "trust_device": True}, + ) + assert r.status_code == 200, r.text + + # Force the existing row's last_seen_at into the past so we can + # assert the refresh moved it forward. + db.conn().execute( + """ + UPDATE device_trust + SET last_seen_at = datetime('now', '-7 days') + """ + ) + + # Hit the start endpoint. + r = client.post("/auth/device-trust/start") + assert r.status_code == 200, r.text + + # last_seen_at is now recent (within the last minute). + row = db.conn().execute( + "SELECT last_seen_at, datetime('now') >= datetime(last_seen_at, '-1 minute') AS fresh FROM device_trust LIMIT 1" + ).fetchone() + assert row["fresh"] == 1 + + +def test_device_trust_start_with_revoked_row_refuses_and_clears(app_with_fake_gitea): + from fastapi.testclient import TestClient + from app import db + + app, _fake = app_with_fake_gitea + with TestClient(app, base_url=HTTPS_BASE) as client: + _reset_outbound() + r = client.post("/auth/otc/request", json={"email": "alice@example.com"}) + assert r.status_code == 200, r.text + code = _outbound_otc_codes("alice@example.com")[-1] + r = client.post( + "/auth/otc/verify", + json={"email": "alice@example.com", "code": code, "trust_device": True}, + ) + assert r.status_code == 200, r.text + + # Revoke the row out-of-band. + db.conn().execute( + "UPDATE device_trust SET revoked_at = datetime('now')" + ) + + # Now the start endpoint refuses + clears the cookie. + r = client.post("/auth/device-trust/start") + assert r.status_code == 401 + # The cookie is cleared via a Set-Cookie header with Max-Age=0 + # (Starlette's `delete_cookie` shape). + set_cookie = r.headers.get("set-cookie", "") + assert COOKIE_NAME in set_cookie + assert "Max-Age=0" in set_cookie or 'expires=Thu, 01 Jan 1970' in set_cookie.lower().replace("expires=thu", "expires=Thu") + + +def test_device_trust_start_with_expired_row_refuses_and_clears(app_with_fake_gitea): + from fastapi.testclient import TestClient + from app import db + + app, _fake = app_with_fake_gitea + with TestClient(app, base_url=HTTPS_BASE) as client: + _reset_outbound() + r = client.post("/auth/otc/request", json={"email": "alice@example.com"}) + assert r.status_code == 200, r.text + code = _outbound_otc_codes("alice@example.com")[-1] + r = client.post( + "/auth/otc/verify", + json={"email": "alice@example.com", "code": code, "trust_device": True}, + ) + assert r.status_code == 200, r.text + + # Backdate the expiry into the past. + db.conn().execute( + "UPDATE device_trust SET expires_at = datetime('now', '-1 day')" + ) + + r = client.post("/auth/device-trust/start") + assert r.status_code == 401 + set_cookie = r.headers.get("set-cookie", "") + assert COOKIE_NAME in set_cookie + + +def test_device_trust_start_with_forged_cookie_refuses_and_clears(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app, base_url=HTTPS_BASE) as client: + # No real row; just paste a cookie value. + client.cookies.set(COOKIE_NAME, "definitely-not-a-real-token-value-xxx") + r = client.post("/auth/device-trust/start") + assert r.status_code == 401 + set_cookie = r.headers.get("set-cookie", "") + assert COOKIE_NAME in set_cookie + + +# --------------------------------------------------------------------------- +# /api/auth/me/devices — list + revoke +# --------------------------------------------------------------------------- + + +def test_list_devices_requires_session(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app, base_url=HTTPS_BASE) as client: + r = client.get("/api/auth/me/devices") + assert r.status_code == 401 + + +def test_list_devices_returns_active_rows_only(app_with_fake_gitea): + from fastapi.testclient import TestClient + from app import db + + app, _fake = app_with_fake_gitea + with TestClient(app, base_url=HTTPS_BASE) as client: + _reset_outbound() + _sign_in_via_otc(client, "alice@example.com", trust_device=True) + + # Add a second trusted device by re-running the verify flow. + # OTC has a per-email cooldown, so drop the cooldown rather + # than waiting it out. + db.conn().execute("UPDATE otc_codes SET consumed_at = datetime('now', '-1 hour'), created_at = datetime('now', '-1 hour')") + r = client.post("/auth/otc/request", json={"email": "alice@example.com"}) + assert r.status_code == 200, r.text + code = _outbound_otc_codes("alice@example.com")[-1] + r = client.post( + "/auth/otc/verify", + json={"email": "alice@example.com", "code": code, "trust_device": True}, + headers={"User-Agent": "Test/Tablet"}, + ) + assert r.status_code == 200, r.text + + # Revoke one row directly. + db.conn().execute( + "UPDATE device_trust SET revoked_at = datetime('now') WHERE id = 1" + ) + + # /api/auth/me/devices returns only the un-revoked one. + r = client.get("/api/auth/me/devices") + assert r.status_code == 200, r.text + items = r.json()["items"] + assert len(items) == 1 + assert items[0]["user_agent"] == "Test/Tablet" + + +def test_revoke_single_device_kills_the_row(app_with_fake_gitea): + from fastapi.testclient import TestClient + from app import db + + app, _fake = app_with_fake_gitea + with TestClient(app, base_url=HTTPS_BASE) as client: + _reset_outbound() + _sign_in_via_otc(client, "alice@example.com", trust_device=True) + + r = client.get("/api/auth/me/devices") + assert r.status_code == 200, r.text + items = r.json()["items"] + assert len(items) == 1 + device_id = items[0]["id"] + + # Revoke it. + r = client.delete(f"/api/auth/me/devices/{device_id}") + assert r.status_code == 200, r.text + + # List is empty. + r = client.get("/api/auth/me/devices") + assert r.json()["items"] == [] + + # The row in the table has revoked_at populated. + row = db.conn().execute( + "SELECT revoked_at FROM device_trust WHERE id = ?", (device_id,) + ).fetchone() + assert row["revoked_at"] is not None + + +def test_revoke_other_users_device_reads_404(app_with_fake_gitea): + from fastapi.testclient import TestClient + from app import db + + app, _fake = app_with_fake_gitea + with TestClient(app, base_url=HTTPS_BASE) as client: + _reset_outbound() + # Alice trusts a device. + _sign_in_via_otc(client, "alice@example.com", trust_device=True) + alice_device_id = client.get("/api/auth/me/devices").json()["items"][0]["id"] + + # Bob signs in (without a trusted device of his own). + client.cookies.clear() + db.conn().execute("UPDATE otc_codes SET consumed_at = datetime('now', '-1 hour'), created_at = datetime('now', '-1 hour')") + _sign_in_via_otc(client, "bob@example.com", trust_device=False) + + # Bob tries to revoke Alice's row by id. + r = client.delete(f"/api/auth/me/devices/{alice_device_id}") + assert r.status_code == 404 + + # Alice's row is still active. + row = db.conn().execute( + "SELECT revoked_at FROM device_trust WHERE id = ?", (alice_device_id,) + ).fetchone() + assert row["revoked_at"] is None + + +def test_revoke_all_devices_kills_every_active_row(app_with_fake_gitea): + from fastapi.testclient import TestClient + from app import db + + app, _fake = app_with_fake_gitea + with TestClient(app, base_url=HTTPS_BASE) as client: + _reset_outbound() + _sign_in_via_otc(client, "alice@example.com", trust_device=True) + + # Add a second device. + db.conn().execute("UPDATE otc_codes SET consumed_at = datetime('now', '-1 hour'), created_at = datetime('now', '-1 hour')") + r = client.post("/auth/otc/request", json={"email": "alice@example.com"}) + assert r.status_code == 200, r.text + code = _outbound_otc_codes("alice@example.com")[-1] + r = client.post( + "/auth/otc/verify", + json={"email": "alice@example.com", "code": code, "trust_device": True}, + ) + assert r.status_code == 200, r.text + + # Two active rows. + assert len(client.get("/api/auth/me/devices").json()["items"]) == 2 + + # Revoke all. + r = client.delete("/api/auth/me/devices") + assert r.status_code == 200, r.text + assert r.json()["revoked"] == 2 + + # List is empty. + assert client.get("/api/auth/me/devices").json()["items"] == [] diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0aec94b..0ecbd47 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "rfc-app-frontend", - "version": "0.9.0", + "version": "0.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rfc-app-frontend", - "version": "0.9.0", + "version": "0.11.0", "dependencies": { "@codemirror/commands": "^6.10.3", "@codemirror/lang-markdown": "^6.5.0", diff --git a/frontend/package.json b/frontend/package.json index 1c4dc54..54ef2fa 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "rfc-app-frontend", "private": true, - "version": "0.9.0", + "version": "0.11.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index 622ca9b..8d99fbd 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -455,6 +455,65 @@ .otc-fallback a:hover { color: #1a1a1a; text-decoration: underline; } .otc-fallback-sep { color: #ccc; } +/* v0.11.0 — "trust this device for 30 days" checkbox on the verify + step. Sits above the action row, padded so it doesn't crowd the + passcode/code input. */ +.otc-trust-device { + display: flex; align-items: center; gap: 8px; + font-size: 13px; color: #444; + margin: 8px 0 4px; + cursor: pointer; + user-select: none; +} +.otc-trust-device input[type="checkbox"] { + width: auto; margin: 0; cursor: pointer; +} + +/* v0.11.0 — /settings/devices revoke-device UI. */ +.device-list { + list-style: none; padding: 0; margin: 12px 0 0; +} +.device-list-item { + display: flex; align-items: center; justify-content: space-between; + gap: 12px; + border: 1px solid #eee; border-radius: 6px; + padding: 10px 12px; margin: 0 0 8px; + background: #fafafa; +} +.device-list-item .device-meta { + flex: 1; min-width: 0; +} +.device-list-item .device-ua { + font-size: 13px; color: #1a1a1a; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.device-list-item .device-stamps { + font-size: 12px; color: #777; + margin-top: 2px; +} +.device-list-item button { + font-size: 12px; padding: 4px 10px; + border: 1px solid #ccc; border-radius: 4px; + background: white; cursor: pointer; +} +.device-list-item button:hover:not(:disabled) { + background: #f5f5f5; +} +.device-revoke-all { + margin-top: 8px; + font-size: 13px; padding: 6px 12px; + border: 1px solid #cb6a6a; border-radius: 4px; + background: white; color: #cb6a6a; cursor: pointer; +} +.device-revoke-all:hover:not(:disabled) { + background: #fff5f5; +} +.device-empty { + font-size: 13px; color: #777; + background: #fafafa; border: 1px solid #eee; border-radius: 6px; + padding: 12px; +} + /* --- Beta-pending page (post-OAuth-rejection) --- */ .beta-pending { diff --git a/frontend/src/api.js b/frontend/src/api.js index 19f5df1..6a48b89 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -40,11 +40,16 @@ export async function requestOtc(email) { return jsonOrThrow(res) } -export async function verifyOtc(email, code) { +export async function verifyOtc(email, code, { trustDevice = false } = {}) { + // v0.11.0 — `trustDevice` is the "trust this device for 30 days" + // checkbox on the Login.jsx OTC step. When true, the server mints + // a fresh device-trust row and sets the long-lived cookie; on + // subsequent visits, the cookie skips the OTC roundtrip via + // `startDeviceTrust()`. const res = await fetch('/auth/otc/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, code }), + body: JSON.stringify({ email, code, trust_device: !!trustDevice }), }) return jsonOrThrow(res) } @@ -82,15 +87,44 @@ export async function checkPasscode(email) { return jsonOrThrow(res) } -export async function verifyPasscode(email, passcode) { +export async function verifyPasscode(email, passcode, { trustDevice = false } = {}) { + // v0.11.0 — same trust-device opt-in as `verifyOtc`. const res = await fetch('/auth/passcode/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, passcode }), + body: JSON.stringify({ email, passcode, trust_device: !!trustDevice }), }) return jsonOrThrow(res) } +// ── v0.11.0: trust device for 30 days (§6.2, roadmap item #9) ───────────── +// +// On a returning visit with a valid device-trust cookie, `startDeviceTrust` +// re-establishes the session without an OTC / passcode roundtrip. The +// cookie is HttpOnly so the client cannot read it; the call is a pure POST +// that the browser attaches the cookie to automatically. +// +// `listMyDevices`, `revokeMyDevice`, and `revokeAllMyDevices` drive the +// /settings/devices revoke-device UI. The signed-in user is the implicit +// subject; the cookie carries the session. + +export async function startDeviceTrust() { + const res = await fetch('/auth/device-trust/start', { method: 'POST' }) + return jsonOrThrow(res) +} + +export async function listMyDevices() { + return jsonOrThrow(await fetch('/api/auth/me/devices')) +} + +export async function revokeMyDevice(deviceId) { + return jsonOrThrow(await fetch(`/api/auth/me/devices/${deviceId}`, { method: 'DELETE' })) +} + +export async function revokeAllMyDevices() { + return jsonOrThrow(await fetch('/api/auth/me/devices', { method: 'DELETE' })) +} + export async function setPasscode(passcode) { // Requires an active session — the server returns 401 if not signed // in. The signed-in user is the implicit subject; the body carries diff --git a/frontend/src/components/Login.jsx b/frontend/src/components/Login.jsx index f711397..5e0e2c6 100644 --- a/frontend/src/components/Login.jsx +++ b/frontend/src/components/Login.jsx @@ -72,6 +72,7 @@ import { checkPasscode, verifyPasscode, setPasscode as apiSetPasscode, + startDeviceTrust, } from '../api' export default function Login() { @@ -84,6 +85,13 @@ export default function Login() { const [code, setCode] = useState('') const [passcode, setPasscode] = useState('') const [newPasscode, setNewPasscode] = useState('') + // v0.11.0 — "trust this device for 30 days" checkbox, shared by the + // OTC and passcode verify steps. The flag rides on the verify POST; + // a checked box mints a device-trust row server-side and sets the + // long-lived `rfc_device_trust` cookie. Defaults off so the user + // makes an explicit choice — auth credentials shouldn't persist by + // default. + const [trustDevice, setTrustDevice] = useState(false) // v0.8.0 — capture-profile fields. const [firstName, setFirstName] = useState('') const [lastName, setLastName] = useState('') @@ -105,6 +113,28 @@ export default function Login() { else if (step === 'set-passcode') newPasscodeRef.current?.focus() }, [step]) + // v0.11.0 — on mount, try the device-trust cookie path. If the + // browser still carries a valid `rfc_device_trust` cookie from a + // prior "trust this device" gesture, the server re-establishes the + // session without any user input and we redirect home. The cookie + // is HttpOnly so we can't peek at it; we just call the endpoint and + // see whether it returns 200. 401 (no cookie / invalid / revoked) + // is the structural-silent case — the user proceeds to the email + // step normally. We do not surface any UI about the attempt; a + // failure should be invisible. + useEffect(() => { + let cancelled = false + ;(async () => { + try { + await startDeviceTrust() + if (!cancelled) window.location.assign('/') + } catch (_) { + // No trusted device — fall through to the email step. + } + })() + return () => { cancelled = true } + }, []) + async function submitEmail(e) { e.preventDefault() if (!email.trim() || !email.includes('@')) { @@ -143,7 +173,7 @@ export default function Login() { setBusy(true) setStatus('') try { - await verifyPasscode(email.trim(), passcode.trim()) + await verifyPasscode(email.trim(), passcode.trim(), { trustDevice }) // Reload so App.jsx's getMe() picks up the fresh session. A // returning passcode user is by definition already past the // §6.1 capture step (they couldn't have set a passcode while @@ -189,7 +219,7 @@ export default function Login() { setBusy(true) setStatus('') try { - await verifyOtc(email.trim(), code.trim()) + await verifyOtc(email.trim(), code.trim(), { trustDevice }) // OTC verified — the server has signed in the user. Fetch the // canonical /api/auth/me to decide where to land: // * needs_profile → §6.1 capture (then /beta-pending). @@ -378,6 +408,19 @@ export default function Login() { required disabled={busy} /> + {/* v0.11.0 — trust device for 30 days. The checkbox lives + on the verify step so the user makes the trust gesture + in the same breath as signing in. Off by default; the + user opts in deliberately. */} +
) } +// ── v0.11.0: trusted devices (§6.2, roadmap item #9) ────────────────────── +// +// Lists the user's active device-trust rows and lets them revoke any +// or all. A revoke marks the row dead server-side; the matching +// device's next visit will be refused and the cookie cleared. The +// surface intentionally does not single out the row whose cookie the +// current request carries — every row reads identically, so the user +// can revoke "this device" alongside any other from a single page. + +function DevicesSection() { + const [devices, setDevices] = useState(null) + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + + useEffect(() => { + refresh() + }, []) + + async function refresh() { + try { + const { items } = await listMyDevices() + setDevices(items || []) + setError(null) + } catch (e) { + setError(e.message || 'Could not load trusted devices.') + } + } + + async function onRevoke(deviceId) { + setBusy(true) + try { + await revokeMyDevice(deviceId) + await refresh() + } catch (e) { + setError(e.message || 'Could not revoke device.') + } finally { + setBusy(false) + } + } + + async function onRevokeAll() { + if (!confirm('Revoke trust on every device, including this one? You will be asked to sign in via email next time.')) { + return + } + setBusy(true) + try { + await revokeAllMyDevices() + await refresh() + } catch (e) { + setError(e.message || 'Could not revoke devices.') + } finally { + setBusy(false) + } + } + + return ( + + {devices === null &&

Loading…

} + {devices !== null && devices.length === 0 && ( +

+ No trusted devices. Sign in and check “Trust this device for 30 days” + to add the device you're on now. +

+ )} + {devices !== null && devices.length > 0 && ( + <> + + + + )} + {error &&

{error}

} +
+ ) +} + +function formatStamp(stamp) { + // The server emits SQLite `datetime('now')` strings (UTC, no + // timezone marker). Parse defensively; fall back to the raw stamp + // if Date can't make sense of it. + if (!stamp) return '—' + const d = new Date(stamp.replace(' ', 'T') + 'Z') + if (Number.isNaN(d.getTime())) return stamp + return d.toLocaleString() +} + // ── §6.2 sign-in (v0.10.0 / roadmap item #8): passcode management ────────── function SignInSection() {