-- §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);