Files
Project-SDE-WP-Suite/server/auth.py
Matt Mabrey 6cde6e3f60 T13.1: idle timeout + absolute ceiling (D18)
Sessions now slide on activity (AUTH_IDLE_MINUTES, default 30) capped by
a hard ceiling from original sign-in (AUTH_SESSION_HOURS, meaning changed,
default 12 -> proposed 8). login_at carried across reissues so the ceiling
survives refreshes; pre-D18 tokens with no login_at fall back to iat.
Refresh is throttled (~IDLE_MINUTES/3) so the cookie isn't rewritten on
every request. Wired into auth_gate (server/app.py) - no DB hit, reads
only the already-validated claims.

Verified: 7 unit-level checks (fresh-token expiry, past-ceiling refusal,
throttling, mid-session extension, legacy-token fallback both live and
expired, idle cutoff itself) all pass, plus the full 27-check smoke
suite still passes end to end through the new middleware path.
2026-09-23 11:40:59 -07:00

359 lines
16 KiB
Python

"""Authentication for the Work Package Suite.
Identity is confirmed by Okta (OIDC authorization-code flow, see server/okta_auth.py
and the routes in server/app.py); there is no local password anywhere in this app
(D15, D16 — T10.4 removed the last of it). A successful sign-in issues a signed JWT
that rides in an HttpOnly cookie (`wp_session`). Because the token is signed and
self-validating, there is no server-side session store — every request is checked by
verifying the cookie's signature and expiry (see `auth_gate` and `get_current_user`).
Security model:
• The real boundary is `auth_gate` (middleware in app.py): every /api/ data
route is refused with 401 unless a valid session cookie is present.
• The cookie is HttpOnly (JS can't read it → XSS can't steal the session),
SameSite=Lax (blunts CSRF), and Secure whenever the request arrives over
HTTPS (detected via X-Forwarded-Proto behind NGINX).
• The signing secret comes from AUTH_SECRET_KEY. In production this MUST be
set; if it is missing we fall back to a random per-process key (which logs a
warning and invalidates every session on restart) so dev still works.
Permissions roles (`User.role`) — distinct from a person's job function on the
project, which lives in `User.project_role` and grants nothing. Decided entirely
locally: Okta gates WHO can authenticate at all, this app decides what an
authenticated account may do — see D15/D16.
• admin application administrator: user administration, app settings,
and implicit access to every project.
• project_super_user
everything a project_admin may do, plus USER ADMINISTRATION
scoped to the projects they hold the role on: they create and
manage the accounts on their own jobs without an app admin
having to do it for them. They cannot reach app settings, and
they cannot create or alter an admin / super-user account.
• project_admin within their assigned projects: may delete work packages,
modify a SOP after it has been completed, and delete projects.
• project_user normal member: creates and edits work packages, authors a SOP
up to completion. May NOT delete WPs or change a completed SOP.
Also where Okta JIT provisioning (T10.3) lands a brand-new
account — the lowest-privilege role, promoted locally from
there by an admin (see manage_users.py for the bootstrap case).
The user-administration SCOPE of a super user is worked out in server/app.py
(`managed_project_ids`, `manage_user_problem`), because it depends on project
membership rows — this module only decides which roles carry the power at all.
"""
import os
import secrets
import logging
from datetime import datetime, timedelta, timezone
from typing import Optional
import jwt
from fastapi import Depends, HTTPException, Request, Response, status
from sqlalchemy import select, func
from sqlalchemy.orm import Session
from .db import get_db, DATABASE_URL
from . import models
log = logging.getLogger("wpsuite.auth")
COOKIE_NAME = "wp_session"
JWT_ALG = "HS256"
# D18 (2026-09-23): a session now slides on activity, capped by a hard ceiling
# underneath - not a single flat lifetime. A pure idle timer with no ceiling
# would let a continuously-active session never force a fresh Okta recheck,
# which is a worse fit for this item's own purpose (catching someone still
# active after being deprovisioned) than a flat expiry would have been. Both
# numbers are proposed defaults, not confirmed against the tenant's actual
# Okta SSO session policy - see docs/waves/decisions-2026-09-17.md.
#
# No request for this long invalidates the session outright.
IDLE_MINUTES = int(os.getenv("AUTH_IDLE_MINUTES", "30"))
# The absolute ceiling from the ORIGINAL sign-in, regardless of activity. Same
# env var name as the old flat-lifetime design; the meaning changed, the name
# didn't, because it still answers "how long can this session possibly live."
SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "8"))
# How much later a refreshed exp must be before it's worth rewriting the
# cookie. Without this, an active session gets a new Set-Cookie on literally
# every request - correct, but wasteful, and it makes the cookie a busier
# target than it needs to be. A third of the idle window is a reasonable
# balance: refreshed a few times within any idle window, never every request.
_REFRESH_SLACK = timedelta(minutes=max(1, IDLE_MINUTES // 3))
# ── permissions roles ─────────────────────────────────────────────────────────
ROLE_ADMIN = "admin"
ROLE_PROJECT_SUPER = "project_super_user"
ROLE_PROJECT_ADMIN = "project_admin"
ROLE_PROJECT_USER = "project_user"
# Ordered most- to least-privileged; the console renders dropdowns in this order.
ROLES = (ROLE_ADMIN, ROLE_PROJECT_SUPER, ROLE_PROJECT_ADMIN, ROLE_PROJECT_USER)
ROLE_LABELS = {
ROLE_ADMIN: "Administrator",
ROLE_PROJECT_SUPER: "Project Super User",
ROLE_PROJECT_ADMIN: "Project Admin",
ROLE_PROJECT_USER: "Project User",
}
# Roles that may be held ON A SINGLE PROJECT via ProjectMember.role, so someone can
# run the users on one job and be an ordinary member of the next. '' means "inherit
# the account's own role" and is always allowed alongside these.
PROJECT_SCOPED_ROLES = (ROLE_PROJECT_SUPER, ROLE_PROJECT_ADMIN, ROLE_PROJECT_USER)
# Job functions offered in the admin console. Free text underneath, so a project
# can use a title that isn't on this list.
PROJECT_ROLES = (
"Project Manager", "Assistant Project Manager", "Construction Manager",
"Quality Manager", "Superintendent", "General Foreman", "Foreman",
"Planner / Scheduler", "BIM / VDC Coordinator", "Engineer",
"Safety (HSE)", "Warehouse / Materials", "Commissioning", "Field Technician",
)
def normalize_role(role: Optional[str]) -> str:
"""Map a stored/incoming role onto the current vocabulary.
Accounts created before permissions roles existed carry the legacy value
'user', which means exactly what 'project_user' means now."""
r = (role or "").strip()
if r == "user":
return ROLE_PROJECT_USER
return r if r in ROLES else ROLE_PROJECT_USER
def is_admin(user: "models.User") -> bool:
return normalize_role(user.role) == ROLE_ADMIN
def is_project_admin(user: "models.User") -> bool:
"""True for the roles allowed to delete work packages and change a completed
SOP. A super user is a project admin with user administration on top, so it is
included here — never enumerate the two roles by hand."""
return normalize_role(user.role) in (ROLE_ADMIN, ROLE_PROJECT_SUPER, ROLE_PROJECT_ADMIN)
# NOTE: "may this account administer users?" is deliberately NOT answered here. The
# super-user role can be held per project (ProjectMember.role), so the question needs
# membership rows to answer and lives in app.py — `is_user_manager` /
# `require_user_manager` / `managed_project_ids`. An account-role-only version of the
# same question used to exist here and silently disagreed with the scoped one, which
# locked per-project super users out of the routes they were entitled to.
# Paths under /api that do NOT require a session (the Okta routes themselves, health, docs).
_EXEMPT_PREFIXES = ("/api/auth/",)
_EXEMPT_EXACT = {
"/api/health",
"/api/docs",
"/api/openapi.json",
"/api/docs/oauth2-redirect",
"/api/redoc",
}
def _load_secret() -> str:
s = os.getenv("AUTH_SECRET_KEY")
if s:
return s
# No key configured. In production (a real database is configured via
# POSTGRES_* / DATABASE_URL) this is FATAL — refuse to start rather than sign
# sessions with a throwaway key that silently rotates on every restart. In
# local dev (SQLite, no DB env) fall back to an ephemeral key so the app still
# runs zero-config.
# "Prod" = a real (non-SQLite) database is in use — matches exactly the
# condition db.py uses to pick Postgres, so we don't wrongly block a
# zero-config SQLite dev run just because a stray POSTGRES_USER is exported.
is_prod = not str(DATABASE_URL).startswith("sqlite")
if is_prod:
raise RuntimeError(
"AUTH_SECRET_KEY is not set. Refusing to start in production with an "
"ephemeral signing key — set a strong fixed AUTH_SECRET_KEY "
"(see server/.env.example / DEPLOYMENT.md)."
)
log.warning(
"AUTH_SECRET_KEY is not set — using a random ephemeral key for local dev. "
"Logins reset on restart. Set AUTH_SECRET_KEY for anything non-dev."
)
return secrets.token_urlsafe(48)
SECRET_KEY = _load_secret()
# ── tokens ──────────────────────────────────────────────────────────────────
def _parse_claim_dt(v) -> Optional[datetime]:
"""`login_at` is a custom claim, so unlike `exp`/`iat` (which PyJWT
special-cases for a datetime -> POSIX-timestamp conversion on encode) it
is stored and read back as a plain numeric timestamp. Returns None for
anything unparseable rather than raising - a malformed or legacy token
should fail closed into "no refresh", not 500."""
if v is None:
return None
try:
return datetime.fromtimestamp(float(v), tz=timezone.utc)
except (TypeError, ValueError, OSError):
return None
def _next_exp(login_at: datetime, now: datetime) -> datetime:
"""Whichever comes first: another IDLE_MINUTES of quiet from now, or the
absolute SESSION_HOURS ceiling measured from the session's original
sign-in. Shared by create_token and maybe_refresh_token so the two can't
drift apart."""
ceiling = login_at + timedelta(hours=SESSION_HOURS)
idle_edge = now + timedelta(minutes=IDLE_MINUTES)
return min(ceiling, idle_edge)
def create_token(user: "models.User") -> str:
now = datetime.now(timezone.utc)
payload = {
"sub": user.id,
"username": user.username,
"role": user.role,
"ver": user.token_version or 0,
"iat": now,
"login_at": now.timestamp(),
"exp": _next_exp(now, now),
}
return jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALG)
def maybe_refresh_token(claims: dict) -> Optional[str]:
"""Given a validated token's claims, return a reissued token if the
session is worth extending, or None if nothing should change. Called from
`auth_gate` on every authenticated request (D18) - deliberately reads only
the already-validated claims, never the database, so it costs nothing
beyond the JWT encode itself. The separate is_active/token_version check
in get_current_user is unaffected either way.
Three ways this returns None: past the absolute ceiling (session is done,
full re-login required - never extended, not even by a second); the
claims can't be parsed (fails closed into no-refresh rather than guessing);
or a refresh happened recently enough that a new cookie isn't worth
writing yet (_REFRESH_SLACK)."""
now = datetime.now(timezone.utc)
login_at = _parse_claim_dt(claims.get("login_at"))
if login_at is None:
# Pre-D18 token (no login_at claim) - fall back to iat so it still
# gets a real ceiling instead of riding on the old flat exp forever.
login_at = _parse_claim_dt(claims.get("iat"))
if login_at is None:
return None
ceiling = login_at + timedelta(hours=SESSION_HOURS)
if now >= ceiling:
return None
new_exp = _next_exp(login_at, now)
current_exp = _parse_claim_dt(claims.get("exp"))
if current_exp is not None and (new_exp - current_exp) < _REFRESH_SLACK:
return None
payload = {
"sub": claims.get("sub"),
"username": claims.get("username"),
"role": claims.get("role"),
"ver": claims.get("ver", 0),
"iat": now,
"login_at": login_at.timestamp(),
"exp": new_exp,
}
return jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALG)
def decode_token(token: str) -> Optional[dict]:
"""Return the token claims if the signature and expiry are valid, else None.
Session cookies only — a token of any other type is rejected."""
try:
claims = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
except jwt.PyJWTError:
return None
# No route issues a `typ`-carrying token anymore (that was the password-reset
# token, removed in T10.4), but a session token still must never validate as
# one — kept as a defensive check, cheap insurance against a future token type
# riding the same cookie.
if claims.get("typ"):
return None
return claims
# ── cookie helpers ────────────────────────────────────────────────────────────
def _is_https(request: Request) -> bool:
# Behind NGINX, TLS is terminated at the proxy and forwarded as plain HTTP,
# so trust X-Forwarded-Proto (set in nginx-wp-suite.conf) when present.
xfp = request.headers.get("x-forwarded-proto", "")
if xfp:
return xfp.split(",")[0].strip().lower() == "https"
return request.url.scheme == "https"
def set_session_cookie(response: Response, request: Request, token: str) -> None:
response.set_cookie(
key=COOKIE_NAME,
value=token,
max_age=SESSION_HOURS * 3600,
httponly=True,
secure=_is_https(request),
samesite="lax",
path="/",
)
def clear_session_cookie(response: Response) -> None:
response.delete_cookie(COOKIE_NAME, path="/")
# ── request gate (used as middleware in app.py) ─────────────────────────────────
def _needs_auth(path: str) -> bool:
if not path.startswith("/api/"):
return False # static assets are served by NGINX, not this app
if path in _EXEMPT_EXACT:
return False
return not any(path.startswith(p) for p in _EXEMPT_PREFIXES)
def is_request_authenticated(request: Request) -> Optional[dict]:
"""Validate the session cookie on a raw request. Returns claims or None.
Used by the middleware gate, which has no dependency-injection context."""
token = request.cookies.get(COOKIE_NAME)
if not token:
return None
return decode_token(token)
# ── dependencies (used inside route handlers) ───────────────────────────────────
def get_current_user(request: Request, db: Session = Depends(get_db)) -> "models.User":
"""Resolve the logged-in user from the session cookie, or raise 401.
Unlike the middleware gate (which only checks the token signature), this also
confirms the account still exists and is active — so disabling a user takes
effect on their next request."""
claims = is_request_authenticated(request)
if not claims:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
user = db.get(models.User, claims.get("sub"))
if not user or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Account is inactive")
# Session revocation: a mismatch means the token was invalidated (e.g. the
# password was changed after this token was issued).
if (claims.get("ver", 0) or 0) != (user.token_version or 0):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Session expired")
return user
def require_admin(user: "models.User" = Depends(get_current_user)) -> "models.User":
if not is_admin(user):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user
# ── account helpers (shared by routes and the CLI) ──────────────────────────────
def find_user(db: Session, username: str) -> Optional["models.User"]:
"""Look up by username, case-insensitively (also matches on email)."""
uname = (username or "").strip().lower()
if not uname:
return None
return db.scalars(
select(models.User).where(
(func.lower(models.User.username) == uname)
| (func.lower(models.User.email) == uname)
)
).first()