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 7872b921ed
commit 6fb68a95c7
14 changed files with 1600 additions and 29 deletions
+134 -5
View File
@@ -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