diff --git a/.env.example b/.env.example index 954e52c..0e1a1ca 100644 --- a/.env.example +++ b/.env.example @@ -70,9 +70,10 @@ EXPLORER_ACCESS_DB="/var/lib/explorer/access.db" # The __Host- cookie contract requires HTTPS; central mode fails startup if # this is disabled. AUTH_ALLOW_INSECURE_HTTP exists only for development. EXPLORER_AUTH_COOKIE_SECURE="1" -# Idle and absolute session lifetimes: 60 minutes and 12 hours. -EXPLORER_SESSION_IDLE_SECONDS="3600" -EXPLORER_SESSION_ABSOLUTE_SECONDS="43200" +# Idle and absolute session lifetimes: 12 hours and 24 hours. Short on +# purpose: expiry is a silent redirect while the central Auth session lives. +EXPLORER_SESSION_IDLE_SECONDS="43200" +EXPLORER_SESSION_ABSOLUTE_SECONDS="86400" # ── Session and CSRF cookie ───────────────────────────────────────────── # Signs the central-login state/PKCE transaction plus the short-lived cookie diff --git a/README.md b/README.md index f936d9f..04ff0a1 100644 --- a/README.md +++ b/README.md @@ -148,8 +148,8 @@ Neither dotenv file is committed. `.env.example` is the annotated template: | `EXPLORER_ACCESS_DB` | central mode | `/var/lib/explorer/access.db` | Deployment-local email access list and app sessions; contains no passwords. | | `EXPLORER_AUTH_COOKIE_SECURE` | central mode | `1` | Requires the app-scoped cookie to travel over HTTPS. Keep enabled in production. | | `EXPLORER_UI_COOKIE_SECURE` | no | `1` | Requires the search/CSRF cookie to travel over HTTPS. Keep enabled in production. | -| `EXPLORER_SESSION_IDLE_SECONDS` | no | `3600` | Central-mode Explorer session idle lifetime (60 minutes). | -| `EXPLORER_SESSION_ABSOLUTE_SECONDS` | no | `43200` | Central-mode Explorer session absolute lifetime (12 hours). | +| `EXPLORER_SESSION_IDLE_SECONDS` | no | `43200` | Central-mode Explorer session idle lifetime (12 hours). | +| `EXPLORER_SESSION_ABSOLUTE_SECONDS` | no | `86400` | Central-mode Explorer session absolute lifetime (24 hours). | | `EXPLORER_SESSION_SECRET` | central: **yes**; Elcano: recommended | a dev placeholder | Signs central login state and the cookie scoping search jobs to one browser. Generate with `openssl rand -hex 32`. | ### Authentication @@ -200,9 +200,12 @@ sudo explorer access revoke user@example.com Allowed users receive a random 256-bit, app-only session. Only its SHA-256 hash is stored in `/var/lib/explorer/access.db`; the host-only `__Host-explorer_session` cookie is `Secure`, `HttpOnly`, `SameSite=Lax`, and -scoped to `/`. Sessions expire after 60 minutes idle or 12 hours total; the +scoped to `/`. Sessions expire after 12 hours idle or 24 hours total; the idle clock is refreshed at most once a minute (the Elcano convention for service sessions), so a session can end up to a minute early but never late. +Expiry costs the user only a redirect: while their central Auth session (30 +days) is live, the handoff signs them back in without a prompt. The short +app limit bounds a stolen cookie and re-checks the account with Auth daily. Revoking an email immediately invalidates all of that email's Explorer sessions. Logout is CSRF-protected and revokes only the current Explorer session. Auth's signed back-channel endpoint also revokes every local session diff --git a/app/central_auth.py b/app/central_auth.py index 26e88d7..e1e71c5 100644 --- a/app/central_auth.py +++ b/app/central_auth.py @@ -31,11 +31,18 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey CENTRAL_AUTH_COOKIE_NAME = "__Host-explorer_session" -DEFAULT_IDLE_SECONDS = 60 * 60 -DEFAULT_ABSOLUTE_SECONDS = 12 * 60 * 60 +# Application sessions are deliberately short. Expiry costs the user only a +# redirect: the code handoff signs them back in silently while the 30-day +# central Auth session is live. The short limit bounds a stolen Explorer +# cookie and forces a daily re-check with Auth that the account is still +# enabled. One day absolute, 12 hours idle is the Elcano convention for every +# application session (owner decision 2026-09-15; see Auth's +# docs/AUTH_V2_IMPLEMENTATION.md "Application session conventions"). +DEFAULT_IDLE_SECONDS = 12 * 60 * 60 +DEFAULT_ABSOLUTE_SECONDS = 24 * 60 * 60 # How often a validated session rewrites last_seen_at / idle_expires_at. Every # request reads the session; only a request more than this long after the -# previous touch writes. The idle limit therefore behaves as "60 minutes minus +# previous touch writes. The idle limit therefore behaves as "12 hours minus # at most one minute", never longer, and a page's burst of requests costs one # SQLite write instead of one per request. One minute is the convention for # every Elcano service with its own sessions (Auth, Explorer, Lens, and diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index f1c28c8..242b419 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -180,8 +180,8 @@ but lose to `.env`. | `AUTH_SIGNING_PREVIOUS_PUBKEYS` | no | *(empty)* | Comma-separated prior Ed25519 public keys. Rarely needed now: in central mode Explorer also reads Auth's published `/jwks.json` (cached 10 minutes, refreshed once when a token names an unknown key), so an Auth key rotation needs no env edit here. | | `EXPLORER_ACCESS_DB` | central mode | `/var/lib/explorer/access.db` | SQLite email access list and Explorer session hashes. Keep it outside the application tree and mode `0600`. | | `EXPLORER_AUTH_COOKIE_SECURE` | central mode | `1` | Controls `Secure` on `__Host-explorer_session`. Central mode refuses an insecure setting in production. | -| `EXPLORER_SESSION_IDLE_SECONDS` | no | `3600` | Explorer app-session idle lifetime. Activity refreshes this deadline but never extends the absolute deadline. | -| `EXPLORER_SESSION_ABSOLUTE_SECONDS` | no | `43200` | Explorer app-session absolute lifetime. | +| `EXPLORER_SESSION_IDLE_SECONDS` | no | `43200` | Explorer app-session idle lifetime. Activity refreshes this deadline but never extends the absolute deadline. | +| `EXPLORER_SESSION_ABSOLUTE_SECONDS` | no | `86400` | Explorer app-session absolute lifetime. | Central mode owns no passwords. Manage only the local authorization list: diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index ce52cbe..4c79fc8 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -164,8 +164,8 @@ AUTH_SIGNING_PUBKEY="${AUTH_SIGNING_PUBKEY:-}" AUTH_SIGNING_PUBKEY="$(prompt AUTH_SIGNING_PUBKEY "auth service AUTH_SIGNING_PUBKEY, base64 (run 'auth pubkey' on the auth host; blank to set later)" "$AUTH_SIGNING_PUBKEY")" EXPLORER_ACCESS_DB="${EXPLORER_ACCESS_DB:-/var/lib/explorer/access.db}" -EXPLORER_SESSION_IDLE_SECONDS="${EXPLORER_SESSION_IDLE_SECONDS:-3600}" -EXPLORER_SESSION_ABSOLUTE_SECONDS="${EXPLORER_SESSION_ABSOLUTE_SECONDS:-43200}" +EXPLORER_SESSION_IDLE_SECONDS="${EXPLORER_SESSION_IDLE_SECONDS:-43200}" +EXPLORER_SESSION_ABSOLUTE_SECONDS="${EXPLORER_SESSION_ABSOLUTE_SECONDS:-86400}" # ── Caddy / TLS intent (actual install happens in step 7) ──────── # Collect answers now so the rest of the run has no surprise prompts. diff --git a/tests/test_central_auth_endpoints.py b/tests/test_central_auth_endpoints.py index 0112726..8872348 100644 --- a/tests/test_central_auth_endpoints.py +++ b/tests/test_central_auth_endpoints.py @@ -166,7 +166,7 @@ def test_callback_issues_app_scoped_cookie_for_allowlisted_email( assert "Secure" in cookie assert "SameSite=lax" in cookie assert "Path=/" in cookie - assert "Max-Age=43200" in cookie + assert "Max-Age=86400" in cookie assert "Domain=" not in cookie